mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-11 22:02:21 +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/navaraCellGeometry.js",
|
||||||
"src/layers/shared/render/navaraGroundCells.js",
|
"src/layers/shared/render/navaraGroundCells.js",
|
||||||
"src/layers/firms/render/navaraSprites.js",
|
"src/layers/firms/render/navaraSprites.js",
|
||||||
|
"src/layers/firms/render/navaraHeat.js",
|
||||||
"src/layers/shared/render/navaraGroundGeometry.js",
|
"src/layers/shared/render/navaraGroundGeometry.js",
|
||||||
"src/layers/shared/render/navaraGroundGrid.js",
|
"src/layers/shared/render/navaraGroundGrid.js",
|
||||||
"src/layers/shared/render/navaraGroundSurface.js",
|
"src/layers/shared/render/navaraGroundSurface.js",
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ const settle = async () => {
|
|||||||
await new Promise((resolve) => setImmediate(resolve));
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
};
|
};
|
||||||
const FIRE = { lat: 38.5, lon: -121.5 };
|
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) => ({
|
const records = Array.from({ length: 9 }, (_, i) => ({
|
||||||
lat: FIRE.lat + (i % 3) * 0.002,
|
lat: FIRE.lat + (i % 3) * 0.002,
|
||||||
lon: FIRE.lon + Math.floor(i / 3) * 0.002,
|
lon: FIRE.lon + Math.floor(i / 3) * 0.002,
|
||||||
@@ -69,7 +70,17 @@ function fixture() {
|
|||||||
camera: {},
|
camera: {},
|
||||||
registerEffect() {},
|
registerEffect() {},
|
||||||
addEffect(config) {
|
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);
|
effects.push(handle);
|
||||||
return handle;
|
return handle;
|
||||||
},
|
},
|
||||||
@@ -77,8 +88,6 @@ function fixture() {
|
|||||||
EffectDesc: class {},
|
EffectDesc: class {},
|
||||||
NavaraEffect: class {},
|
NavaraEffect: class {},
|
||||||
};
|
};
|
||||||
// Cells the layer hands to the ground display for its current build.
|
|
||||||
let cells = [];
|
|
||||||
const noop = () => {};
|
const noop = () => {};
|
||||||
const layer = createFirmsHeatmapLayer({
|
const layer = createFirmsHeatmapLayer({
|
||||||
id: 'local-firms',
|
id: 'local-firms',
|
||||||
@@ -106,13 +115,8 @@ function fixture() {
|
|||||||
},
|
},
|
||||||
overlayHost: { setEntries: noop, setVisible: noop, clearSource: noop },
|
overlayHost: { setEntries: noop, setVisible: noop, clearSource: noop },
|
||||||
renderer: {
|
renderer: {
|
||||||
create: ({ engine: target, id }) => {
|
create: ({ engine: target, id }) =>
|
||||||
const renderer = createFirmsNavaraRenderer(target, { ...native, id });
|
createFirmsNavaraRenderer(target, { ...native, id }),
|
||||||
const { addCell, clearCells } = renderer;
|
|
||||||
renderer.addCell = (cell) => (cells.push(cell), addCell(cell));
|
|
||||||
renderer.clearCells = () => ((cells = []), clearCells());
|
|
||||||
return renderer;
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
layer.init(null, { engine });
|
layer.init(null, { engine });
|
||||||
@@ -123,9 +127,20 @@ function fixture() {
|
|||||||
engine,
|
engine,
|
||||||
layer,
|
layer,
|
||||||
pose,
|
pose,
|
||||||
cells: () => cells,
|
// The heatmap as drawn: its raster's alpha at the fire, 0 when the
|
||||||
// Ground cells actually drawn by the shared display.
|
// drape is hidden, faded out or unpainted.
|
||||||
drawnCells: () => effects[0]?.config.gevFirmsGround.owner.state.count ?? 0,
|
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).
|
// Fire sprites actually drawn (shown instances of the engine set).
|
||||||
drawnSprites() {
|
drawnSprites() {
|
||||||
const set = engine.fake.moverSet('firms:local-firms');
|
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();
|
const f = fixture();
|
||||||
try {
|
try {
|
||||||
await f.layer.enable();
|
await f.layer.enable();
|
||||||
await f.step();
|
await f.step();
|
||||||
const sum = (list) => list.reduce((n, c) => n + c.properties.count, 0);
|
assert.ok(f.drawnHeat() > 0, 'orbit: the heatmap glows at the fire');
|
||||||
assert.equal(
|
assert.equal(f.cards(), 1, 'orbit: one card holds the cluster');
|
||||||
sum(f.cells()),
|
|
||||||
records.length,
|
|
||||||
'orbit cells hold every observation',
|
|
||||||
);
|
|
||||||
assert.equal(f.drawnCells(), 1);
|
|
||||||
assert.equal(f.drawnSprites(), 0);
|
assert.equal(f.drawnSprites(), 0);
|
||||||
for (const round of [1, 2]) {
|
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);
|
f.pose(20_000);
|
||||||
await f.step();
|
await f.step();
|
||||||
assert.equal(
|
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`,
|
`round ${round} at the fire`,
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
f.drawnCells(),
|
f.drawnHeat(),
|
||||||
0,
|
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);
|
f.pose(12_000_000);
|
||||||
await f.step();
|
await f.step();
|
||||||
assert.equal(sum(f.cells()), records.length);
|
assert.ok(f.drawnHeat() > 0, `round ${round} in orbit`);
|
||||||
assert.equal(f.drawnCells(), 1, `round ${round} in orbit`);
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
f.drawnSprites(),
|
f.drawnSprites(),
|
||||||
0,
|
0,
|
||||||
`round ${round}: no sprite over the cell`,
|
`round ${round}: no marker over the heatmap`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { selectionDispatcherFor } from '../../../data/selectionDispatcher.js';
|
|||||||
import { LOD_LEVELS } from '../policy.js';
|
import { LOD_LEVELS } from '../policy.js';
|
||||||
import { createFirmsSpriteBank } from './navaraSprites.js';
|
import { createFirmsSpriteBank } from './navaraSprites.js';
|
||||||
import { createGroundCellDisplay } from '../../shared/render/navaraGroundCells.js';
|
import { createGroundCellDisplay } from '../../shared/render/navaraGroundCells.js';
|
||||||
|
import { createFirmsHeatDrape } from './navaraHeat.js';
|
||||||
export function createFirmsNavaraRenderer(
|
export function createFirmsNavaraRenderer(
|
||||||
engine,
|
engine,
|
||||||
{ view: nativeView, EffectDesc, NavaraEffect, id = 'local-firms' } = {},
|
{ view: nativeView, EffectDesc, NavaraEffect, id = 'local-firms' } = {},
|
||||||
@@ -121,6 +122,20 @@ export function createFirmsNavaraRenderer(
|
|||||||
return destroyed ? null : sprites.add(description);
|
return destroyed ? null : sprites.add(description);
|
||||||
},
|
},
|
||||||
removeSprites,
|
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() {},
|
onEnable() {},
|
||||||
destroy() {
|
destroy() {
|
||||||
if (destroyed) return;
|
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 = [],
|
let rows = [],
|
||||||
visible = false,
|
visible = false,
|
||||||
|
// The heatmap hand-off: every marker's alpha, and whether any shows.
|
||||||
|
fade = WHITE,
|
||||||
|
fadeShown = true,
|
||||||
destroyed = false,
|
destroyed = false,
|
||||||
generation = 0,
|
generation = 0,
|
||||||
queued = false,
|
queued = false,
|
||||||
@@ -88,7 +91,7 @@ export function createFirmsSpriteBank(
|
|||||||
}
|
}
|
||||||
async function flush() {
|
async function flush() {
|
||||||
if (destroyed) return;
|
if (destroyed) return;
|
||||||
if (!visible || !rows.length) {
|
if (!visible || !fadeShown || !rows.length) {
|
||||||
hide();
|
hide();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -120,7 +123,7 @@ export function createFirmsSpriteBank(
|
|||||||
position: row.position,
|
position: row.position,
|
||||||
cell: cell.key,
|
cell: cell.key,
|
||||||
scale: cell.scale,
|
scale: cell.scale,
|
||||||
color: WHITE,
|
color: fade,
|
||||||
show: row.show,
|
show: row.show,
|
||||||
pickId: row.id,
|
pickId: row.id,
|
||||||
});
|
});
|
||||||
@@ -177,6 +180,15 @@ export function createFirmsSpriteBank(
|
|||||||
schedule();
|
schedule();
|
||||||
return row;
|
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) {
|
setVisible(value) {
|
||||||
if (destroyed || visible === Boolean(value)) return;
|
if (destroyed || visible === Boolean(value)) return;
|
||||||
visible = Boolean(value);
|
visible = Boolean(value);
|
||||||
|
|||||||
@@ -141,3 +141,37 @@ test('clearing rows during decode preserves the new generation and commits only
|
|||||||
assert.equal(h.commits, 1);
|
assert.equal(h.commits, 1);
|
||||||
h.bank.destroy();
|
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
|
* Draw one band. Markers and heatmap stay built in every band; the
|
||||||
* and heatmap stay built in every band and the hand-off only fades
|
* hand-off only fades between them.
|
||||||
* between them. Elsewhere the far bands draw heat cells instead.
|
|
||||||
* @param {Object} lod - Band descriptor.
|
* @param {Object} lod - Band descriptor.
|
||||||
* @param {?Object} bounds - Padded view bounds in degrees, or null.
|
* @param {?Object} bounds - Padded view bounds in degrees, or null.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function renderBand(lod, bounds) {
|
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');
|
prepareHeat(lod.mode === 'heat');
|
||||||
if (lod.mode === 'detections') renderDetections(lod, bounds);
|
if (lod.mode === 'detections') renderDetections(lod, bounds);
|
||||||
else renderHeat(lod, bounds);
|
else renderHeat(lod, bounds);
|
||||||
@@ -91,8 +85,7 @@ export function createRendering({
|
|||||||
/**
|
/**
|
||||||
* Cross-fade between markers and heatmap by camera height (HANDOFF_FADE),
|
* Cross-fade between markers and heatmap by camera height (HANDOFF_FADE),
|
||||||
* on every rendered frame, so a fast zoom never outruns the throttled band
|
* 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
|
* check. Cheap when nothing changed: one height read and a compare.
|
||||||
* globe without the heatmap keeps its markers as they are.
|
|
||||||
* @param {boolean} [force] Reapply, e.g. to freshly added markers.
|
* @param {boolean} [force] Reapply, e.g. to freshly added markers.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -175,10 +168,7 @@ export function createRendering({
|
|||||||
return clipped;
|
return clipped;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** The renderer's heat imagery adapter, created on first use. */
|
||||||
* The heat imagery adapter, created on first use, or null when the
|
|
||||||
* renderer's globe cannot drape it.
|
|
||||||
*/
|
|
||||||
|
|
||||||
function heatImagery() {
|
function heatImagery() {
|
||||||
layerState._heatImagery ??=
|
layerState._heatImagery ??=
|
||||||
@@ -234,8 +224,8 @@ export function createRendering({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Far bands (global/regional) on a globe with the heatmap: a Snap
|
* Far bands (global/regional): a Snap Map–style heatmap draped over the
|
||||||
* Map–style heatmap draped over the globe, transparent where nothing
|
* globe, transparent where nothing
|
||||||
* burns, plus the decluttered "N FIRES" cards from the coarse aggregation.
|
* burns, plus the decluttered "N FIRES" cards from the coarse aggregation.
|
||||||
* The nearest band's markers are kept built underneath for the hand-off
|
* The nearest band's markers are kept built underneath for the hand-off
|
||||||
* fade and hidden once it completes; cards go through the same
|
* 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
|
* Render individual detections (local/close bands) as pre-baked
|
||||||
* radial-glow sprite billboards sized by FRP, capped per viewport by
|
* radial-glow sprite billboards sized by FRP, capped per viewport by
|
||||||
@@ -496,7 +423,6 @@ export function createRendering({
|
|||||||
renderBand,
|
renderBand,
|
||||||
aggregateFires,
|
aggregateFires,
|
||||||
renderHeat,
|
renderHeat,
|
||||||
renderCells,
|
|
||||||
renderDetections,
|
renderDetections,
|
||||||
applyHandoffFade,
|
applyHandoffFade,
|
||||||
ensureDetectionCollections,
|
ensureDetectionCollections,
|
||||||
|
|||||||
Reference in new issue
Block a user