mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-07 20:04:00 +00:00
Drape the Active Fires heatmap on Navara too, with the same hand-off fade to the markers
This commit is contained in:
1 parent
0c0df50a40
commit
727bb29cae
8 files changed
+390
-111
No files matched your search
@@ -3866,6 +3866,7 @@
|
||||
"src/layers/shared/render/navaraCellGeometry.js",
|
||||
"src/layers/shared/render/navaraGroundCells.js",
|
||||
"src/layers/firms/render/navaraSprites.js",
|
||||
"src/layers/firms/render/navaraHeat.js",
|
||||
"src/layers/shared/render/navaraGroundGeometry.js",
|
||||
"src/layers/shared/render/navaraGroundGrid.js",
|
||||
"src/layers/shared/render/navaraGroundSurface.js",
|
||||
|
||||
@@ -11,7 +11,8 @@ const settle = async () => {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
};
|
||||
const FIRE = { lat: 38.5, lon: -121.5 };
|
||||
// One cluster a few hundred metres across: one cell from orbit, sprites at the fire.
|
||||
// One cluster a few hundred metres across: one heat spot and one card from
|
||||
// orbit, sprites at the fire.
|
||||
const records = Array.from({ length: 9 }, (_, i) => ({
|
||||
lat: FIRE.lat + (i % 3) * 0.002,
|
||||
lon: FIRE.lon + Math.floor(i / 3) * 0.002,
|
||||
@@ -69,7 +70,17 @@ function fixture() {
|
||||
camera: {},
|
||||
registerEffect() {},
|
||||
addEffect(config) {
|
||||
const handle = { config, update() {}, delete() {} };
|
||||
const handle = {
|
||||
config,
|
||||
visible: config.visible,
|
||||
deleted: false,
|
||||
update(next) {
|
||||
if ('visible' in next) handle.visible = next.visible;
|
||||
},
|
||||
delete() {
|
||||
handle.deleted = true;
|
||||
},
|
||||
};
|
||||
effects.push(handle);
|
||||
return handle;
|
||||
},
|
||||
@@ -77,8 +88,6 @@ function fixture() {
|
||||
EffectDesc: class {},
|
||||
NavaraEffect: class {},
|
||||
};
|
||||
// Cells the layer hands to the ground display for its current build.
|
||||
let cells = [];
|
||||
const noop = () => {};
|
||||
const layer = createFirmsHeatmapLayer({
|
||||
id: 'local-firms',
|
||||
@@ -106,13 +115,8 @@ function fixture() {
|
||||
},
|
||||
overlayHost: { setEntries: noop, setVisible: noop, clearSource: noop },
|
||||
renderer: {
|
||||
create: ({ engine: target, id }) => {
|
||||
const renderer = createFirmsNavaraRenderer(target, { ...native, id });
|
||||
const { addCell, clearCells } = renderer;
|
||||
renderer.addCell = (cell) => (cells.push(cell), addCell(cell));
|
||||
renderer.clearCells = () => ((cells = []), clearCells());
|
||||
return renderer;
|
||||
},
|
||||
create: ({ engine: target, id }) =>
|
||||
createFirmsNavaraRenderer(target, { ...native, id }),
|
||||
},
|
||||
});
|
||||
layer.init(null, { engine });
|
||||
@@ -123,9 +127,20 @@ function fixture() {
|
||||
engine,
|
||||
layer,
|
||||
pose,
|
||||
cells: () => cells,
|
||||
// Ground cells actually drawn by the shared display.
|
||||
drawnCells: () => effects[0]?.config.gevFirmsGround.owner.state.count ?? 0,
|
||||
// The heatmap as drawn: its raster's alpha at the fire, 0 when the
|
||||
// drape is hidden, faded out or unpainted.
|
||||
drawnHeat() {
|
||||
const handle = effects.find(
|
||||
(effect) => effect.config.gevFirmsHeat && !effect.deleted,
|
||||
);
|
||||
const owner = handle?.config.gevFirmsHeat.owner;
|
||||
if (!handle?.visible || !owner?.texture || !(owner.opacity > 0)) return 0;
|
||||
const { data, width, height } = owner.texture.image;
|
||||
const x = Math.floor(((FIRE.lon + 180) / 360) * width);
|
||||
const y = Math.floor(((90 - FIRE.lat) / 180) * height);
|
||||
return (data[(y * width + x) * 4 + 3] / 255) * owner.opacity;
|
||||
},
|
||||
cards: () => layer.getStats().cells,
|
||||
// Fire sprites actually drawn (shown instances of the engine set).
|
||||
drawnSprites() {
|
||||
const set = engine.fake.moverSet('firms:local-firms');
|
||||
@@ -148,21 +163,16 @@ function fixture() {
|
||||
};
|
||||
}
|
||||
|
||||
test('edge:local-firms: orbit to the fire keeps the observation sum and never doubles a marker across LOD changes', async () => {
|
||||
test('edge:local-firms: orbit to the fire hands over between heatmap and markers and never shows both', async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
await f.layer.enable();
|
||||
await f.step();
|
||||
const sum = (list) => list.reduce((n, c) => n + c.properties.count, 0);
|
||||
assert.equal(
|
||||
sum(f.cells()),
|
||||
records.length,
|
||||
'orbit cells hold every observation',
|
||||
);
|
||||
assert.equal(f.drawnCells(), 1);
|
||||
assert.ok(f.drawnHeat() > 0, 'orbit: the heatmap glows at the fire');
|
||||
assert.equal(f.cards(), 1, 'orbit: one card holds the cluster');
|
||||
assert.equal(f.drawnSprites(), 0);
|
||||
for (const round of [1, 2]) {
|
||||
// Down to the fire: detections replace the cell, one sprite per observation.
|
||||
// Down to the fire: markers replace the heatmap, one per observation.
|
||||
f.pose(20_000);
|
||||
await f.step();
|
||||
assert.equal(
|
||||
@@ -171,19 +181,18 @@ test('edge:local-firms: orbit to the fire keeps the observation sum and never do
|
||||
`round ${round} at the fire`,
|
||||
);
|
||||
assert.equal(
|
||||
f.drawnCells(),
|
||||
f.drawnHeat(),
|
||||
0,
|
||||
`round ${round}: no cell under the sprites`,
|
||||
`round ${round}: no heat under the markers`,
|
||||
);
|
||||
// Back to orbit: the cell returns with the same sum, sprites go.
|
||||
// Back to orbit: the heatmap returns, the markers go.
|
||||
f.pose(12_000_000);
|
||||
await f.step();
|
||||
assert.equal(sum(f.cells()), records.length);
|
||||
assert.equal(f.drawnCells(), 1, `round ${round} in orbit`);
|
||||
assert.ok(f.drawnHeat() > 0, `round ${round} in orbit`);
|
||||
assert.equal(
|
||||
f.drawnSprites(),
|
||||
0,
|
||||
`round ${round}: no sprite over the cell`,
|
||||
`round ${round}: no marker over the heatmap`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { selectionDispatcherFor } from '../../../data/selectionDispatcher.js';
|
||||
import { LOD_LEVELS } from '../policy.js';
|
||||
import { createFirmsSpriteBank } from './navaraSprites.js';
|
||||
import { createGroundCellDisplay } from '../../shared/render/navaraGroundCells.js';
|
||||
import { createFirmsHeatDrape } from './navaraHeat.js';
|
||||
export function createFirmsNavaraRenderer(
|
||||
engine,
|
||||
{ view: nativeView, EffectDesc, NavaraEffect, id = 'local-firms' } = {},
|
||||
@@ -121,6 +122,20 @@ export function createFirmsNavaraRenderer(
|
||||
return destroyed ? null : sprites.add(description);
|
||||
},
|
||||
removeSprites,
|
||||
/** The far-zoom heatmap, draped on this view's surface. */
|
||||
createHeatImagery() {
|
||||
return destroyed
|
||||
? null
|
||||
: createFirmsHeatDrape(engine, {
|
||||
view: nativeView,
|
||||
EffectDesc,
|
||||
NavaraEffect,
|
||||
});
|
||||
},
|
||||
/** The hand-off fade: every marker at `alpha`, shown while `show`. */
|
||||
fadeSprites(alpha, show) {
|
||||
if (!destroyed) sprites?.setFade(alpha, show);
|
||||
},
|
||||
onEnable() {},
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
// The Active Fires far-zoom heatmap on Navara: the same equirectangular heat
|
||||
// raster the Cesium globe drapes as imagery (firmsHeatRaster.js), drawn over
|
||||
// visible terrain and 3D tiles by one non-pickable classification pass. Only
|
||||
// surface pixels take it, so the fire markers above stay untouched. The
|
||||
// adapter has heatImagery.js's interface, so the layer drives both alike.
|
||||
import { Effect, EffectAttribute } from 'postprocessing';
|
||||
import {
|
||||
DataTexture,
|
||||
LinearFilter,
|
||||
Matrix4,
|
||||
RGBAFormat,
|
||||
RepeatWrapping,
|
||||
ClampToEdgeWrapping,
|
||||
Uniform,
|
||||
UnsignedByteType,
|
||||
} from 'three';
|
||||
import {
|
||||
groundSurfaceUniforms,
|
||||
updateGroundSurface,
|
||||
} from '../../shared/render/navaraGroundSurface.js';
|
||||
|
||||
const KEY = 'gevFirmsHeat';
|
||||
const registeredViews = new WeakSet();
|
||||
|
||||
// Row 0 of the raster is the north edge and column 0 is 180°W; the texture
|
||||
// keeps that order (no flip), so v = 0 is north.
|
||||
const fragmentShader = /* glsl */ `
|
||||
uniform sampler2D tAllDepth;
|
||||
uniform sampler2D tSurfaceDepth;
|
||||
uniform sampler2D tHeat;
|
||||
uniform mat4 uInverseProjection;
|
||||
uniform mat4 uInverseView;
|
||||
uniform float uLogDepthFC;
|
||||
uniform float uOpacity;
|
||||
// WGS84 first eccentricity squared: geodetic latitude of a near-surface point.
|
||||
const float E2=0.0066943799901413165;
|
||||
vec3 toDisplay(vec3 c) {
|
||||
return mix(12.92*c,1.055*pow(max(c,vec3(0.0)),vec3(1.0/2.4))-0.055,step(vec3(0.0031308),c));
|
||||
}
|
||||
vec3 toLinear(vec3 c) {
|
||||
return mix(c/12.92,pow(max((c+0.055)/1.055,vec3(0.0)),vec3(2.4)),step(vec3(0.04045),c));
|
||||
}
|
||||
void mainImage(const in vec4 inputColor,const in vec2 uv,out vec4 outputColor) {
|
||||
outputColor=inputColor;
|
||||
float depth=unpackRGBAToDepth(texture2D(tAllDepth,uv));
|
||||
if(depth>=1.0) return;
|
||||
float surface=texture2D(tSurfaceDepth,uv).r;
|
||||
if(depth<surface-2e-7) return;
|
||||
float distance=exp2(2.0*depth/uLogDepthFC)-1.0;
|
||||
vec4 ray=uInverseProjection*vec4(uv*2.0-1.0,-1.0,1.0);
|
||||
vec3 point=ray.xyz*(distance/-ray.z);
|
||||
vec3 world=(uInverseView*vec4(point,1.0)).xyz;
|
||||
float rho=length(world.xy);
|
||||
float longitude=atan(world.y,world.x);
|
||||
float latitude=atan(world.z,rho*(1.0-E2));
|
||||
// PI comes from three.js's shader chunks.
|
||||
vec2 st=vec2(longitude/(2.0*PI)+0.5,0.5-latitude/PI);
|
||||
vec4 heat=texture2D(tHeat,st);
|
||||
float alpha=heat.a*uOpacity;
|
||||
if(alpha<=0.0) return;
|
||||
vec3 color=mix(toDisplay(inputColor.rgb),heat.rgb,alpha);
|
||||
outputColor=vec4(toLinear(color),inputColor.a);
|
||||
}
|
||||
`;
|
||||
|
||||
function heatEffectClass(EffectDesc, NavaraEffect) {
|
||||
return class FirmsHeatEffectDesc extends EffectDesc {
|
||||
static key = KEY;
|
||||
// Under the heat cells' and markers' place in the chain: after the other
|
||||
// surface drapes, before sharpening, anti-aliasing and overlays.
|
||||
static insertAfter = [
|
||||
'gevEarthquakeGround',
|
||||
'gevObservedWeather',
|
||||
'gevNepalEvent',
|
||||
'gevRecentImagery',
|
||||
'toneMapping',
|
||||
];
|
||||
static insertBefore = [
|
||||
'gevFirmsGround',
|
||||
'gevPreSharpenOverlay',
|
||||
'gevSharpen',
|
||||
'smaa',
|
||||
'fxaa',
|
||||
'gevOverlay',
|
||||
'final',
|
||||
];
|
||||
constructor(view, context, config) {
|
||||
super(view, context, config);
|
||||
this.owner = config[KEY].owner;
|
||||
}
|
||||
createPass() {
|
||||
this.effect = new Effect('GevFirmsHeat', fragmentShader, {
|
||||
attributes: EffectAttribute.CONVOLUTION,
|
||||
uniforms: new Map([
|
||||
...groundSurfaceUniforms(),
|
||||
['tHeat', new Uniform(null)],
|
||||
['uOpacity', new Uniform(1)],
|
||||
['uInverseView', new Uniform(new Matrix4())],
|
||||
]),
|
||||
});
|
||||
return new NavaraEffect(this.view.camera.raw, this.effect);
|
||||
}
|
||||
updateFrame() {
|
||||
const owner = this.owner,
|
||||
camera = this.view.camera.raw;
|
||||
if (!this.effect || !owner.texture || !camera) return;
|
||||
updateGroundSurface(this.effect, camera, this.ctx, this.find('mrt'));
|
||||
const u = this.effect.uniforms;
|
||||
u.get('tHeat').value = owner.texture;
|
||||
u.get('uOpacity').value = owner.opacity;
|
||||
u.get('uInverseView').value.copy(camera.matrixWorld);
|
||||
}
|
||||
onDestroy() {
|
||||
this.effect?.dispose();
|
||||
super.onDestroy();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The raster as a texture: linear between pixels, wrapping across 180°. Its
|
||||
* sRGB bytes are read as they are and blended in display space, as the heat
|
||||
* cells' colors are.
|
||||
*/
|
||||
function heatTexture(raster) {
|
||||
const texture = new DataTexture(
|
||||
raster.rgba,
|
||||
raster.width,
|
||||
raster.height,
|
||||
RGBAFormat,
|
||||
UnsignedByteType,
|
||||
);
|
||||
texture.minFilter = LinearFilter;
|
||||
texture.magFilter = LinearFilter;
|
||||
texture.wrapS = RepeatWrapping;
|
||||
texture.wrapT = ClampToEdgeWrapping;
|
||||
texture.generateMipmaps = false;
|
||||
texture.needsUpdate = true;
|
||||
return texture;
|
||||
}
|
||||
|
||||
/**
|
||||
* The heatmap drape on one Navara view, with heatImagery.js's interface:
|
||||
* setRaster, hasRaster, setShow, setOpacity (the hand-off fade), rehome and
|
||||
* destroy. Drawn only while shown, painted and above zero opacity.
|
||||
*/
|
||||
export function createFirmsHeatDrape(
|
||||
engine,
|
||||
{ view, EffectDesc, NavaraEffect },
|
||||
) {
|
||||
if (!registeredViews.has(view)) {
|
||||
view.registerEffect(KEY, heatEffectClass(EffectDesc, NavaraEffect));
|
||||
registeredViews.add(view);
|
||||
}
|
||||
const owner = { texture: null, opacity: 1 };
|
||||
let raster = null,
|
||||
show = true,
|
||||
handle = null,
|
||||
destroyed = false;
|
||||
function sync() {
|
||||
if (destroyed) return;
|
||||
const visible = Boolean(owner.texture && show && owner.opacity > 0);
|
||||
if (!handle && visible)
|
||||
handle = view.addEffect({ [KEY]: { owner }, visible: true });
|
||||
else handle?.update({ visible });
|
||||
engine.render.request('firms-heat');
|
||||
}
|
||||
return {
|
||||
setRaster(next) {
|
||||
if (destroyed) return;
|
||||
raster = next;
|
||||
owner.texture?.dispose();
|
||||
owner.texture = next ? heatTexture(next) : null;
|
||||
sync();
|
||||
},
|
||||
/** True once a raster was handed over. */
|
||||
hasRaster() {
|
||||
return raster !== null;
|
||||
},
|
||||
setShow(value) {
|
||||
show = Boolean(value);
|
||||
sync();
|
||||
},
|
||||
/** Hand-off fade, 0..1. */
|
||||
setOpacity(value) {
|
||||
owner.opacity = Math.min(1, Math.max(0, Number(value) || 0));
|
||||
sync();
|
||||
},
|
||||
/** Navara drapes on its own surface; there is no imagery host to follow. */
|
||||
rehome() {},
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
handle?.delete();
|
||||
handle = null;
|
||||
owner.texture?.dispose();
|
||||
owner.texture = null;
|
||||
raster = null;
|
||||
engine.render.request('firms-heat-destroy');
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createFirmsHeatDrape } from './navaraHeat.js';
|
||||
|
||||
function harness() {
|
||||
const effects = [];
|
||||
const requests = [];
|
||||
const view = {
|
||||
registered: [],
|
||||
registerEffect(key) {
|
||||
view.registered.push(key);
|
||||
},
|
||||
addEffect(config) {
|
||||
const handle = {
|
||||
config,
|
||||
visible: config.visible,
|
||||
deleted: false,
|
||||
update(next) {
|
||||
handle.visible = next.visible;
|
||||
},
|
||||
delete() {
|
||||
handle.deleted = true;
|
||||
},
|
||||
};
|
||||
effects.push(handle);
|
||||
return handle;
|
||||
},
|
||||
};
|
||||
const engine = { render: { request: (reason) => requests.push(reason) } };
|
||||
const drape = createFirmsHeatDrape(engine, {
|
||||
view,
|
||||
EffectDesc: class {},
|
||||
NavaraEffect: class {},
|
||||
});
|
||||
return { drape, view, effects, requests };
|
||||
}
|
||||
|
||||
const raster = (alpha) => ({
|
||||
width: 4,
|
||||
height: 2,
|
||||
rgba: new Uint8ClampedArray(4 * 2 * 4).fill(alpha),
|
||||
});
|
||||
|
||||
test('the heatmap drape draws only a painted, shown raster above zero opacity', () => {
|
||||
const h = harness();
|
||||
assert.deepEqual(h.view.registered, ['gevFirmsHeat']);
|
||||
assert.equal(h.drape.hasRaster(), false);
|
||||
assert.equal(h.effects.length, 0, 'nothing to draw before a raster');
|
||||
h.drape.setRaster(raster(200));
|
||||
assert.equal(h.drape.hasRaster(), true);
|
||||
assert.equal(h.effects.length, 1);
|
||||
const [effect] = h.effects;
|
||||
const owner = effect.config.gevFirmsHeat.owner;
|
||||
assert.equal(owner.texture.image.width, 4);
|
||||
assert.equal(owner.texture.image.height, 2);
|
||||
assert.equal(effect.visible, true);
|
||||
h.drape.setOpacity(0);
|
||||
assert.equal(effect.visible, false, 'faded out under the markers');
|
||||
h.drape.setOpacity(0.5);
|
||||
assert.equal(effect.visible, true);
|
||||
assert.equal(owner.opacity, 0.5);
|
||||
h.drape.setShow(false);
|
||||
assert.equal(effect.visible, false, 'the layer is off');
|
||||
h.drape.setShow(true);
|
||||
// A new raster replaces the texture in the same pass.
|
||||
const first = owner.texture;
|
||||
let disposed = false;
|
||||
first.dispose = () => (disposed = true);
|
||||
h.drape.setRaster(raster(100));
|
||||
assert.equal(disposed, true);
|
||||
assert.notEqual(owner.texture, first);
|
||||
assert.equal(h.effects.length, 1);
|
||||
assert.ok(h.requests.length > 0, 'every change asks for a frame');
|
||||
h.drape.destroy();
|
||||
assert.equal(effect.deleted, true);
|
||||
assert.equal(owner.texture, null);
|
||||
assert.equal(h.drape.hasRaster(), false);
|
||||
h.drape.setRaster(raster(50));
|
||||
assert.equal(h.effects.length, 1, 'a destroyed drape draws nothing again');
|
||||
});
|
||||
@@ -27,6 +27,9 @@ export function createFirmsSpriteBank(
|
||||
) {
|
||||
let rows = [],
|
||||
visible = false,
|
||||
// The heatmap hand-off: every marker's alpha, and whether any shows.
|
||||
fade = WHITE,
|
||||
fadeShown = true,
|
||||
destroyed = false,
|
||||
generation = 0,
|
||||
queued = false,
|
||||
@@ -88,7 +91,7 @@ export function createFirmsSpriteBank(
|
||||
}
|
||||
async function flush() {
|
||||
if (destroyed) return;
|
||||
if (!visible || !rows.length) {
|
||||
if (!visible || !fadeShown || !rows.length) {
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
@@ -120,7 +123,7 @@ export function createFirmsSpriteBank(
|
||||
position: row.position,
|
||||
cell: cell.key,
|
||||
scale: cell.scale,
|
||||
color: WHITE,
|
||||
color: fade,
|
||||
show: row.show,
|
||||
pickId: row.id,
|
||||
});
|
||||
@@ -177,6 +180,15 @@ export function createFirmsSpriteBank(
|
||||
schedule();
|
||||
return row;
|
||||
},
|
||||
/** The hand-off fade: every marker at `alpha`, drawn while `show`. */
|
||||
setFade(alpha, show) {
|
||||
if (destroyed) return;
|
||||
const next = alpha >= 1 ? WHITE : Object.freeze([1, 1, 1, alpha]);
|
||||
if (next[3] === fade[3] && Boolean(show) === fadeShown) return;
|
||||
fade = next;
|
||||
fadeShown = Boolean(show);
|
||||
schedule();
|
||||
},
|
||||
setVisible(value) {
|
||||
if (destroyed || visible === Boolean(value)) return;
|
||||
visible = Boolean(value);
|
||||
|
||||
@@ -141,3 +141,37 @@ test('clearing rows during decode preserves the new generation and commits only
|
||||
assert.equal(h.commits, 1);
|
||||
h.bank.destroy();
|
||||
});
|
||||
|
||||
test('the heatmap hand-off fades every marker and hides them all once it completes', async () => {
|
||||
const h = harness(async () => ({ width: 56, height: 56 }));
|
||||
h.bank.add({ id: 'firms-0', position: { x: 1, y: 2, z: 3 }, image: 'a' });
|
||||
h.bank.add({ id: 'firms-1', position: { x: 4, y: 5, z: 6 }, image: 'a' });
|
||||
h.bank.setVisible(true);
|
||||
await settle();
|
||||
await settle();
|
||||
const entry = h.registry.sets.get('firms-test');
|
||||
assert.equal(entry.store.count, 2);
|
||||
assert.deepEqual(
|
||||
[...entry.store.colors.slice(0, 8)],
|
||||
[1, 1, 1, 1, 1, 1, 1, 1],
|
||||
);
|
||||
h.bank.setFade(0.25, true);
|
||||
await settle();
|
||||
await settle();
|
||||
assert.deepEqual(
|
||||
[...entry.store.colors.slice(0, 8)],
|
||||
[1, 1, 1, 0.25, 1, 1, 1, 0.25],
|
||||
);
|
||||
h.bank.setFade(0, false);
|
||||
await settle();
|
||||
await settle();
|
||||
assert.equal(entry.store.count, 0, 'the heatmap alone: no marker drawn');
|
||||
h.bank.setFade(1, true);
|
||||
await settle();
|
||||
await settle();
|
||||
assert.equal(entry.store.count, 2);
|
||||
assert.deepEqual(
|
||||
[...entry.store.colors.slice(0, 8)],
|
||||
[1, 1, 1, 1, 1, 1, 1, 1],
|
||||
);
|
||||
});
|
||||
@@ -69,19 +69,13 @@ export function createRendering({
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw one band. On a globe that can drape the heatmap (Cesium), markers
|
||||
* and heatmap stay built in every band and the hand-off only fades
|
||||
* between them. Elsewhere the far bands draw heat cells instead.
|
||||
* Draw one band. Markers and heatmap stay built in every band; the
|
||||
* hand-off only fades between them.
|
||||
* @param {Object} lod - Band descriptor.
|
||||
* @param {?Object} bounds - Padded view bounds in degrees, or null.
|
||||
*/
|
||||
|
||||
function renderBand(lod, bounds) {
|
||||
if (!heatImagery()) {
|
||||
if (lod.mode === 'detections') renderDetections(lod, bounds);
|
||||
else renderCells(aggregateFires(lod, bounds), lod, bounds);
|
||||
return;
|
||||
}
|
||||
prepareHeat(lod.mode === 'heat');
|
||||
if (lod.mode === 'detections') renderDetections(lod, bounds);
|
||||
else renderHeat(lod, bounds);
|
||||
@@ -91,8 +85,7 @@ export function createRendering({
|
||||
/**
|
||||
* Cross-fade between markers and heatmap by camera height (HANDOFF_FADE),
|
||||
* on every rendered frame, so a fast zoom never outruns the throttled band
|
||||
* check. Cheap when nothing changed: one height read and a compare. A
|
||||
* globe without the heatmap keeps its markers as they are.
|
||||
* check. Cheap when nothing changed: one height read and a compare.
|
||||
* @param {boolean} [force] Reapply, e.g. to freshly added markers.
|
||||
*/
|
||||
|
||||
@@ -175,10 +168,7 @@ export function createRendering({
|
||||
return clipped;
|
||||
}
|
||||
|
||||
/**
|
||||
* The heat imagery adapter, created on first use, or null when the
|
||||
* renderer's globe cannot drape it.
|
||||
*/
|
||||
/** The renderer's heat imagery adapter, created on first use. */
|
||||
|
||||
function heatImagery() {
|
||||
layerState._heatImagery ??=
|
||||
@@ -234,8 +224,8 @@ export function createRendering({
|
||||
}
|
||||
|
||||
/**
|
||||
* Far bands (global/regional) on a globe with the heatmap: a Snap
|
||||
* Map–style heatmap draped over the globe, transparent where nothing
|
||||
* Far bands (global/regional): a Snap Map–style heatmap draped over the
|
||||
* globe, transparent where nothing
|
||||
* burns, plus the decluttered "N FIRES" cards from the coarse aggregation.
|
||||
* The nearest band's markers are kept built underneath for the hand-off
|
||||
* fade and hidden once it completes; cards go through the same
|
||||
@@ -271,69 +261,6 @@ export function createRendering({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Far bands on a globe without the heatmap (Navara): aggregated heat cells
|
||||
* as ground rectangles colored on the FRP ramp by their strongest fire.
|
||||
* Labels go through the same screen-space pipeline as detections.
|
||||
* @param {Array<Object>} cells - Aggregated cells, heat-sorted descending.
|
||||
* @param {Object} lod - Active LOD descriptor.
|
||||
* @param {?Object} bounds - Padded view bounds in degrees, or null.
|
||||
*/
|
||||
|
||||
function renderCells(cells, lod, bounds) {
|
||||
ensureDetectionCollections();
|
||||
layerState._renderer.clearCells();
|
||||
if (layerState._billboards) layerState._billboards.removeAll();
|
||||
layerState._pickIndexById.clear();
|
||||
layerState._cullPositions.length = 0;
|
||||
layerState._cellCount = cells.length;
|
||||
const maxScore = Math.max(1, ...cells.map(components.model.heatScore));
|
||||
|
||||
layerState._labelCandidates = [];
|
||||
layerState._labelLodDistance = lod.labelDistance;
|
||||
|
||||
for (const cell of cells) {
|
||||
const score = components.model.heatScore(cell);
|
||||
const normalized = Math.min(1, Math.sqrt(score / maxScore));
|
||||
const alpha = 0.16 + normalized * 0.5;
|
||||
const color = components.model
|
||||
.frpColorStop(cell.maxFrp)
|
||||
.color.withAlpha(alpha);
|
||||
const centerLon = cell.lonCell + lod.gridDegrees / 2;
|
||||
const centerLat = cell.latCell + lod.gridDegrees / 2;
|
||||
const position = geo.cartesian3.fromDegrees(centerLon, centerLat, 0);
|
||||
|
||||
layerState._renderer.addCell({
|
||||
west: cell.lonCell,
|
||||
south: cell.latCell,
|
||||
east: cell.lonCell + lod.gridDegrees,
|
||||
north: cell.latCell + lod.gridDegrees,
|
||||
color,
|
||||
position,
|
||||
properties: {
|
||||
count: cell.count,
|
||||
intensity: score,
|
||||
maxFrp: cell.maxFrp,
|
||||
night: cell.night,
|
||||
},
|
||||
});
|
||||
|
||||
// Cells arrive heat-sorted, so candidate order doubles as label priority.
|
||||
layerState._labelCandidates.push({
|
||||
position,
|
||||
cullPosition: components.model.cellCullPosition(centerLon, centerLat),
|
||||
fire: null,
|
||||
cell,
|
||||
accent: components.model.cellCardAccent(cell),
|
||||
});
|
||||
}
|
||||
|
||||
components.cards.rebuildAmbientLabels();
|
||||
components.selection.refreshContextRegistrations(
|
||||
components.selection.topFiresWithinBounds(bounds),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render individual detections (local/close bands) as pre-baked
|
||||
* radial-glow sprite billboards sized by FRP, capped per viewport by
|
||||
@@ -496,7 +423,6 @@ export function createRendering({
|
||||
renderBand,
|
||||
aggregateFires,
|
||||
renderHeat,
|
||||
renderCells,
|
||||
renderDetections,
|
||||
applyHandoffFade,
|
||||
ensureDetectionCollections,
|
||||
|
||||
Reference in new issue
Block a user