mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 15:52:21 +00:00
feat(navara): render clipped recent imagery comparisons
This commit is contained in:
1 parent
5a8bc33fbd
commit
f59d134928
15 files changed
+1526
-21
No files matched your search
@@ -1266,5 +1266,9 @@
|
|||||||
"scripts/fixtures/w2/radio.mjs",
|
"scripts/fixtures/w2/radio.mjs",
|
||||||
"scripts/qa-map-policy.mjs",
|
"scripts/qa-map-policy.mjs",
|
||||||
"scripts/qa-map-policy.test.mjs",
|
"scripts/qa-map-policy.test.mjs",
|
||||||
"scripts/qa-map-startup.mjs"
|
"scripts/qa-map-startup.mjs",
|
||||||
|
"src/layers/recentImagery/render/tiles.test.mjs",
|
||||||
|
"src/layers/recentImagery/render/navara.test.mjs",
|
||||||
|
"src/ui/imageryBoxNavara.test.mjs",
|
||||||
|
"scripts/qa-w2-imagery.mjs"
|
||||||
]
|
]
|
||||||
@@ -2571,6 +2571,7 @@
|
|||||||
"src/maps/imageryHost.js",
|
"src/maps/imageryHost.js",
|
||||||
"src/ui/recentImagery.js",
|
"src/ui/recentImagery.js",
|
||||||
"src/ui/imageryBoxTool.js",
|
"src/ui/imageryBoxTool.js",
|
||||||
|
"src/ui/imageryBoxNavara.js",
|
||||||
"src/voice/pointerContext.js",
|
"src/voice/pointerContext.js",
|
||||||
"src/voice/pointerReticle.js",
|
"src/voice/pointerReticle.js",
|
||||||
"src/voice/deixis.js",
|
"src/voice/deixis.js",
|
||||||
@@ -2710,6 +2711,9 @@
|
|||||||
"src/layers/transit/render/navaraSprites.js",
|
"src/layers/transit/render/navaraSprites.js",
|
||||||
"src/layers/transit/render/navaraView.js",
|
"src/layers/transit/render/navaraView.js",
|
||||||
"src/layers/transit/render/navaraTrail.js",
|
"src/layers/transit/render/navaraTrail.js",
|
||||||
|
"src/layers/recentImagery/render/navara.js",
|
||||||
|
"src/layers/recentImagery/render/navaraGround.js",
|
||||||
|
"src/layers/recentImagery/render/tiles.js",
|
||||||
"src/layers/cctv/render/navara.js",
|
"src/layers/cctv/render/navara.js",
|
||||||
"src/layers/cctv/render/navaraMeshes.js",
|
"src/layers/cctv/render/navaraMeshes.js",
|
||||||
"src/layers/cctv/render/navaraGizmo.js",
|
"src/layers/cctv/render/navaraGizmo.js",
|
||||||
|
|||||||
@@ -0,0 +1,428 @@
|
|||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import puppeteer from 'puppeteer';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import { blockPaidQaMaps } from './qa-map-policy.mjs';
|
||||||
|
import { isExpectedPreviewSetupError, redact } from './qa-browserEvidence.mjs';
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const option = (name, fallback) =>
|
||||||
|
args.includes(name) ? args[args.indexOf(name) + 1] : fallback;
|
||||||
|
const url = option('--url', 'http://127.0.0.1:4193');
|
||||||
|
const output = option('--report', '/private/tmp/gev-imagery.json');
|
||||||
|
if (
|
||||||
|
['4173', '4185', '4186', '4187', '4188', '4189', '4194'].includes(
|
||||||
|
new URL(url).port,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new Error('Reserved QA port');
|
||||||
|
const report = {
|
||||||
|
url,
|
||||||
|
checks: [],
|
||||||
|
errors: [],
|
||||||
|
expectedErrors: [],
|
||||||
|
requests: [],
|
||||||
|
};
|
||||||
|
let failLandsat = false,
|
||||||
|
failedTiles = 0;
|
||||||
|
const check = (name, value) => {
|
||||||
|
report.checks.push({ name, pass: Boolean(value) });
|
||||||
|
console.log(`${value ? 'PASS' : 'FAIL'} ${name}`);
|
||||||
|
};
|
||||||
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
const colors = await Promise.all(
|
||||||
|
['#ed3838', '#35c969'].map((background) =>
|
||||||
|
sharp({ create: { width: 256, height: 256, channels: 4, background } })
|
||||||
|
.png()
|
||||||
|
.toBuffer(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
headless: true,
|
||||||
|
args: [
|
||||||
|
'--no-sandbox',
|
||||||
|
'--use-angle=metal',
|
||||||
|
'--enable-gpu',
|
||||||
|
'--disable-background-timer-throttling',
|
||||||
|
'--disable-renderer-backgrounding',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setViewport({ width: 1280, height: 800, deviceScaleFactor: 1 });
|
||||||
|
await blockPaidQaMaps(page);
|
||||||
|
page.on('pageerror', (e) => report.errors.push(redact(e.message)));
|
||||||
|
page.on('console', (message) => {
|
||||||
|
if (message.type() !== 'error') return;
|
||||||
|
const text = redact(message.text());
|
||||||
|
const expected =
|
||||||
|
isExpectedPreviewSetupError(message, {
|
||||||
|
preview: args.includes('--preview'),
|
||||||
|
baseUrl: url,
|
||||||
|
}) ||
|
||||||
|
(failedTiles > 0 &&
|
||||||
|
((/\b503\b/.test(text) &&
|
||||||
|
String(message.location()?.url).includes('HLS_L30')) ||
|
||||||
|
(failLandsat && /Failed to obtain image tile/.test(text))));
|
||||||
|
(expected ? report.expectedErrors : report.errors).push(text);
|
||||||
|
});
|
||||||
|
const session = await page.createCDPSession();
|
||||||
|
await session.send('Fetch.enable', {
|
||||||
|
patterns: [{ urlPattern: '*://*.earthdata.nasa.gov/*' }],
|
||||||
|
});
|
||||||
|
session.on('Fetch.requestPaused', async (event) => {
|
||||||
|
try {
|
||||||
|
const request = new URL(event.request.url);
|
||||||
|
const catalog = request.hostname === 'cmr.earthdata.nasa.gov';
|
||||||
|
const green = request.href.includes('2026-10-27');
|
||||||
|
const tileFailure =
|
||||||
|
failLandsat &&
|
||||||
|
request.pathname.includes('HLS_L30') &&
|
||||||
|
request.hostname.startsWith('gibs');
|
||||||
|
if (tileFailure) failedTiles++;
|
||||||
|
report.requests.push({ host: request.hostname, path: request.pathname });
|
||||||
|
const body = catalog
|
||||||
|
? Buffer.from(
|
||||||
|
JSON.stringify({
|
||||||
|
hits: 2,
|
||||||
|
items: ['2026-10-28', '2026-10-27'].map((day) => ({
|
||||||
|
umm: {
|
||||||
|
TemporalExtent: {
|
||||||
|
RangeDateTime: { BeginningDateTime: `${day}T12:00:00Z` },
|
||||||
|
},
|
||||||
|
AdditionalAttributes: [
|
||||||
|
{ Name: 'CLOUD_COVERAGE', Values: ['0'] },
|
||||||
|
],
|
||||||
|
SpatialExtent: {
|
||||||
|
HorizontalSpatialDomain: {
|
||||||
|
Geometry: {
|
||||||
|
BoundingRectangles: [
|
||||||
|
{
|
||||||
|
WestBoundingCoordinate: -98,
|
||||||
|
EastBoundingCoordinate: -97,
|
||||||
|
SouthBoundingCoordinate: 30,
|
||||||
|
NorthBoundingCoordinate: 31,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
: colors[green ? 1 : 0];
|
||||||
|
await session.send('Fetch.fulfillRequest', {
|
||||||
|
requestId: event.requestId,
|
||||||
|
responseCode: tileFailure ? 503 : 200,
|
||||||
|
responseHeaders: [
|
||||||
|
{
|
||||||
|
name: 'Content-Type',
|
||||||
|
value: catalog ? 'application/json' : 'image/png',
|
||||||
|
},
|
||||||
|
{ name: 'Access-Control-Allow-Origin', value: '*' },
|
||||||
|
{ name: 'Access-Control-Expose-Headers', value: 'Data-Present' },
|
||||||
|
{ name: 'Data-Present', value: 'true' },
|
||||||
|
{ name: 'Cache-Control', value: 'no-store' },
|
||||||
|
],
|
||||||
|
body: body.toString('base64'),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (
|
||||||
|
String(e.message).includes(
|
||||||
|
'Fetch.fulfillRequest): Invalid InterceptionId',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
report.expectedErrors.push(
|
||||||
|
'Fixture response cancelled before fulfillment',
|
||||||
|
);
|
||||||
|
else if (!page.isClosed()) report.errors.push(redact(e.message));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const target = new URL(url);
|
||||||
|
target.searchParams.set('welcome', '0');
|
||||||
|
target.hash = 'lat=30.27&lon=-97.74&alt=12000&heading=0&pitch=-90&map=osm';
|
||||||
|
await page.goto(target.href, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => window.__gevRecentImagery && window.__godsEyeView,
|
||||||
|
{ timeout: 90000 },
|
||||||
|
);
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const app = window.__godsEyeView;
|
||||||
|
await app.styleManager.initialRestorePromise;
|
||||||
|
app.styleManager.orbitController.stop();
|
||||||
|
for (const [id, entry] of app.dataManager.layers)
|
||||||
|
if (entry.enabled) await app.dataManager.setEnabled(id, false);
|
||||||
|
app.styleManager._setDetectionMode('OFF');
|
||||||
|
await app.dataManager.setEnabled('recent-imagery', true);
|
||||||
|
window.__gevRecentImagery.layer.setBox({
|
||||||
|
west: -97.77,
|
||||||
|
east: -97.71,
|
||||||
|
south: 30.25,
|
||||||
|
north: 30.29,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
window.__gevRecentImagery.layer
|
||||||
|
.getSnapshot()
|
||||||
|
.candidates.filter(
|
||||||
|
(c) => c.product === 'S30' && c.thumbnail?.status === 'present',
|
||||||
|
).length >= 2,
|
||||||
|
{ timeout: 30000 },
|
||||||
|
);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const l = window.__gevRecentImagery.layer;
|
||||||
|
const c = l
|
||||||
|
.getSnapshot()
|
||||||
|
.candidates.filter((c) => c.product === 'S30')
|
||||||
|
.sort((a, b) => b.day.localeCompare(a.day));
|
||||||
|
l.setAssignment('a', c[0].key);
|
||||||
|
l.setAssignment('b', c[1].key);
|
||||||
|
l.setMode('image');
|
||||||
|
});
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => window.__godsEyeView.engine.surface.tilesLoaded(),
|
||||||
|
{ timeout: 90000 },
|
||||||
|
);
|
||||||
|
await sleep(4000);
|
||||||
|
const capture = async (name) => {
|
||||||
|
await page.evaluate(() => window.__godsEyeView.requestRender('imagery-qa'));
|
||||||
|
await sleep(350);
|
||||||
|
const buffer = await page.screenshot();
|
||||||
|
await fs.writeFile(output.replace(/\.json$/, `-${name}.png`), buffer);
|
||||||
|
const pixels = await sharp(buffer)
|
||||||
|
.raw()
|
||||||
|
.toBuffer({ resolveWithObject: true });
|
||||||
|
const sample = (x, y) =>
|
||||||
|
Array.from(
|
||||||
|
pixels.data.subarray(
|
||||||
|
(y * pixels.info.width + x) * pixels.info.channels,
|
||||||
|
(y * pixels.info.width + x) * pixels.info.channels + 3,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const result = {
|
||||||
|
left: sample(565, 400),
|
||||||
|
right: sample(715, 400),
|
||||||
|
outside: sample(340, 400),
|
||||||
|
};
|
||||||
|
report[name] = result;
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
const red = (p) => p[0] > p[1] * 1.6 && p[0] > 100;
|
||||||
|
const green = (p) => p[1] > p[0] * 1.6 && p[1] > 100;
|
||||||
|
let image = await capture('image');
|
||||||
|
check(
|
||||||
|
'image pixels inside selected box',
|
||||||
|
red(image.left) && red(image.right),
|
||||||
|
);
|
||||||
|
check('geographic clip outside box', !red(image.outside));
|
||||||
|
await page.evaluate(() => window.__gevRecentImagery.layer.setMode('ab'));
|
||||||
|
await sleep(2500);
|
||||||
|
image = await capture('ab');
|
||||||
|
check('A/B dates on opposite sides', red(image.left) && green(image.right));
|
||||||
|
await page.evaluate(() => window.__gevRecentImagery.layer.swapSides());
|
||||||
|
image = await capture('swap');
|
||||||
|
check('swap reverses dates', green(image.left) && red(image.right));
|
||||||
|
await page.evaluate(() => window.__gevRecentImagery.layer.swapSides());
|
||||||
|
await page.mouse.move(640, 400);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(780, 400, { steps: 10 });
|
||||||
|
await page.mouse.up();
|
||||||
|
image = await capture('divider');
|
||||||
|
check(
|
||||||
|
'physical divider drag changes rendered split',
|
||||||
|
red(image.left) && red(image.right),
|
||||||
|
);
|
||||||
|
await page.focus('.recent-imagery-split-handle');
|
||||||
|
await page.keyboard.press('Home');
|
||||||
|
image = await capture('home');
|
||||||
|
check('divider Home exposes B', green(image.left) && green(image.right));
|
||||||
|
await page.keyboard.press('End');
|
||||||
|
image = await capture('end');
|
||||||
|
check('divider End exposes A', red(image.left) && red(image.right));
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const l = window.__gevRecentImagery.layer;
|
||||||
|
l.setMode('image');
|
||||||
|
l.setAlpha(0);
|
||||||
|
});
|
||||||
|
const base = await capture('transparent');
|
||||||
|
await page.evaluate(() => window.__gevRecentImagery.layer.setAlpha(0.5));
|
||||||
|
const half = await capture('half');
|
||||||
|
check(
|
||||||
|
'half opacity blends source with basemap',
|
||||||
|
half.left.every(
|
||||||
|
(c, i) => Math.abs(c - (base.left[i] + report.image.left[i]) / 2) < 5,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const l = window.__gevRecentImagery.layer;
|
||||||
|
l.setAlpha(1);
|
||||||
|
l.setMode('basemap');
|
||||||
|
});
|
||||||
|
image = await capture('basemap');
|
||||||
|
check(
|
||||||
|
'basemap comparison clips only A',
|
||||||
|
red(image.left) && !red(image.right),
|
||||||
|
);
|
||||||
|
report.beforeToggle = await page.evaluate(() =>
|
||||||
|
window.__gevRecentImagery.layer.diagnostics(),
|
||||||
|
);
|
||||||
|
for (let i = 0; i < 10; i++)
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const app = window.__godsEyeView;
|
||||||
|
await app.dataManager.setEnabled('recent-imagery', false);
|
||||||
|
await app.dataManager.setEnabled('recent-imagery', true);
|
||||||
|
});
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => window.__gevRecentImagery.layer.diagnostics().ownedCount === 1,
|
||||||
|
{ timeout: 30000 },
|
||||||
|
);
|
||||||
|
await sleep(2000);
|
||||||
|
image = await capture('toggles');
|
||||||
|
check('ten toggles restore visible image', red(image.left));
|
||||||
|
report.afterToggle = await page.evaluate(() =>
|
||||||
|
window.__gevRecentImagery.layer.diagnostics(),
|
||||||
|
);
|
||||||
|
if (report.afterToggle.renderer)
|
||||||
|
check(
|
||||||
|
'Navara tile admission stays bounded',
|
||||||
|
report.afterToggle.renderer.peak <= 6 &&
|
||||||
|
Object.values(report.afterToggle.renderer.slots).every(
|
||||||
|
(s) => !s || s.tiles <= 64,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
failLandsat = true;
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const l = window.__gevRecentImagery.layer;
|
||||||
|
l.setMode('image');
|
||||||
|
l.setAssignment(
|
||||||
|
'a',
|
||||||
|
l
|
||||||
|
.getSnapshot()
|
||||||
|
.candidates.find((c) => c.product === 'L30' && c.day === '2026-10-28')
|
||||||
|
.key,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await sleep(2000);
|
||||||
|
image = await capture('source-failure');
|
||||||
|
check(
|
||||||
|
'failed replacement does not leave stale image pixels',
|
||||||
|
failedTiles > 0 && !red(image.left),
|
||||||
|
);
|
||||||
|
report.failedTiles = failedTiles;
|
||||||
|
failLandsat = false;
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const l = window.__gevRecentImagery.layer;
|
||||||
|
const keys = l
|
||||||
|
.getSnapshot()
|
||||||
|
.candidates.filter((c) => c.product === 'S30')
|
||||||
|
.sort((a, b) => b.day.localeCompare(a.day))
|
||||||
|
.map((c) => c.key);
|
||||||
|
for (let i = 0; i < 10; i++) l.setAssignment('a', keys[i % 2]);
|
||||||
|
});
|
||||||
|
await sleep(2000);
|
||||||
|
image = await capture('replaced');
|
||||||
|
check(
|
||||||
|
'rapid replacement leaves final date only',
|
||||||
|
green(image.left) && green(image.right),
|
||||||
|
);
|
||||||
|
await page.evaluate(() => window.__gevRecentImagery.layer.clear());
|
||||||
|
await sleep(1000);
|
||||||
|
check(
|
||||||
|
'clear removes both slots',
|
||||||
|
await page.evaluate(
|
||||||
|
() => window.__gevRecentImagery.layer.diagnostics().ownedCount === 0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const dragStart = await page.evaluate(() => {
|
||||||
|
const app = window.__godsEyeView;
|
||||||
|
return {
|
||||||
|
pose: app.engine.camera.pose().position,
|
||||||
|
started: window.__gevRecentImagery.tool.start(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await page.mouse.move(480, 300);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(780, 500, { steps: 12 });
|
||||||
|
await capture('box-drag');
|
||||||
|
await page.mouse.up();
|
||||||
|
const dragged = await page.evaluate(() => ({
|
||||||
|
box: window.__gevRecentImagery.layer.getSnapshot().box,
|
||||||
|
active: window.__gevRecentImagery.tool.isActive(),
|
||||||
|
pose: window.__godsEyeView.engine.camera.pose().position,
|
||||||
|
}));
|
||||||
|
check(
|
||||||
|
'physical box drag selects geography without moving camera',
|
||||||
|
dragStart.started &&
|
||||||
|
dragged.box &&
|
||||||
|
!dragged.active &&
|
||||||
|
['x', 'y', 'z'].every(
|
||||||
|
(k) => Math.abs(dragged.pose[k] - dragStart.pose[k]) < 0.1,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const usedView = await page.evaluate(() =>
|
||||||
|
window.__gevRecentImagery.layer.useCurrentView(),
|
||||||
|
);
|
||||||
|
check('USE VIEW works on real engine camera', usedView);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const e = window.__godsEyeView.engine,
|
||||||
|
p = e.camera.pose();
|
||||||
|
e.camera.setView({
|
||||||
|
destination: {
|
||||||
|
x: p.position.x * 1.2,
|
||||||
|
y: p.position.y * 1.2,
|
||||||
|
z: p.position.z * 1.2,
|
||||||
|
},
|
||||||
|
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
window.__godsEyeView.engine.camera.pose().cartographic.height > 1000000,
|
||||||
|
{ timeout: 10000 },
|
||||||
|
);
|
||||||
|
await sleep(350);
|
||||||
|
const fit = (report.fit = await page.evaluate(() => {
|
||||||
|
const l = window.__gevRecentImagery.layer;
|
||||||
|
const used = l.useCurrentView();
|
||||||
|
return { used, offered: l.getSnapshot().zoomToFit, height: l.zoomToFit() };
|
||||||
|
}));
|
||||||
|
check(
|
||||||
|
'oversized view refuses and offers bounded zoom',
|
||||||
|
!fit.used && fit.offered && fit.height >= 5000 && fit.height <= 400000,
|
||||||
|
);
|
||||||
|
await sleep(2200);
|
||||||
|
check(
|
||||||
|
'ZOOM IN lands at requested height',
|
||||||
|
await page.evaluate(
|
||||||
|
(h) =>
|
||||||
|
Math.abs(
|
||||||
|
window.__godsEyeView.engine.camera.pose().cartographic.height - h,
|
||||||
|
) < 5,
|
||||||
|
fit.height,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
window.__gevRecentImagery.layer.clear();
|
||||||
|
await window.__godsEyeView.dataManager.setEnabled('recent-imagery', false);
|
||||||
|
});
|
||||||
|
await sleep(1000);
|
||||||
|
report.disabled = await page.evaluate(() =>
|
||||||
|
window.__gevRecentImagery.layer.diagnostics(),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'disable leaves no imagery owners or queued requests',
|
||||||
|
report.disabled.ownedCount === 0 &&
|
||||||
|
(!report.disabled.renderer ||
|
||||||
|
(report.disabled.renderer.active === 0 &&
|
||||||
|
report.disabled.renderer.queued === 0)),
|
||||||
|
);
|
||||||
|
check('no browser exceptions', report.errors.length === 0);
|
||||||
|
} catch (e) {
|
||||||
|
report.errors.push(e.stack);
|
||||||
|
check('driver completes', false);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
await fs.writeFile(output, JSON.stringify(report, null, 2));
|
||||||
|
}
|
||||||
|
if (report.checks.some((c) => !c.pass)) process.exitCode = 1;
|
||||||
@@ -2,16 +2,21 @@ import { createRecentImageryLayer } from '../../layers/recentImagery/index.js';
|
|||||||
import { createRecentImageryRenderer } from '../../layers/recentImagery/rendering.js';
|
import { createRecentImageryRenderer } from '../../layers/recentImagery/rendering.js';
|
||||||
import { createThumbnailLoader } from '../../layers/recentImagery/thumbnails.js';
|
import { createThumbnailLoader } from '../../layers/recentImagery/thumbnails.js';
|
||||||
import { governorRequestRender } from '../../renderGovernor.js';
|
import { governorRequestRender } from '../../renderGovernor.js';
|
||||||
|
import { rendererFor } from './renderers.js';
|
||||||
|
|
||||||
/** Construct imagery presentation over one explicitly supplied acquisition source. */
|
/** Construct imagery presentation over one explicitly supplied acquisition source. */
|
||||||
export function createApplicationRecentImagery({ source }) {
|
export function createApplicationRecentImagery({ source }) {
|
||||||
|
const options = {
|
||||||
|
requestRender: governorRequestRender,
|
||||||
|
tileTemplate: (...args) => source.getTileTemplate(...args),
|
||||||
|
credit: source.credit || source.label || 'Imagery',
|
||||||
|
};
|
||||||
return createRecentImageryLayer({
|
return createRecentImageryLayer({
|
||||||
catalog: { searchHls: (...args) => source.searchHls(...args) },
|
catalog: { searchHls: (...args) => source.searchHls(...args) },
|
||||||
renderer: createRecentImageryRenderer({
|
rendererFactory: ({ engine }) =>
|
||||||
requestRender: governorRequestRender,
|
engine?.kind === 'navara'
|
||||||
tileTemplate: (...args) => source.getTileTemplate(...args),
|
? rendererFor('recent-imagery', engine, options)
|
||||||
credit: source.credit || source.label || 'Imagery',
|
: createRecentImageryRenderer(options),
|
||||||
}),
|
|
||||||
thumbnails: createThumbnailLoader({
|
thumbnails: createThumbnailLoader({
|
||||||
requestThumbnail: (...args) => source.getThumbnail(...args),
|
requestThumbnail: (...args) => source.getThumbnail(...args),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ test('the Navara mode registers migrated renderers once', async () => {
|
|||||||
'alpr-cameras',
|
'alpr-cameras',
|
||||||
'cctv',
|
'cctv',
|
||||||
'transit',
|
'transit',
|
||||||
|
'recent-imagery',
|
||||||
])
|
])
|
||||||
assert.equal(layerAvailableOn(id, 'navara'), true, id);
|
assert.equal(layerAvailableOn(id, 'navara'), true, id);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
@@ -50,6 +51,7 @@ test('the Navara mode registers migrated renderers once', async () => {
|
|||||||
'radio',
|
'radio',
|
||||||
'transit',
|
'transit',
|
||||||
'bikeshare',
|
'bikeshare',
|
||||||
|
'recent-imagery',
|
||||||
'ais-live-vessels',
|
'ais-live-vessels',
|
||||||
'military-installations',
|
'military-installations',
|
||||||
'local-firms',
|
'local-firms',
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import {
|
|||||||
uninstallRenderGovernor,
|
uninstallRenderGovernor,
|
||||||
} from '../renderGovernor.js';
|
} from '../renderGovernor.js';
|
||||||
import { initGevVoiceCommands } from '../voice/gevRealtime.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.
|
// Native mesh capability stays at the composition boundary.
|
||||||
const navaraViews = new WeakMap();
|
const navaraViews = new WeakMap();
|
||||||
@@ -65,6 +67,15 @@ const NAVARA_CREDIT_KEYS = new Set([
|
|||||||
*/
|
*/
|
||||||
export async function registerNavaraLayerRenderers() {
|
export async function registerNavaraLayerRenderers() {
|
||||||
const loaders = {
|
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 () => {
|
transit: async () => {
|
||||||
const { createTransitNavaraRenderer } =
|
const { createTransitNavaraRenderer } =
|
||||||
await import('../layers/transit/render/navara.js');
|
await import('../layers/transit/render/navara.js');
|
||||||
@@ -252,6 +263,35 @@ export function createViewerlessTools({
|
|||||||
defer,
|
defer,
|
||||||
}) {
|
}) {
|
||||||
const { dataManager } = data;
|
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.
|
// No first-run welcome: its choices turn on data layers.
|
||||||
if (startChrome)
|
if (startChrome)
|
||||||
defer(
|
defer(
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ const modeFrom = (value) =>
|
|||||||
export function createRecentImageryLayer({
|
export function createRecentImageryLayer({
|
||||||
catalog = { searchHls },
|
catalog = { searchHls },
|
||||||
renderer,
|
renderer,
|
||||||
|
rendererFactory,
|
||||||
thumbnails,
|
thumbnails,
|
||||||
host = resolveImageryHost,
|
host = resolveImageryHost,
|
||||||
now = () => new Date(),
|
now = () => new Date(),
|
||||||
@@ -113,6 +114,7 @@ export function createRecentImageryLayer({
|
|||||||
clearTimeoutImpl = (id) => globalThis.clearTimeout(id),
|
clearTimeoutImpl = (id) => globalThis.clearTimeout(id),
|
||||||
} = {}) {
|
} = {}) {
|
||||||
let _viewer = null;
|
let _viewer = null;
|
||||||
|
let _engine = null;
|
||||||
let _tileset = null;
|
let _tileset = null;
|
||||||
let _mapStackController = null;
|
let _mapStackController = null;
|
||||||
let _dataManager = null;
|
let _dataManager = null;
|
||||||
@@ -370,14 +372,17 @@ export function createRecentImageryLayer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function syncHost() {
|
function syncHost() {
|
||||||
const next = host({ viewer: _viewer, tileset: _tileset }) || {
|
const next = renderer?.resolveHost?.() ||
|
||||||
collection: null,
|
host({ viewer: _viewer, tileset: _tileset }) || {
|
||||||
kind: 'none',
|
collection: null,
|
||||||
};
|
kind: 'none',
|
||||||
|
};
|
||||||
const changed =
|
const changed =
|
||||||
next.collection !== _hostInfo.collection || next.kind !== _hostInfo.kind;
|
(next.identity || next.collection) !==
|
||||||
|
(_hostInfo.identity || _hostInfo.collection) ||
|
||||||
|
next.kind !== _hostInfo.kind;
|
||||||
_hostInfo = next;
|
_hostInfo = next;
|
||||||
renderer.rebind(next);
|
renderer?.rebind(next);
|
||||||
return changed;
|
return changed;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -531,15 +536,16 @@ export function createRecentImageryLayer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function syncRender({ comparison = true } = {}) {
|
function syncRender({ comparison = true } = {}) {
|
||||||
if (_destroyed) return;
|
if (_destroyed || !renderer) return;
|
||||||
syncHost();
|
syncHost();
|
||||||
pruneEmpty();
|
pruneEmpty();
|
||||||
const shown = plan();
|
const shown = plan();
|
||||||
trackSwipeStart(shown.swipe);
|
trackSwipeStart(shown.swipe);
|
||||||
const live = swipeLive(shown);
|
const live = swipeLive(shown);
|
||||||
|
renderer.setSplit?.(_split);
|
||||||
for (const slotId of SLOT_IDS) {
|
for (const slotId of SLOT_IDS) {
|
||||||
const candidate = candidateFor(shown[slotId]);
|
const candidate = candidateFor(shown[slotId]);
|
||||||
if (candidate && _hostInfo.collection) {
|
if (candidate && (_hostInfo.identity || _hostInfo.collection)) {
|
||||||
renderer.showSlot(slotId, candidate, _box, {
|
renderer.showSlot(slotId, candidate, _box, {
|
||||||
alpha: _alpha,
|
alpha: _alpha,
|
||||||
splitDirection: !live
|
splitDirection: !live
|
||||||
@@ -794,8 +800,12 @@ export function createRecentImageryLayer({
|
|||||||
source: 'NASA GIBS · HLS + VIIRS',
|
source: 'NASA GIBS · HLS + VIIRS',
|
||||||
updateInterval: 0,
|
updateInterval: 0,
|
||||||
|
|
||||||
init(viewer) {
|
init(viewer, { engine } = {}) {
|
||||||
_viewer = viewer || null;
|
_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;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -849,7 +859,7 @@ export function createRecentImageryLayer({
|
|||||||
resetCatalog();
|
resetCatalog();
|
||||||
_previewKey = null;
|
_previewKey = null;
|
||||||
_swipeKind = 'none';
|
_swipeKind = 'none';
|
||||||
for (const slotId of SLOT_IDS) renderer.hideSlot(slotId);
|
for (const slotId of SLOT_IDS) renderer?.hideSlot(slotId);
|
||||||
releaseComparison();
|
releaseComparison();
|
||||||
notify();
|
notify();
|
||||||
return true;
|
return true;
|
||||||
@@ -865,12 +875,13 @@ export function createRecentImageryLayer({
|
|||||||
clearSplitTimer();
|
clearSplitTimer();
|
||||||
_unsubscribeMap?.();
|
_unsubscribeMap?.();
|
||||||
_unsubscribeMap = null;
|
_unsubscribeMap = null;
|
||||||
renderer.destroy();
|
renderer?.destroy();
|
||||||
thumbnails.destroy();
|
thumbnails.destroy();
|
||||||
_listeners.clear();
|
_listeners.clear();
|
||||||
_rowListener = null;
|
_rowListener = null;
|
||||||
_toolHandler = null;
|
_toolHandler = null;
|
||||||
_viewer = null;
|
_viewer = null;
|
||||||
|
_engine = null;
|
||||||
_tileset = null;
|
_tileset = null;
|
||||||
_mapStackController = null;
|
_mapStackController = null;
|
||||||
_dataManager = null;
|
_dataManager = null;
|
||||||
@@ -947,7 +958,9 @@ export function createRecentImageryLayer({
|
|||||||
* @returns {boolean}
|
* @returns {boolean}
|
||||||
*/
|
*/
|
||||||
useCurrentView(viewer = _viewer) {
|
useCurrentView(viewer = _viewer) {
|
||||||
const rectangle = viewer?.camera?.computeViewRectangle?.();
|
const rectangle =
|
||||||
|
_engine?.camera.viewRectangle() ||
|
||||||
|
viewer?.camera?.computeViewRectangle?.();
|
||||||
if (!rectangle)
|
if (!rectangle)
|
||||||
return refuseBox('Point the camera at the ground to use the view');
|
return refuseBox('Point the camera at the ground to use the view');
|
||||||
const box = boxFromRectangle(rectangle);
|
const box = boxFromRectangle(rectangle);
|
||||||
@@ -955,7 +968,7 @@ export function createRecentImageryLayer({
|
|||||||
const { width, height } = boxSideKm(box);
|
const { width, height } = boxSideKm(box);
|
||||||
return refuseBox(
|
return refuseBox(
|
||||||
viewTooLargeMessage(Math.max(width, height)),
|
viewTooLargeMessage(Math.max(width, height)),
|
||||||
viewCentre(viewer) || boxCentre(box),
|
renderer?.viewCentre?.() || viewCentre(viewer) || boxCentre(box),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return chooseBox(box);
|
return chooseBox(box);
|
||||||
@@ -969,6 +982,7 @@ export function createRecentImageryLayer({
|
|||||||
*/
|
*/
|
||||||
zoomToFit() {
|
zoomToFit() {
|
||||||
const target = _boxError ? _refusalTarget : null;
|
const target = _boxError ? _refusalTarget : null;
|
||||||
|
if (target && renderer?.zoomToFit) return renderer.zoomToFit(target);
|
||||||
const camera = _viewer?.camera;
|
const camera = _viewer?.camera;
|
||||||
const ellipsoid = ellipsoidOf(_viewer);
|
const ellipsoid = ellipsoidOf(_viewer);
|
||||||
if (!target || !camera?.flyTo || !ellipsoid?.cartographicToCartesian)
|
if (!target || !camera?.flyTo || !ellipsoid?.cartographicToCartesian)
|
||||||
@@ -991,6 +1005,11 @@ export function createRecentImageryLayer({
|
|||||||
return height;
|
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. */
|
/** A 10 km box centred on a point. */
|
||||||
boxFromPinAt(lon, lat) {
|
boxFromPinAt(lon, lat) {
|
||||||
const box = boxFromPin(lon, lat);
|
const box = boxFromPin(lon, lat);
|
||||||
@@ -1170,6 +1189,7 @@ export function createRecentImageryLayer({
|
|||||||
const next = clampUnit(value, _split);
|
const next = clampUnit(value, _split);
|
||||||
if (next === _split) return;
|
if (next === _split) return;
|
||||||
_split = next;
|
_split = next;
|
||||||
|
renderer?.setSplit?.(next);
|
||||||
clearSplitTimer();
|
clearSplitTimer();
|
||||||
_splitTimer = setTimeoutImpl(() => {
|
_splitTimer = setTimeoutImpl(() => {
|
||||||
_splitTimer = null;
|
_splitTimer = null;
|
||||||
@@ -1436,7 +1456,8 @@ export function createRecentImageryLayer({
|
|||||||
const shown = plan();
|
const shown = plan();
|
||||||
return {
|
return {
|
||||||
host: _hostInfo.kind,
|
host: _hostInfo.kind,
|
||||||
ownedCount: renderer.ownedCount(),
|
ownedCount: renderer?.ownedCount() || 0,
|
||||||
|
renderer: renderer?.diagnostics?.(),
|
||||||
lease: Boolean(_lease),
|
lease: Boolean(_lease),
|
||||||
releasing: Boolean(_releasing),
|
releasing: Boolean(_releasing),
|
||||||
mode: _mode,
|
mode: _mode,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -136,12 +136,16 @@ export function createRecentImageryPanel({
|
|||||||
container,
|
container,
|
||||||
layer,
|
layer,
|
||||||
viewer = null,
|
viewer = null,
|
||||||
|
engine = null,
|
||||||
tool = null,
|
tool = null,
|
||||||
createSplit = createImagerySplit,
|
createSplit = createImagerySplit,
|
||||||
fetchImpl = globalThis.fetch?.bind(globalThis),
|
fetchImpl = globalThis.fetch?.bind(globalThis),
|
||||||
createObjectUrl = (blob) => URL.createObjectURL(blob),
|
createObjectUrl = (blob) => URL.createObjectURL(blob),
|
||||||
revokeObjectUrl = (url) => URL.revokeObjectURL(url),
|
revokeObjectUrl = (url) => URL.revokeObjectURL(url),
|
||||||
requestRender = () => viewer?.scene?.requestRender?.(),
|
requestRender = () =>
|
||||||
|
engine
|
||||||
|
? engine.render.request('recent-imagery-panel')
|
||||||
|
: viewer?.scene?.requestRender?.(),
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const document = container?.ownerDocument;
|
const document = container?.ownerDocument;
|
||||||
if (!document?.createElement || !layer) return null;
|
if (!document?.createElement || !layer) return null;
|
||||||
@@ -804,6 +808,7 @@ export function createRecentImageryPanel({
|
|||||||
formatValueText: (leftPercent, rightPercent) =>
|
formatValueText: (leftPercent, rightPercent) =>
|
||||||
`${before[2][0].toUpperCase()}${before[2].slice(1)} ${leftPercent} percent, ${after[2]} ${rightPercent} percent`,
|
`${before[2][0].toUpperCase()}${before[2].slice(1)} ${leftPercent} percent, ${after[2]} ${rightPercent} percent`,
|
||||||
getViewportWidth: () =>
|
getViewportWidth: () =>
|
||||||
|
Number(engine?.host.size().width) ||
|
||||||
Number(viewer?.scene?.canvas?.clientWidth) ||
|
Number(viewer?.scene?.canvas?.clientWidth) ||
|
||||||
Number(document.documentElement?.clientWidth) ||
|
Number(document.documentElement?.clientWidth) ||
|
||||||
0,
|
0,
|
||||||
|
|||||||
Reference in new issue
Block a user