feat(navara): render clipped recent imagery comparisons

This commit is contained in:
German Khokhlov committed 2026-10-29 18:30:49 +04:00
1 parent 5a8bc33fbd
commit f59d134928
15 files changed
+1526 -21

No files matched your search

+10 -5
View File
@@ -2,16 +2,21 @@ import { createRecentImageryLayer } from '../../layers/recentImagery/index.js';
import { createRecentImageryRenderer } from '../../layers/recentImagery/rendering.js';
import { createThumbnailLoader } from '../../layers/recentImagery/thumbnails.js';
import { governorRequestRender } from '../../renderGovernor.js';
import { rendererFor } from './renderers.js';
/** Construct imagery presentation over one explicitly supplied acquisition source. */
export function createApplicationRecentImagery({ source }) {
const options = {
requestRender: governorRequestRender,
tileTemplate: (...args) => source.getTileTemplate(...args),
credit: source.credit || source.label || 'Imagery',
};
return createRecentImageryLayer({
catalog: { searchHls: (...args) => source.searchHls(...args) },
renderer: createRecentImageryRenderer({
requestRender: governorRequestRender,
tileTemplate: (...args) => source.getTileTemplate(...args),
credit: source.credit || source.label || 'Imagery',
}),
rendererFactory: ({ engine }) =>
engine?.kind === 'navara'
? rendererFor('recent-imagery', engine, options)
: createRecentImageryRenderer(options),
thumbnails: createThumbnailLoader({
requestThumbnail: (...args) => source.getThumbnail(...args),
}),
+2
View File
@@ -32,6 +32,7 @@ test('the Navara mode registers migrated renderers once', async () => {
'alpr-cameras',
'cctv',
'transit',
'recent-imagery',
])
assert.equal(layerAvailableOn(id, 'navara'), true, id);
assert.deepEqual(
@@ -50,6 +51,7 @@ test('the Navara mode registers migrated renderers once', async () => {
'radio',
'transit',
'bikeshare',
'recent-imagery',
'ais-live-vessels',
'military-installations',
'local-firms',
+40
View File
@@ -17,6 +17,8 @@ import {
uninstallRenderGovernor,
} from '../renderGovernor.js';
import { initGevVoiceCommands } from '../voice/gevRealtime.js';
import { initNavaraImageryBoxTool } from '../ui/imageryBoxNavara.js';
import { createRecentImageryPanel } from '../ui/recentImagery.js';
// Native mesh capability stays at the composition boundary.
const navaraViews = new WeakMap();
@@ -65,6 +67,15 @@ const NAVARA_CREDIT_KEYS = new Set([
*/
export async function registerNavaraLayerRenderers() {
const loaders = {
'recent-imagery': async () => {
const { createRecentImageryNavaraRenderer } =
await import('../layers/recentImagery/render/navara.js');
return (engine, options) =>
createRecentImageryNavaraRenderer(engine, {
...options,
...navaraViews.get(engine),
});
},
transit: async () => {
const { createTransitNavaraRenderer } =
await import('../layers/transit/render/navara.js');
@@ -252,6 +263,35 @@ export function createViewerlessTools({
defer,
}) {
const { dataManager } = data;
const recentImagery = dataManager.layers.get('recent-imagery')?.module;
if (recentImagery) {
const tool = initNavaraImageryBoxTool({
engine,
onBox: (box) => recentImagery.setBox(box),
onPreview: (box) => recentImagery.setBoxPreview(box),
onCancel: (reason, message, box) => {
if (message) recentImagery.reportBoxRefusal(message, box);
},
onActive: (active) => recentImagery.setToolActive(active),
onEscape: () => recentImagery.clearPreview(),
});
data.presentation.attachRecentImagery((container) =>
createRecentImageryPanel({
container,
engine,
layer: recentImagery,
tool,
}),
);
const handle = { layer: recentImagery, tool };
window.__gevRecentImagery = handle;
defer(() => {
if (window.__gevRecentImagery === handle)
delete window.__gevRecentImagery;
data.presentation.attachRecentImagery(null);
tool.destroy();
});
}
// No first-run welcome: its choices turn on data layers.
if (startChrome)
defer(
+35 -14
View File
@@ -105,6 +105,7 @@ const modeFrom = (value) =>
export function createRecentImageryLayer({
catalog = { searchHls },
renderer,
rendererFactory,
thumbnails,
host = resolveImageryHost,
now = () => new Date(),
@@ -113,6 +114,7 @@ export function createRecentImageryLayer({
clearTimeoutImpl = (id) => globalThis.clearTimeout(id),
} = {}) {
let _viewer = null;
let _engine = null;
let _tileset = null;
let _mapStackController = null;
let _dataManager = null;
@@ -370,14 +372,17 @@ export function createRecentImageryLayer({
}
function syncHost() {
const next = host({ viewer: _viewer, tileset: _tileset }) || {
collection: null,
kind: 'none',
};
const next = renderer?.resolveHost?.() ||
host({ viewer: _viewer, tileset: _tileset }) || {
collection: null,
kind: 'none',
};
const changed =
next.collection !== _hostInfo.collection || next.kind !== _hostInfo.kind;
(next.identity || next.collection) !==
(_hostInfo.identity || _hostInfo.collection) ||
next.kind !== _hostInfo.kind;
_hostInfo = next;
renderer.rebind(next);
renderer?.rebind(next);
return changed;
}
@@ -531,15 +536,16 @@ export function createRecentImageryLayer({
}
function syncRender({ comparison = true } = {}) {
if (_destroyed) return;
if (_destroyed || !renderer) return;
syncHost();
pruneEmpty();
const shown = plan();
trackSwipeStart(shown.swipe);
const live = swipeLive(shown);
renderer.setSplit?.(_split);
for (const slotId of SLOT_IDS) {
const candidate = candidateFor(shown[slotId]);
if (candidate && _hostInfo.collection) {
if (candidate && (_hostInfo.identity || _hostInfo.collection)) {
renderer.showSlot(slotId, candidate, _box, {
alpha: _alpha,
splitDirection: !live
@@ -794,8 +800,12 @@ export function createRecentImageryLayer({
source: 'NASA GIBS · HLS + VIIRS',
updateInterval: 0,
init(viewer) {
init(viewer, { engine } = {}) {
_viewer = viewer || null;
_engine = engine || null;
if (rendererFactory && !renderer)
renderer = rendererFactory({ viewer, engine });
if (!renderer) throw new TypeError('Recent imagery requires a renderer');
return true;
},
@@ -849,7 +859,7 @@ export function createRecentImageryLayer({
resetCatalog();
_previewKey = null;
_swipeKind = 'none';
for (const slotId of SLOT_IDS) renderer.hideSlot(slotId);
for (const slotId of SLOT_IDS) renderer?.hideSlot(slotId);
releaseComparison();
notify();
return true;
@@ -865,12 +875,13 @@ export function createRecentImageryLayer({
clearSplitTimer();
_unsubscribeMap?.();
_unsubscribeMap = null;
renderer.destroy();
renderer?.destroy();
thumbnails.destroy();
_listeners.clear();
_rowListener = null;
_toolHandler = null;
_viewer = null;
_engine = null;
_tileset = null;
_mapStackController = null;
_dataManager = null;
@@ -947,7 +958,9 @@ export function createRecentImageryLayer({
* @returns {boolean}
*/
useCurrentView(viewer = _viewer) {
const rectangle = viewer?.camera?.computeViewRectangle?.();
const rectangle =
_engine?.camera.viewRectangle() ||
viewer?.camera?.computeViewRectangle?.();
if (!rectangle)
return refuseBox('Point the camera at the ground to use the view');
const box = boxFromRectangle(rectangle);
@@ -955,7 +968,7 @@ export function createRecentImageryLayer({
const { width, height } = boxSideKm(box);
return refuseBox(
viewTooLargeMessage(Math.max(width, height)),
viewCentre(viewer) || boxCentre(box),
renderer?.viewCentre?.() || viewCentre(viewer) || boxCentre(box),
);
}
return chooseBox(box);
@@ -969,6 +982,7 @@ export function createRecentImageryLayer({
*/
zoomToFit() {
const target = _boxError ? _refusalTarget : null;
if (target && renderer?.zoomToFit) return renderer.zoomToFit(target);
const camera = _viewer?.camera;
const ellipsoid = ellipsoidOf(_viewer);
if (!target || !camera?.flyTo || !ellipsoid?.cartographicToCartesian)
@@ -991,6 +1005,11 @@ export function createRecentImageryLayer({
return height;
},
/** The box tool's transient ground preview, owned by the renderer. */
setBoxPreview(box) {
renderer?.setBoxPreview?.(box);
},
/** A 10 km box centred on a point. */
boxFromPinAt(lon, lat) {
const box = boxFromPin(lon, lat);
@@ -1170,6 +1189,7 @@ export function createRecentImageryLayer({
const next = clampUnit(value, _split);
if (next === _split) return;
_split = next;
renderer?.setSplit?.(next);
clearSplitTimer();
_splitTimer = setTimeoutImpl(() => {
_splitTimer = null;
@@ -1436,7 +1456,8 @@ export function createRecentImageryLayer({
const shown = plan();
return {
host: _hostInfo.kind,
ownedCount: renderer.ownedCount(),
ownedCount: renderer?.ownedCount() || 0,
renderer: renderer?.diagnostics?.(),
lease: Boolean(_lease),
releasing: Boolean(_releasing),
mode: _mode,
+275
View File
@@ -0,0 +1,275 @@
import { CanvasTexture, LinearFilter } from 'three';
import * as geo from '../../../geo/index.js';
import { PRODUCTS, fitViewHeightM, gibsTemplate } from '../model.js';
import { createImageryGround } from './navaraGround.js';
import {
TILE_SIZE,
imageryTilePlan,
imageryTileUrl,
createImageryTileQueue,
} from './tiles.js';
const keyOf = (b) => [b.west, b.south, b.east, b.north].join(',');
const alphaOf = (x) =>
Number.isFinite(Number(x)) ? Math.max(0, Math.min(1, Number(x))) : 1;
async function loadTile(url, signal) {
const response = await fetch(url, { signal });
if (!response.ok) throw new Error(`Imagery tile HTTP ${response.status}`);
const blob = await response.blob();
signal.throwIfAborted();
return createImageBitmap(blob);
}
/** Two bounded visible mosaics on actual terrain depth, with no paid source. */
export function createRecentImageryNavaraRenderer(engine, options = {}) {
const {
tileTemplate = gibsTemplate,
credit = 'NASA GIBS',
load = loadTile,
} = options;
const owner = { slots: { a: null, b: null }, split: 0.5, preview: null };
const ground = createImageryGround(engine, options, owner);
const queue = createImageryTileQueue({ load });
let destroyed = false,
host = null,
releaseCredit = null;
const wake = () => engine.render.request('recent-imagery');
function disposeAtlas(record) {
const atlas = record.atlas;
record.atlas = null;
if (!atlas) return;
for (const c of atlas.controllers) c.abort();
atlas.texture.dispose();
atlas.canvas.width = atlas.canvas.height = 0;
queue.prune();
}
function updateTiles(record) {
const pose = engine.camera.pose(),
size = engine.host.size();
const metresPerPixel =
(2 * Math.max(1, pose.cartographic.height) * Math.tan(pose.fovy / 2)) /
Math.max(1, size.height);
const level = Math.min(
PRODUCTS[record.candidate.product].maxLevel,
Math.ceil(
Math.log2(
(156543.033928 * Math.cos(pose.cartographic.latitude)) /
metresPerPixel,
),
),
);
const plan = imageryTilePlan(
record.box,
engine.camera.viewRectangle(),
level,
);
if ((record.atlas?.plan.key || null) === (plan?.key || null)) return;
disposeAtlas(record);
if (!plan) {
wake();
return;
}
const canvas = document.createElement('canvas');
canvas.width = plan.width * TILE_SIZE;
canvas.height = plan.height * TILE_SIZE;
const texture = new CanvasTexture(canvas);
texture.flipY = false;
texture.minFilter = texture.magFilter = LinearFilter;
texture.generateMipmaps = false;
const atlas = {
plan,
canvas,
texture,
controllers: new Set(),
loaded: 0,
failed: 0,
};
record.atlas = atlas;
const context = canvas.getContext('2d');
const template = tileTemplate(
record.candidate.product,
record.candidate.day,
),
tasks = [];
for (let y = 0; y < plan.height; y++)
for (let x = 0; x < plan.width; x++)
tasks.push({
url: imageryTileUrl(template, plan.x + x, plan.y + y, plan.z),
controllers: atlas.controllers,
live: () => !destroyed && record.atlas === atlas,
draw(image) {
context.drawImage(
image,
x * TILE_SIZE,
y * TILE_SIZE,
TILE_SIZE,
TILE_SIZE,
);
atlas.loaded++;
texture.needsUpdate = true;
wake();
},
fail() {
atlas.failed++;
},
});
queue.add(tasks);
wake();
}
function sync() {
ground.sync();
const shown = owner.slots.a || owner.slots.b;
if (shown && !releaseCredit)
releaseCredit = engine.credits.addStatic(credit);
else if (!shown && releaseCredit) {
releaseCredit();
releaseCredit = null;
}
wake();
}
function hideSlot(id) {
const record = owner.slots[id];
if (!record) return;
owner.slots[id] = null;
disposeAtlas(record);
sync();
}
const offCamera = engine.camera.changed.addEventListener(() => {
for (const record of Object.values(owner.slots))
if (record) updateTiles(record);
});
return {
resolveHost: () => ({
identity: engine,
kind: engine.maps.current()?.photoreal ? 'tileset' : 'globe',
}),
rebind(next) {
host = next;
},
showSlot(id, candidate, box, { alpha = 1, splitDirection = 'none' } = {}) {
if (
destroyed ||
!['a', 'b'].includes(id) ||
!candidate?.key ||
!PRODUCTS[candidate.product] ||
!box ||
!host?.identity
)
return false;
let record = owner.slots[id];
if (
record &&
(record.candidate.key !== candidate.key ||
keyOf(record.box) !== keyOf(box))
) {
hideSlot(id);
record = null;
}
if (!record) {
record = owner.slots[id] = {
candidate: { ...candidate },
box: { ...box },
alpha: alphaOf(alpha),
split: splitDirection,
atlas: null,
};
updateTiles(record);
sync();
} else if (
record.alpha !== alphaOf(alpha) ||
record.split !== splitDirection
) {
record.alpha = alphaOf(alpha);
record.split = splitDirection;
wake();
}
return true;
},
hideSlot,
setAlpha(id, alpha) {
const r = owner.slots[id],
next = alphaOf(alpha);
if (r && r.alpha !== next) {
r.alpha = next;
wake();
}
},
setSplit(value) {
if (owner.split !== value) {
owner.split = value;
wake();
}
},
setBoxPreview(box) {
owner.preview = box ? { ...box } : null;
ground.sync();
wake();
},
viewCentre() {
const { width, height } = engine.host.size(),
hit = engine.camera.pickEllipsoid({ x: width / 2, y: height / 2 });
const c = hit && geo.cartographic.fromCartesian(hit, geo.ellipsoid.WGS84);
return c
? {
lon: (c.longitude * 180) / Math.PI,
lat: (c.latitude * 180) / Math.PI,
}
: null;
},
zoomToFit(target) {
const height = fitViewHeightM({
...engine.host.size(),
fovy: engine.camera.pose().fovy,
});
engine.camera.flyTo({
destination: geo.cartesian3.fromDegrees(target.lon, target.lat, height),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
duration: 1.2,
});
return height;
},
getOwned: () =>
Object.fromEntries(
Object.entries(owner.slots).map(([id, r]) => [
id,
r
? {
key: r.candidate.key,
kind: 'globe',
alpha: r.alpha,
split: r.split,
}
: null,
]),
),
ownedCount: () => Object.values(owner.slots).filter(Boolean).length,
diagnostics: () => ({
...queue.diagnostics(),
slots: Object.fromEntries(
Object.entries(owner.slots).map(([id, r]) => [
id,
r?.atlas
? {
level: r.atlas.plan.z,
tiles: r.atlas.plan.width * r.atlas.plan.height,
loaded: r.atlas.loaded,
failed: r.atlas.failed,
}
: null,
]),
),
}),
destroy() {
if (destroyed) return;
destroyed = true;
offCamera();
hideSlot('a');
hideSlot('b');
queue.destroy();
ground.destroy();
releaseCredit?.();
releaseCredit = null;
owner.preview = null;
},
};
}
@@ -0,0 +1,126 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createRecentImageryNavaraRenderer } from './navara.js';
test('slot replacement, alpha-only edits, and destruction own textures, credits and late decodes', async () => {
const previousDocument = globalThis.document,
canvases = [],
jobs = [],
draws = [];
globalThis.document = {
createElement() {
const c = {
width: 0,
height: 0,
getContext: () => ({ drawImage: (i) => draws.push(i) }),
};
canvases.push(c);
return c;
},
};
let owner,
removed = 0,
credits = 0,
textureDisposals = 0,
off = 0;
const callbacks = [];
const engine = {
camera: {
changed: {
addEventListener(fn) {
callbacks.push(fn);
return () => off++;
},
},
pose: () => ({
cartographic: { height: 12000, latitude: 0.5 },
fovy: 0.7,
}),
viewRectangle: () => null,
},
host: {
canvas: { width: 1280 },
size: () => ({ width: 1280, height: 800 }),
},
maps: { current: () => ({ photoreal: null }) },
render: { request() {} },
credits: {
addStatic() {
credits++;
return () => credits--;
},
},
};
const view = {
registerEffect() {},
addEffect(config) {
owner = config.gevRecentImagery.owner;
return {
update() {},
delete() {
removed++;
},
};
},
};
const renderer = createRecentImageryNavaraRenderer(engine, {
view,
EffectDesc: class {},
NavaraEffect: class {},
load: (url, signal) =>
new Promise((resolve) => jobs.push({ url, signal, resolve })),
});
try {
const box = { west: -97.77, east: -97.71, south: 30.25, north: 30.29 };
const candidate = {
key: 'S30:2026-10-28',
product: 'S30',
day: '2026-10-28',
};
renderer.rebind(renderer.resolveHost());
renderer.showSlot('a', candidate, box);
await new Promise((resolve) => setImmediate(resolve));
const texture = owner.slots.a.atlas.texture,
canvas = owner.slots.a.atlas.canvas;
texture.addEventListener('dispose', () => textureDisposals++);
const originalJobs = jobs.length;
renderer.showSlot('a', candidate, box, {
alpha: 0.4,
splitDirection: 'left',
});
renderer.setSplit(0.3);
callbacks[0]();
assert.equal(owner.slots.a.atlas.texture, texture);
assert.equal(jobs.length, originalJobs);
assert.equal(owner.split, 0.3);
assert.equal(renderer.getOwned().a.alpha, 0.4);
assert.equal(credits, 1);
renderer.showSlot(
'a',
{ ...candidate, key: 'new', day: '2026-10-27' },
box,
);
assert.equal(textureDisposals, 1);
assert.equal(canvas.width, 0);
assert.ok(jobs.every((j) => j.signal.aborted));
let closed = 0;
for (const j of jobs) j.resolve({ close: () => closed++ });
await new Promise((resolve) => setImmediate(resolve));
assert.equal(draws.length, 0);
assert.equal(closed, originalJobs);
renderer.destroy();
renderer.destroy();
for (const j of jobs.slice(originalJobs))
j.resolve({ close: () => closed++ });
await new Promise((resolve) => setImmediate(resolve));
assert.equal(renderer.ownedCount(), 0);
assert.equal(credits, 0);
assert.equal(removed, 1);
assert.equal(off, 1);
assert.equal(renderer.diagnostics().active, 0);
assert.ok(canvases.every((c) => c.width === 0));
} finally {
renderer.destroy();
globalThis.document = previousDocument;
}
});
@@ -0,0 +1,151 @@
import { Effect, EffectAttribute } from 'postprocessing';
import { Uniform, Matrix4, DataTexture } from 'three';
import {
groundSurfaceUniforms,
updateGroundSurface,
} from '../../shared/render/navaraGroundSurface.js';
const KEY = 'gevRecentImagery',
registered = new WeakSet();
const shader = /* glsl */ `
uniform sampler2D tAllDepth,tSurfaceDepth,tA,tB;
uniform mat4 uInverseProjection,uWorld;
uniform float uLogDepthFC,uSplit,uPixelRatio;
uniform vec4 uBoundsA,uBoundsB,uBoxA,uBoxB,uPreview;
uniform vec2 uLookA,uLookB;
uniform bool uPreviewVisible;
vec3 displayColor(vec3 c) {
return mix(c*12.92,1.055*pow(max(c,vec3(0.0)),vec3(1.0/2.4))-0.055,step(vec3(0.0031308),c));
}
vec3 linearColor(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));
}
bool inBox(vec2 geo,vec4 box) { return geo.x>=box.x && geo.y>=box.y && geo.x<=box.z && geo.y<=box.w; }
vec3 imageColor(vec3 color,sampler2D image,vec4 bounds,vec4 box,vec2 look,vec2 geo,vec2 mercator,float x) {
if(look.x<=0.0 || !inBox(geo,box)) return color;
if((look.y<0.0 && x>uSplit)||(look.y>0.0 && x<uSplit)) return color;
vec2 at=(mercator-bounds.xy)/bounds.zw;
if(any(lessThan(at,vec2(0.0)))||any(greaterThan(at,vec2(1.0)))) return color;
vec4 pixel=texture2D(image,at);
return mix(color,pixel.rgb,pixel.a*look.x);
}
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 || depth<texture2D(tSurfaceDepth,uv).r-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=(uWorld*vec4(ray.xyz*(distance/-ray.z),1.0)).xyz;
float p=length(point.xy),theta=atan(point.z*6378137.0,p*6356752.314245);
float st=sin(theta),ct=cos(theta);
float latitude=atan(point.z+42841.311513*st*st*st,p-42697.672707*ct*ct*ct);
vec2 geo=vec2(atan(point.y,point.x),latitude)*57.29577951308232;
vec2 mercator=vec2((geo.x+180.0)/360.0,(1.0-log(tan(0.7853981633974483+latitude*0.5))/3.141592653589793)/2.0);
vec3 color=displayColor(inputColor.rgb);
color=imageColor(color,tA,uBoundsA,uBoxA,uLookA,geo,mercator,uv.x);
color=imageColor(color,tB,uBoundsB,uBoxB,uLookB,geo,mercator,uv.x);
if(uPreviewVisible && inBox(geo,uPreview)) {
vec2 pixelWidth=max(fwidth(geo),vec2(1e-7));
vec2 edges=min(geo-uPreview.xy,uPreview.zw-geo)/pixelWidth;
float edge=1.0-step(2.0*uPixelRatio,min(edges.x,edges.y));
float dash=step(0.5,fract((gl_FragCoord.x+gl_FragCoord.y)/(12.0*uPixelRatio)));
color=mix(color,vec3(0.545,0.914,1.0),mix(0.12,0.95,edge*dash));
}
outputColor=vec4(linearColor(color),inputColor.a);
}
`;
const boxVector = (b) =>
b ? [b.west, b.south, b.east, b.north] : [0, 0, 0, 0];
export function createImageryGround(
engine,
{ view, EffectDesc, NavaraEffect },
owner,
) {
if (!view || !EffectDesc || !NavaraEffect)
throw new TypeError('Imagery requires Navara ground effects');
if (!registered.has(view)) {
class ImageryEffect extends EffectDesc {
static key = KEY;
static insertAfter = ['toneMapping'];
static insertBefore = [
'gevSharpen',
'smaa',
'fxaa',
'gevPreSharpenOverlay',
'gevOverlay',
'final',
];
constructor(view, context, config) {
super(view, context, config);
this.owner = config[KEY].owner;
}
createPass() {
this.effect = new Effect('GevRecentImagery', shader, {
attributes: EffectAttribute.CONVOLUTION,
uniforms: new Map([
...groundSurfaceUniforms(),
['uWorld', new Uniform(new Matrix4())],
['uSplit', new Uniform(0.5)],
['uPixelRatio', new Uniform(1)],
['uPreview', new Uniform([0, 0, 0, 0])],
['uPreviewVisible', new Uniform(false)],
...['A', 'B'].flatMap((id) => [
[`t${id}`, new Uniform(null)],
[`uBounds${id}`, new Uniform([0, 0, 1, 1])],
[`uBox${id}`, new Uniform([0, 0, 0, 0])],
[`uLook${id}`, new Uniform([0, 0])],
]),
]),
});
return new NavaraEffect(this.view.camera.raw, this.effect);
}
update() {
if (!this.effect) return;
const camera = this.view.camera.raw,
u = this.effect.uniforms,
o = this.owner;
updateGroundSurface(this.effect, camera, this.ctx, this.find('mrt'));
u.get('uWorld').value.copy(camera.matrixWorld);
u.get('uSplit').value = o.split;
u.get('uPixelRatio').value = o.pixelRatio();
u.get('uPreview').value = boxVector(o.preview);
u.get('uPreviewVisible').value = Boolean(o.preview);
for (const id of ['A', 'B']) {
const r = o.slots[id.toLowerCase()];
u.get(`t${id}`).value = r?.atlas?.texture || o.empty;
u.get(`uBounds${id}`).value = r?.atlas?.plan.bounds || [0, 0, 1, 1];
u.get(`uBox${id}`).value = boxVector(r?.box);
u.get(`uLook${id}`).value = [
r?.atlas ? r.alpha : 0,
r?.split === 'left' ? -1 : r?.split === 'right' ? 1 : 0,
];
}
}
onDestroy() {
this.effect?.dispose();
super.onDestroy();
}
}
view.registerEffect(KEY, ImageryEffect);
registered.add(view);
}
owner.empty = new DataTexture(new Uint8Array(4), 1, 1);
owner.empty.needsUpdate = true;
owner.pixelRatio = () =>
engine.host.canvas.width / Math.max(1, engine.host.size().width);
let handle = null;
return {
sync() {
const visible = Boolean(owner.slots.a || owner.slots.b || owner.preview);
if (visible && !handle)
handle = view.addEffect({ [KEY]: { owner }, visible: true });
else handle?.update({ visible });
},
destroy() {
handle?.delete();
handle = null;
owner.empty.dispose();
},
};
}
+113
View File
@@ -0,0 +1,113 @@
const LIMIT = 85.05112878;
export const TILE_SIZE = 256;
export const MAX_TILE_SIDE = 8;
export const mercatorX = (lon) => (lon + 180) / 360;
export const mercatorY = (lat) => {
const r = (Math.max(-LIMIT, Math.min(LIMIT, lat)) * Math.PI) / 180;
return (1 - Math.log(Math.tan(Math.PI / 4 + r / 2)) / Math.PI) / 2;
};
/** At most 8×8 visible source tiles per slot, never a global mosaic. */
export function imageryTilePlan(box, rectangle, level) {
let area = { ...box };
if (rectangle && rectangle.east >= rectangle.west) {
const degrees = 180 / Math.PI;
area = {
west: Math.max(box.west, rectangle.west * degrees),
east: Math.min(box.east, rectangle.east * degrees),
south: Math.max(box.south, rectangle.south * degrees),
north: Math.min(box.north, rectangle.north * degrees),
};
}
if (area.west >= area.east || area.south >= area.north) return null;
for (let z = Math.max(0, Math.floor(level)); z >= 0; z--) {
const n = 2 ** z;
const x = Math.max(0, Math.floor(mercatorX(area.west) * n));
const y = Math.max(0, Math.floor(mercatorY(area.north) * n));
const right = Math.min(n - 1, Math.ceil(mercatorX(area.east) * n) - 1);
const bottom = Math.min(n - 1, Math.ceil(mercatorY(area.south) * n) - 1);
const width = right - x + 1,
height = bottom - y + 1;
if (width <= MAX_TILE_SIDE && height <= MAX_TILE_SIDE)
return {
key: [z, x, y, width, height].join('/'),
z,
x,
y,
width,
height,
bounds: [x / n, y / n, width / n, height / n],
};
}
return null;
}
export function imageryTileUrl(template, x, y, z) {
return template
.replaceAll('{x}', String(x))
.replaceAll('{y}', String(y))
.replaceAll('{z}', String(z))
.replaceAll('{s}', ['a', 'b', 'c'][(x + y) % 3]);
}
/** Cancellation holds its admission slot until fetch AND decode settle. */
export function createImageryTileQueue({ load, limit = 6 }) {
let active = 0,
peak = 0,
destroyed = false;
const pending = [],
controllers = new Set();
function pump() {
while (!destroyed && active < limit && pending.length) {
const task = pending.shift();
if (!task.live()) continue;
const controller = new AbortController();
controllers.add(controller);
task.controllers.add(controller);
active++;
peak = Math.max(peak, active);
Promise.resolve()
.then(() => load(task.url, controller.signal))
.then((image) => {
try {
if (!destroyed && !controller.signal.aborted && task.live())
task.draw(image);
} finally {
image?.close?.();
}
})
.catch((error) => {
if (!destroyed && !controller.signal.aborted && task.live())
task.fail(error);
})
.finally(() => {
controllers.delete(controller);
task.controllers.delete(controller);
active--;
pump();
});
}
}
return {
add(tasks) {
if (!destroyed) {
pending.push(...tasks);
pump();
}
},
prune() {
for (let i = pending.length - 1; i >= 0; i--)
if (!pending[i].live()) pending.splice(i, 1);
},
diagnostics: () => ({
active,
queued: pending.filter((t) => t.live()).length,
peak,
}),
destroy() {
destroyed = true;
pending.length = 0;
for (const c of controllers) c.abort();
},
};
}
@@ -0,0 +1,90 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
imageryTilePlan,
imageryTileUrl,
createImageryTileQueue,
mercatorX,
mercatorY,
} from './tiles.js';
test('visible imagery tiles stay bounded and contain the intersected geographic area', () => {
const box = { west: -100, east: -90, south: 25, north: 35 };
for (const level of [0, 5, 9, 12]) {
const plan = imageryTilePlan(box, null, level),
n = 2 ** plan.z;
assert.ok(plan.width <= 8 && plan.height <= 8);
assert.ok(
plan.x <= mercatorX(box.west) * n &&
plan.x + plan.width >= mercatorX(box.east) * n,
);
assert.ok(
plan.y <= mercatorY(box.north) * n &&
plan.y + plan.height >= mercatorY(box.south) * n,
);
}
const r = (x) => (x * Math.PI) / 180;
const view = {
west: r(-97.75),
east: r(-97.73),
south: r(30.26),
north: r(30.28),
};
const plan = imageryTilePlan(box, view, 12);
assert.equal(plan.z, 12);
assert.ok(plan.width * plan.height < 8);
assert.equal(
imageryTilePlan(box, { west: 0, east: 0.1, south: 0, north: 0.1 }, 12),
null,
);
});
test('source tile addressing retains XYZ order and deterministic subdomain', () => {
assert.equal(
imageryTileUrl('https://gibs-{s}.example/{z}/{y}/{x}.png', 4, 7, 9),
'https://gibs-c.example/9/7/4.png',
);
});
test('six admissions include slow aborted decodes; stale images close without drawing or errors', async () => {
const pending = [],
drawn = [],
failed = [],
closed = [];
let live = true;
const controllers = new Set();
const queue = createImageryTileQueue({
load: (url, signal) =>
new Promise((resolve) => pending.push({ url, signal, resolve })),
});
const task = (id) => ({
url: String(id),
controllers,
live: () => live,
draw: () => drawn.push(id),
fail: () => failed.push(id),
});
queue.add(Array.from({ length: 10 }, (_, i) => task(i)));
await new Promise((resolve) => setImmediate(resolve));
assert.equal(pending.length, 6);
assert.equal(queue.diagnostics().active, 6);
live = false;
for (const c of controllers) c.abort();
queue.prune();
let replacement = true;
queue.add([{ ...task(10), live: () => replacement }]);
assert.equal(queue.diagnostics().active, 6);
for (const p of pending) p.resolve({ close: () => closed.push(p.url) });
await new Promise((resolve) => setImmediate(resolve));
assert.equal(pending.length, 7);
assert.equal(drawn.length, 0);
assert.equal(closed.length, 6);
queue.destroy();
replacement = false;
pending[6].resolve({ close: () => closed.push('last') });
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(drawn, []);
assert.deepEqual(failed, []);
assert.equal(closed.length, 7);
assert.deepEqual(queue.diagnostics(), { active: 0, queued: 0, peak: 6 });
});
+159
View File
@@ -0,0 +1,159 @@
import * as geo from '../geo/index.js';
import {
claimPointer,
pointerOwner,
releasePointer,
} from '../data/inputOwnership.js';
import { validateBox } from '../layers/recentImagery/model.js';
/** Native DOM capture keeps a box drag out of Navara's camera controller. */
export function initNavaraImageryBoxTool({
engine,
onBox,
onPreview,
onCancel,
onActive,
onEscape,
documentRef = globalThis.document,
}) {
const canvas = engine.host.canvas;
let active = false,
destroyed = false,
anchor = null,
corner = null;
let lease = null,
thaw = null,
pointerId = null;
const box = () =>
anchor && {
west: Math.min(anchor.lon, corner.lon),
east: Math.max(anchor.lon, corner.lon),
south: Math.min(anchor.lat, corner.lat),
north: Math.max(anchor.lat, corner.lat),
};
function point(event) {
const rect = canvas.getBoundingClientRect(),
at = { x: event.clientX - rect.left, y: event.clientY - rect.top };
const hit = engine.picker.pickPosition(at) || engine.picker.pickGlobe(at);
const c = hit && geo.cartographic.fromCartesian(hit, geo.ellipsoid.WGS84);
return c
? {
lon: (c.longitude * 180) / Math.PI,
lat: (c.latitude * 180) / Math.PI,
}
: null;
}
const consume = (event) => {
event.preventDefault?.();
event.stopImmediatePropagation?.();
};
function endDrag() {
anchor = corner = null;
if (pointerId !== null && canvas.hasPointerCapture?.(pointerId))
canvas.releasePointerCapture(pointerId);
pointerId = null;
thaw?.();
thaw = null;
onPreview(null);
}
function down(event) {
if (
!active ||
anchor ||
event.button !== 0 ||
event.shiftKey ||
event.ctrlKey ||
event.altKey
)
return;
consume(event);
const p = point(event);
if (!p) {
onCancel?.('sky', 'Press on the ground, not the sky');
return;
}
anchor = corner = p;
pointerId = event.pointerId;
thaw = engine.input.suspendCameraInputs();
canvas.setPointerCapture?.(pointerId);
onPreview(box());
}
function move(event) {
if (!anchor || event.pointerId !== pointerId) return;
consume(event);
corner = point(event) || corner;
onPreview(box());
}
function up(event) {
if (!anchor || event.pointerId !== pointerId) return;
consume(event);
corner = point(event) || corner;
const selected = box(),
result = validateBox(selected);
if (result.reason === 'degenerate') result.message = 'Drag to size the box';
stop();
if (result.ok) onBox(result.box);
else onCancel?.(result.reason, result.message, selected);
}
function key(event) {
if (!active || event.key !== 'Escape') return;
consume(event);
if (onEscape?.() !== true) cancel('escape');
}
function pointerCancel(event) {
if (anchor && event.pointerId === pointerId) cancel('pointer-cancel');
}
const bindings = [
[canvas, 'pointerdown', down],
[documentRef, 'pointermove', move],
[documentRef, 'pointerup', up],
[documentRef, 'pointercancel', pointerCancel],
[documentRef, 'keydown', key],
];
if (documentRef.defaultView)
bindings.push([documentRef.defaultView, 'blur', () => cancel('blur')]);
function stop() {
if (!active) return;
for (const [target, type, listener] of bindings)
target.removeEventListener(type, listener, true);
endDrag();
releasePointer(lease);
lease = null;
active = false;
documentRef.body?.classList.remove('gev-imagery-box');
onActive?.(false);
}
function cancel(reason = 'cancel') {
if (active) {
stop();
onCancel?.(reason);
}
}
return {
start() {
if (destroyed) return false;
if (active) return true;
lease = claimPointer('recent-imagery-box');
if (!lease) {
onCancel?.(
'pointer-busy',
`${pointerOwner()} is using the pointer — close it first`,
);
return false;
}
active = true;
for (const [target, type, listener] of bindings)
target.addEventListener(type, listener, true);
documentRef.body?.classList.add('gev-imagery-box');
onActive?.(true);
return true;
},
cancel,
isActive: () => active,
destroy() {
if (destroyed) return;
stop();
destroyed = true;
},
};
}
+82
View File
@@ -0,0 +1,82 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as geo from '../geo/index.js';
import { pointerOwner } from '../data/inputOwnership.js';
import { initNavaraImageryBoxTool } from './imageryBoxNavara.js';
test('blur and destruction during a Navara box drag return input and pointer ownership', () => {
const target = () => {
const listeners = new Map();
return {
listeners,
addEventListener(type, fn) {
listeners.set(type, fn);
},
removeEventListener(type) {
listeners.delete(type);
},
};
};
const windowRef = target(),
documentRef = {
...target(),
defaultView: windowRef,
body: { classList: { add() {}, remove() {} } },
};
const canvas = {
...target(),
getBoundingClientRect: () => ({ left: 0, top: 0 }),
setPointerCapture() {},
hasPointerCapture: () => true,
releasePointerCapture() {},
};
let suspended = 0,
preview = null;
const engine = {
host: { canvas },
picker: { pickPosition: () => geo.cartesian3.fromDegrees(10, 20, 0) },
input: {
suspendCameraInputs() {
suspended++;
return () => suspended--;
},
},
};
const tool = initNavaraImageryBoxTool({
engine,
documentRef,
onBox() {},
onPreview: (b) => (preview = b),
});
const down = () =>
canvas.listeners.get('pointerdown')({
button: 0,
pointerId: 1,
clientX: 10,
clientY: 20,
preventDefault() {},
stopImmediatePropagation() {},
});
try {
assert.equal(tool.start(), true);
down();
assert.equal(suspended, 1);
assert.ok(preview);
windowRef.listeners.get('blur')();
assert.equal(suspended, 0);
assert.equal(preview, null);
assert.equal(pointerOwner(), null);
assert.equal(tool.isActive(), false);
tool.start();
down();
tool.destroy();
tool.destroy();
assert.equal(suspended, 0);
assert.equal(pointerOwner(), null);
assert.equal(canvas.listeners.size, 0);
assert.equal(documentRef.listeners.size, 0);
assert.equal(windowRef.listeners.size, 0);
} finally {
tool.destroy();
}
});
+6 -1
View File
@@ -136,12 +136,16 @@ export function createRecentImageryPanel({
container,
layer,
viewer = null,
engine = null,
tool = null,
createSplit = createImagerySplit,
fetchImpl = globalThis.fetch?.bind(globalThis),
createObjectUrl = (blob) => URL.createObjectURL(blob),
revokeObjectUrl = (url) => URL.revokeObjectURL(url),
requestRender = () => viewer?.scene?.requestRender?.(),
requestRender = () =>
engine
? engine.render.request('recent-imagery-panel')
: viewer?.scene?.requestRender?.(),
} = {}) {
const document = container?.ownerDocument;
if (!document?.createElement || !layer) return null;
@@ -804,6 +808,7 @@ export function createRecentImageryPanel({
formatValueText: (leftPercent, rightPercent) =>
`${before[2][0].toUpperCase()}${before[2].slice(1)} ${leftPercent} percent, ${after[2]} ${rightPercent} percent`,
getViewportWidth: () =>
Number(engine?.host.size().width) ||
Number(viewer?.scene?.canvas?.clientWidth) ||
Number(document.documentElement?.clientWidth) ||
0,