diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 267995200..ae4ebb159 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1266,5 +1266,9 @@ "scripts/fixtures/w2/radio.mjs", "scripts/qa-map-policy.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" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index b4b357781..c6fb1f967 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2571,6 +2571,7 @@ "src/maps/imageryHost.js", "src/ui/recentImagery.js", "src/ui/imageryBoxTool.js", + "src/ui/imageryBoxNavara.js", "src/voice/pointerContext.js", "src/voice/pointerReticle.js", "src/voice/deixis.js", @@ -2710,6 +2711,9 @@ "src/layers/transit/render/navaraSprites.js", "src/layers/transit/render/navaraView.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/navaraMeshes.js", "src/layers/cctv/render/navaraGizmo.js", diff --git a/scripts/qa-w2-imagery.mjs b/scripts/qa-w2-imagery.mjs new file mode 100644 index 000000000..55bf486c5 --- /dev/null +++ b/scripts/qa-w2-imagery.mjs @@ -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; diff --git a/src/app/layers/recentImagery.js b/src/app/layers/recentImagery.js index cc4659727..27001ddb7 100644 --- a/src/app/layers/recentImagery.js +++ b/src/app/layers/recentImagery.js @@ -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), }), diff --git a/src/app/navaraLayers.test.mjs b/src/app/navaraLayers.test.mjs index aa489132a..351472e20 100644 --- a/src/app/navaraLayers.test.mjs +++ b/src/app/navaraLayers.test.mjs @@ -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', diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js index 6dff1081e..7f02a85b5 100644 --- a/src/app/navaraMode.js +++ b/src/app/navaraMode.js @@ -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( diff --git a/src/layers/recentImagery/index.js b/src/layers/recentImagery/index.js index 23b12f44c..3d564d4b2 100644 --- a/src/layers/recentImagery/index.js +++ b/src/layers/recentImagery/index.js @@ -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, diff --git a/src/layers/recentImagery/render/navara.js b/src/layers/recentImagery/render/navara.js new file mode 100644 index 000000000..dadaf3244 --- /dev/null +++ b/src/layers/recentImagery/render/navara.js @@ -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; + }, + }; +} diff --git a/src/layers/recentImagery/render/navara.test.mjs b/src/layers/recentImagery/render/navara.test.mjs new file mode 100644 index 000000000..0b31eb597 --- /dev/null +++ b/src/layers/recentImagery/render/navara.test.mjs @@ -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; + } +}); diff --git a/src/layers/recentImagery/render/navaraGround.js b/src/layers/recentImagery/render/navaraGround.js new file mode 100644 index 000000000..f9e6b3873 --- /dev/null +++ b/src/layers/recentImagery/render/navaraGround.js @@ -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=1.0 || depth + 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(); + }, + }; +} diff --git a/src/layers/recentImagery/render/tiles.js b/src/layers/recentImagery/render/tiles.js new file mode 100644 index 000000000..f359ecee1 --- /dev/null +++ b/src/layers/recentImagery/render/tiles.js @@ -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(); + }, + }; +} diff --git a/src/layers/recentImagery/render/tiles.test.mjs b/src/layers/recentImagery/render/tiles.test.mjs new file mode 100644 index 000000000..9bd95ae40 --- /dev/null +++ b/src/layers/recentImagery/render/tiles.test.mjs @@ -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 }); +}); diff --git a/src/ui/imageryBoxNavara.js b/src/ui/imageryBoxNavara.js new file mode 100644 index 000000000..da80d19fb --- /dev/null +++ b/src/ui/imageryBoxNavara.js @@ -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; + }, + }; +} diff --git a/src/ui/imageryBoxNavara.test.mjs b/src/ui/imageryBoxNavara.test.mjs new file mode 100644 index 000000000..f0b8044cf --- /dev/null +++ b/src/ui/imageryBoxNavara.test.mjs @@ -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(); + } +}); diff --git a/src/ui/recentImagery.js b/src/ui/recentImagery.js index 27067e4bf..f96728999 100644 --- a/src/ui/recentImagery.js +++ b/src/ui/recentImagery.js @@ -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,