Carry the Nepal event images, playback and media across globes

This commit is contained in:
German Khokhlov committed 2026-11-03 03:57:18 +04:00
1 parent 811bcaff85
commit 4e09559337
19 files changed
+426 -84

No files matched your search

+1
View File
@@ -3680,6 +3680,7 @@
"src/layers/submarineCables/render/navara.js",
"src/layers/directions/render/navara.js",
"src/layers/shared/render/navaraStory.js",
"src/layers/bhoteKoshi/render/navara.js",
"src/layers/weather/render/navara.js",
"src/layers/weather/render/navaraSurface.js",
"src/layers/wind/render/navara.js",
+152
View File
@@ -0,0 +1,152 @@
import sharp from 'sharp';
import puppeteer from 'puppeteer';
import assert from 'node:assert/strict';
import { blockPaidQaMaps } from './qa-map-policy.mjs';
const base = new URL(process.argv[2]);
if (
!['localhost', '127.0.0.1'].includes(base.hostname) ||
['', '4173', '4185', '4186', '4187', '4188', '4189', '4194'].includes(
base.port,
)
)
throw new Error('Supply an unreserved local keyless preview URL');
base.search = '?engine=navara&welcome=0&look=cinematic';
base.hash =
'v=2&lat=28.2110&lon=85.3380&alt=9000&heading=0&pitch=-90&roll=0&map=sentinel2&l=';
const b = await puppeteer.launch({
headless: true,
args: ['--use-angle=metal', '--enable-gpu'],
});
try {
const p = await b.newPage();
await blockPaidQaMaps(p);
await p.setViewport({ width: 1024, height: 768 });
const errors = [];
p.on('pageerror', (e) => {
errors.push(e.message);
console.log('error', e.message.split('?')[0]);
});
p.on('console', (msg) => {
if (msg.type() === 'error' && /Shader|WebGLProgram/i.test(msg.text()))
errors.push(msg.text());
});
const assets = [];
p.on('request', (req) => {
const path = new URL(req.url()).pathname;
if (
/\/events\/bhote-koshi-2026\/(event\.json|pre\.webp|post\.webp)$/.test(
path,
)
)
assets.push(path);
});
await p.goto(base.href);
const ready = async (kind) => {
await p.waitForFunction(
(k) =>
window.__godsEyeView?.engine.kind === k &&
!document.querySelector('#globe-engine')?.disabled &&
!document.querySelector('.engine-switch-frame') &&
document.querySelector('#loading-screen.hidden'),
{ timeout: 90000 },
kind,
);
await p.evaluate(
() => window.__godsEyeView.styleManager.initialRestorePromise,
);
};
await ready('navara');
await p.evaluate(async () => {
const { tileset, dataManager, sceneDirector } = window.__godsEyeView;
if (!sceneDirector?.listScenes().length) throw Error('No scenes available on Navara');
if (tileset || window.__GOOGLE_MAPS_API_KEY__)
throw Error('Keyless preview required');
const mod = dataManager.layers.get('bhote-koshi-2026').module;
window.qaEvent = mod;
await dataManager.setEnabled(mod.id, true, { origin: 'user' });
mod.setParams({
presentation: 'standalone',
progress: 0.52,
split: 0.27,
autoPlay: false,
});
});
const sample = async () => {
await new Promise((r) => setTimeout(r, 500));
return sharp(await p.screenshot())
.extract({ left: 490, top: 370, width: 25, height: 25 })
.removeAlpha()
.raw()
.toBuffer();
};
for (const kind of ['navara', 'cesium', 'navara']) {
if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind) {
await p.select('#globe-engine', kind);
await ready(kind);
}
const state = await p.evaluate(() => {
const { dataManager } = window.__godsEyeView;
return {
same:
window.qaEvent === dataManager.layers.get('bhote-koshi-2026').module,
state: window.qaEvent.getPlaybackState(),
player:
document.querySelector('#bhote-koshi-embedded-media-root') ===
window.qaPlayer,
};
});
assert.equal(state.same, true);
assert.equal(state.state.enabled, true);
assert.equal(state.state.split, 0.27);
assert.equal(state.state.playing, false);
assert.equal(state.state.progress, 0.52);
await p.evaluate(() => {
window.qaPlayer ??= document.querySelector(
'#bhote-koshi-embedded-media-root',
);
window.qaEvent.setParams({ split: 0.75 });
});
assert.ok(await p.evaluate(()=>Boolean(window.qaPlayer)), 'persistent embedded media root');
const before = await sample();
await p.evaluate(() => window.qaEvent.setParams({ split: 0.27 }));
const after = await sample();
const diff =
before.reduce((n, v, i) => n + Math.abs(v - after[i]), 0) / before.length;
console.log(kind, state.state, 'imagery difference', diff);
assert.ok(diff > 6, 'divider changes the actual imagery in the viewport');
assert.equal(
await p.evaluate(
() =>
document.querySelector('#bhote-koshi-embedded-media-root') ===
window.qaPlayer,
),
true,
);
}
for (const suffix of ['/event.json', '/pre.webp', '/post.webp'])
assert.equal(
assets.filter((a) => a.endsWith(suffix)).length,
1,
JSON.stringify(assets),
);
await p.evaluate(() => window.qaEvent.setParams({ autoPlay: true }));
await p.waitForFunction(
() => window.qaEvent.getPlaybackState().progress > 0.53,
);
await p.select('#globe-engine', 'cesium');
await ready('cesium');
const live = await p.evaluate(() => window.qaEvent.getPlaybackState());
assert.ok(live.playing && live.progress > 0.53);
await p.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled('bhote-koshi-2026', false, {
origin: 'user',
}),
);
assert.deepEqual(errors, []);
console.log(
'PASS event timeline, paused and playing handoff, image pair, divider, player host and single asset acquisition',
);
} finally {
await b.close();
}
+3 -1
View File
@@ -160,7 +160,9 @@ export function createApplicationCatalog({
// Catalog order: [layer id(s), construction]. Only the layers the
// engine can show are constructed (renderers.js).
const entries = [
['bhote-koshi-2026', () => createBhoteKoshiEventLayer()],
['bhote-koshi-2026', () => sessionModel('bhote-koshi-2026', () => createBhoteKoshiEventLayer({
createNativeRenderer: (engine) => rendererFor('bhote-koshi-2026', engine),
}))],
[
'bhote-koshi-locator',
() =>
+2 -2
View File
@@ -45,10 +45,10 @@ export function createApplicationData({
onData?.(dataManager);
if (!catalog?.layers || !catalog?.metadata)
throw new TypeError('An application layer catalog is required');
const attached = dataManager.bindCatalog(viewer, engine, catalog);
for (const layer of catalog.layers) layer.attachDataManager?.(dataManager);
for (const layer of catalog.layers)
layer.attachMapStackController?.(mapStackController);
const attached = dataManager.bindCatalog(viewer, engine, catalog);
for (const layer of catalog.layers) layer.attachDataManager?.(dataManager);
if (allowQaRegistration) {
window.__gevQaRegisterLayer = (targetManager, layerModule) => {
+1
View File
@@ -43,6 +43,7 @@ test('the Navara mode registers migrated renderers once', async () => {
.available()
.map(({ id }) => id),
[
'bhote-koshi-2026',
'bhote-koshi-locator',
'flights',
'military',
+9 -6
View File
@@ -38,9 +38,7 @@ export function navaraModeNotice(availability) {
const names = availability.available().map(({ name }) => name);
return Object.freeze({
label: t('Experimental Navara engine'),
detail: names.length
? `Data layers: ${names.join(', ')}; the other layers and director are unavailable`
: 'No data layers on this engine yet; cockpit and director are unavailable',
detail: names.length ? `Data layers: ${names.join(', ')}` : 'No registered data layers',
});
}
@@ -120,6 +118,10 @@ export async function registerNavaraLayerRenderers() {
return (engine, options) => createWeatherNavaraRenderer(engine, navaraViews.get(engine), options);
},
])),
'bhote-koshi-2026': async () => {
const { createNepalEventDisplay } = await import('../layers/bhoteKoshi/render/navara.js');
return (engine) => createNepalEventDisplay(engine, navaraViews.get(engine));
},
'bhote-koshi-locator': async () => {
const { drawStorySource } = await import('../layers/shared/render/navaraStory.js');
return (engine, { data }) => drawStorySource(engine, navaraViews.get(engine), data);
@@ -331,6 +333,7 @@ export function createViewerlessTools({
placeSearch,
voice = {},
startChrome,
sceneDirector,
signal,
defer,
}) {
@@ -402,7 +405,7 @@ export function createViewerlessTools({
styleManager,
tileset: null,
dataManager,
sceneDirector: null,
sceneDirector,
mapStackController,
annotations: null,
weatherEffects: null,
@@ -428,7 +431,7 @@ export function createViewerlessTools({
engine,
styleManager,
dataManager,
sceneDirector: null,
sceneDirector,
annotations: null,
});
defer(() => {
@@ -437,5 +440,5 @@ export function createViewerlessTools({
delete window.__gevVoiceCommands;
});
debug.voiceCommands = voiceCommands;
return { sceneDirector: null, annotations: null, voiceCommands };
return { sceneDirector, annotations: null, voiceCommands };
}
+17 -16
View File
@@ -41,22 +41,6 @@ export function createApplicationTools({
const { viewer, engine, tileset, mapStackController, operations } = scene;
const { styleManager, weatherEffects, cockpitCloudEffects } = controls;
const { dataManager } = data;
// Without a viewer (the Navara mode) the tools load with that mode's code.
if (!viewer) {
return import('./navaraMode.js').then(({ createViewerlessTools }) =>
createViewerlessTools({
scene,
controls,
data,
loadingScreen,
placeSearch,
voice,
startChrome,
signal,
defer,
}),
);
}
const sceneDirector = new SceneDirector(viewer, styleManager, dataManager, {
engine,
dataPacks: sceneDataPacks,
@@ -68,6 +52,23 @@ export function createApplicationTools({
?.module.attachSceneController(sceneDirector);
defer(() => sceneDirector.destroy());
onSceneDirector?.(sceneDirector);
// Without a viewer (the Navara mode) the tools load with that mode's code.
if (!viewer) {
return import('./navaraMode.js').then(({ createViewerlessTools }) =>
createViewerlessTools({
scene,
controls,
data,
loadingScreen,
placeSearch,
voice,
sceneDirector,
startChrome,
signal,
defer,
}),
);
}
const annotations = initAnnotations({
viewer,
tileset,
+10 -7
View File
@@ -17,11 +17,15 @@ import {
// The tools without a viewer (the experimental Navara engine); they load
// from src/app/navaraMode.js.
test('without a viewer the tools run the governor on the engine, show the notice, create no viewer tools and run voice on the engine', async (t) => {
const saved = { window: globalThis.window, document: globalThis.document };
const saved = { window: globalThis.window, document: globalThis.document, localStorage:globalThis.localStorage };
const listeners = new Map();
globalThis.window = {};
globalThis.localStorage = { getItem:()=>null,setItem(){},removeItem(){} };
globalThis.document = {
hidden: false,
getElementById: () => null,
querySelector: () => null,
body: {classList:{add(){},remove(){},toggle(){},contains(){return false;}},appendChild(){}},
addEventListener: (type, fn) => listeners.set(type, fn),
removeEventListener: (type, fn) => {
if (listeners.get(type) === fn) listeners.delete(type);
@@ -84,7 +88,7 @@ test('without a viewer the tools run the governor on the engine, show the notice
signal: new AbortController().signal,
defer: (dispose) => cleanups.push(dispose),
});
assert.equal(tools.sceneDirector, null);
assert.ok(tools.sceneDirector);
assert.equal(tools.annotations, null);
assert.ok(tools.voiceCommands, 'voice runs on the engine');
assert.equal(window.__gevVoiceCommands, tools.voiceCommands);
@@ -94,8 +98,7 @@ test('without a viewer the tools run the governor on the engine, show the notice
const scene = await voice.runner('control_scene', {
action: 'list',
});
assert.equal(scene.ok, false);
assert.equal(scene.code, 'UNAVAILABLE_ON_ENGINE');
assert.equal(scene.ok, true);
assert.equal(chrome.length, 1);
assert.equal(chrome[0].initializeWelcome, null, 'no first-run welcome');
assert.equal(chrome[0].styleManager, styleManager);
@@ -112,14 +115,14 @@ test('without a viewer the tools run the governor on the engine, show the notice
]);
assert.match(
`${notice.label}: ${notice.detail}`,
/^Experimental Navara engine: no data layers on this engine yet; cockpit and director are unavailable$/i,
/^Experimental Navara engine: no registered data layers$/i,
);
// With Navara renderers, the notice names the layers it shows.
const registry = createRendererRegistry();
registry.register('navara', { satellites: () => ({}), flights: () => ({}) });
assert.equal(
navaraModeNotice(createLayerAvailability('navara', { registry })).detail,
'Data layers: Live Flights, Satellites; the other layers and director are unavailable',
'Data layers: Live Flights, Satellites',
);
// The governor drives the engine's continuous rendering.
assert.equal(isRenderGovernorInstalled(engine), true);
@@ -130,7 +133,7 @@ test('without a viewer the tools run the governor on the engine, show the notice
const debug = window.__godsEyeView;
assert.equal(debug.viewer, null);
assert.equal(debug.engine, engine);
assert.equal(debug.sceneDirector, null);
assert.equal(debug.sceneDirector, tools.sceneDirector);
assert.equal(debug.dataManager, dataManager);
assert.equal(debug.voiceCommands, tools.voiceCommands);
listeners.get('visibilitychange')();
+22 -11
View File
@@ -538,9 +538,10 @@ function ensureProviderConnections(documentRef, provider) {
}
}
function anchorOnScreen(viewer, anchor, root) {
if (!viewer?.scene || !anchor || !root) return null;
const camera = viewer.camera;
function anchorOnScreen(viewer, engine, anchor, root) {
if ((!viewer?.scene && !engine) || !anchor || !root) return null;
const camera = viewer?.camera;
if (engine && !engine.projector.horizonVisible(anchor)) return null;
if (camera?.positionWC && camera?.directionWC) {
const delta = Cesium.Cartesian3.subtract(
anchor,
@@ -549,12 +550,12 @@ function anchorOnScreen(viewer, anchor, root) {
);
if (Cesium.Cartesian3.dot(delta, camera.directionWC) <= 0) return null;
}
const pixel = Cesium.SceneTransforms.worldToWindowCoordinates(
const pixel = engine ? engine.projector.worldToWindow(anchor, {}) : Cesium.SceneTransforms.worldToWindowCoordinates(
viewer.scene,
anchor,
);
if (!pixel) return null;
const canvasBounds = viewer.canvas?.getBoundingClientRect?.();
const canvasBounds = (engine?.host.canvas ?? viewer?.canvas)?.getBoundingClientRect?.();
const rootBounds = root.getBoundingClientRect?.();
if (!canvasBounds || !rootBounds) return null;
const x = pixel.x + canvasBounds.left - rootBounds.left;
@@ -637,7 +638,7 @@ function createRoot(documentRef, viewer) {
const root = documentRef.createElement('div');
root.id = 'bhote-koshi-embedded-media-root';
root.setAttribute('aria-live', 'polite');
(viewer?.container || documentRef.body)?.append(root);
(documentRef.getElementById?.('cesiumContainer') || viewer?.container || documentRef.body)?.append(root);
return root;
}
@@ -648,6 +649,7 @@ function createRoot(documentRef, viewer) {
*/
export function createBhoteKoshiEmbeddedMedia({
viewer,
engine = null,
documentRef = globalThis.document,
globalRef = globalThis,
xLoader = loadXWidgets,
@@ -660,7 +662,7 @@ export function createBhoteKoshiEmbeddedMedia({
const pinokioShell = /(?:^|\s)Pinokio\/[^\s]+/i.test(
globalRef.navigator?.userAgent || '',
);
if (pinokioShell || !documentRef?.createElement || !viewer) {
if (pinokioShell || !documentRef?.createElement || (!viewer && !engine)) {
return {
supportsPlayback: false,
warm: () => false,
@@ -671,7 +673,7 @@ export function createBhoteKoshiEmbeddedMedia({
destroy: () => {},
};
}
const root = createRoot(documentRef, viewer);
const root = createRoot(documentRef, engine?.host ?? viewer);
let record = null;
let warmRecord = null;
let generation = 0;
@@ -848,7 +850,7 @@ export function createBhoteKoshiEmbeddedMedia({
const position = () => {
if (!record) return;
const projected = anchorOnScreen(viewer, record.anchor, root);
const projected = anchorOnScreen(viewer, engine, record.anchor, root);
record.card.hidden = !projected;
if (projected) {
placeCard(record.card, record.leader, projected);
@@ -864,8 +866,16 @@ export function createBhoteKoshiEmbeddedMedia({
}
}
};
const removePostRender =
viewer.scene?.postRender?.addEventListener?.(position);
let removePostRender;
function attachScene(nextViewer, nextEngine) {
removePostRender?.();
viewer = nextViewer;
engine = nextEngine;
const frames = engine?.frames.postRender ?? viewer?.scene?.postRender;
removePostRender = frames?.addEventListener?.(position);
position();
}
attachScene(viewer, engine);
function hide({ immediate = false, preserveWarm = false } = {}) {
if (!preserveWarm) clearWarm();
@@ -1223,6 +1233,7 @@ export function createBhoteKoshiEmbeddedMedia({
}
return {
attachScene,
supportsPlayback: true,
warm,
show,
+111 -22
View File
@@ -889,7 +889,7 @@ function openExternal(url) {
if (target) target.opener = null;
}
function focusView(viewer, viewpoint) {
function focusView(viewer, viewpoint, engine) {
announceNavigationAuthority('bhote-koshi-event-focus');
const target = Cesium.Cartesian3.fromDegrees(
viewpoint.lon,
@@ -901,6 +901,17 @@ function focusView(viewer, viewpoint) {
Cesium.Math.toRadians(viewpoint.pitchDeg),
viewpoint.rangeM,
);
if (engine) {
engine.camera.cancelFlight();
engine.camera.flyToSphere({center: target, radius: 80}, {
heading:offset.heading, pitch:offset.pitch, range:offset.range, duration:2.2,
complete: () => {
engine.camera.lookAt(target, offset);
engine.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
},
});
return;
}
viewer.camera.cancelFlight?.();
viewer.camera.flyToBoundingSphere(new Cesium.BoundingSphere(target, 80), {
offset,
@@ -994,9 +1005,16 @@ export function createBhoteKoshiEventLayer({
evidenceFrameUpdater = updateEvidenceFrame,
videoFactory = DEFAULT_VIDEO_FACTORY,
embeddedMediaFactory = createBhoteKoshiEmbeddedMedia,
createNativeRenderer,
panelStore = useBhoteKoshiPanelStore(applicationPinia()),
} = {}) {
let _viewer = null;
let _engine = null;
let _native = null;
let _nativeFlood = null;
let _providers = null;
const cameraForScene = () => _engine?.camera ?? _viewer?.camera;
const canvasForScene = () => _engine?.host.canvas ?? _viewer?.scene?.canvas;
let _dataManager = null;
let _mapStackController = null;
let _event = null;
@@ -1160,7 +1178,7 @@ export function createBhoteKoshiEventLayer({
event.imagery.rectangle.east,
event.imagery.rectangle.north,
);
const [beforeProvider, afterProvider] = await Promise.all([
const [beforeProvider, afterProvider] = await (_providers ??= Promise.all([
imageryProviderFactory(eventAsset(event.imagery.before.path), {
rectangle,
credit: 'Vantor WorldView Open Data · CC BY-NC 4.0',
@@ -1169,8 +1187,13 @@ export function createBhoteKoshiEventLayer({
rectangle,
credit: 'Vantor WorldView Open Data · CC BY-NC 4.0',
}),
]);
]));
if (!_enabled) return;
if (_native) {
await _native.loadImages(event.imagery.rectangle, [beforeProvider, afterProvider]);
setSplit(_split);
return;
}
_imageryLayerCollection = _viewer.imageryLayers;
_beforeLayer = _imageryLayerCollection.addImageryProvider(beforeProvider);
_afterLayer = _imageryLayerCollection.addImageryProvider(afterProvider);
@@ -1187,7 +1210,11 @@ export function createBhoteKoshiEventLayer({
: [];
let sampled = null;
try {
sampled = await terrainSampler(_viewer, observations, { signal });
sampled = _engine && !_viewer
? observations.map(({lon,lat}) => ({
height: _engine.surface.terrainHeight(Cesium.Cartographic.fromDegrees(lon,lat)),
}))
: await terrainSampler(_viewer, observations, { signal });
} catch (error) {
if (error?.name === 'AbortError') throw error;
console.warn(
@@ -1574,13 +1601,13 @@ export function createBhoteKoshiEventLayer({
if (_sceneControls.evidencePathElevation !== 'source') {
// Sample once while preparing the shot, never from an animation frame.
try {
height = _viewer?.scene?.sampleHeight?.(
height = _engine?.surface?.sampleHeight?.(position) ?? _viewer?.scene?.sampleHeight?.(
position,
[_floodCore, _floodHalo, _surgePoint].filter(Boolean),
);
} catch {}
if (!Number.isFinite(height))
height = _viewer?.scene?.globe?.getHeight?.(position);
height = _engine?.surface?.terrainHeight(position) ?? _viewer?.scene?.globe?.getHeight?.(position);
}
if (!Number.isFinite(height)) height = point.fallbackElevationM;
return Cesium.Cartesian3.fromDegrees(point.lon, point.lat, height + 8);
@@ -2582,13 +2609,16 @@ export function createBhoteKoshiEventLayer({
disableDepthTestDistance: tileSafe ? Number.POSITIVE_INFINITY : 0,
},
});
_viewer.dataSources.add(_floodDataSource);
if (_native) _nativeFlood = _native.draw(_floodDataSource);
else _viewer.dataSources.add(_floodDataSource);
}
function syncFloodSurfaceMode() {
const nextMode = usesPhotorealFloodSurface() ? 'tiles' : 'terrain';
if (!_viewer || !_floodDataSource || _floodSurfaceMode === nextMode) return;
_viewer.dataSources.remove(_floodDataSource, true);
if ((!_viewer && !_native) || !_floodDataSource || _floodSurfaceMode === nextMode) return;
_nativeFlood?.destroy();
_nativeFlood = null;
_viewer?.dataSources.remove(_floodDataSource, true);
_floodDataSource = null;
_floodHalo = null;
_floodCore = null;
@@ -2601,7 +2631,7 @@ export function createBhoteKoshiEventLayer({
function createSplitControl(event) {
_splitControl = createImagerySplit({
scene: _viewer.scene,
scene: _viewer?.scene,
initialValue: _split,
id: 'bhote-koshi-split-line',
handleClass: 'bhote-koshi-split-handle',
@@ -2621,7 +2651,7 @@ export function createBhoteKoshiEventLayer({
function applyCinematicCamera() {
if (
!_cinematicActive ||
!_viewer?.camera ||
!cameraForScene() ||
_cinematicKeyframes.length === 0
)
return;
@@ -2635,7 +2665,7 @@ export function createBhoteKoshiEventLayer({
_cinematicOffset.heading = Cesium.Math.toRadians(pose.headingDeg);
_cinematicOffset.pitch = Cesium.Math.toRadians(pose.pitchDeg);
_cinematicOffset.range = pose.rangeM;
_viewer.camera.lookAt(pose.target, _cinematicOffset);
cameraForScene().lookAt(pose.target, _cinematicOffset);
_lastCinematicProgress = _progress;
}
@@ -2705,7 +2735,7 @@ export function createBhoteKoshiEventLayer({
_cinematicActive = true;
_lastCinematicProgress = null;
announceNavigationAuthority('bhote-koshi-cinematic');
_viewer?.camera?.cancelFlight?.();
cameraForScene()?.cancelFlight?.();
}
syncCinematicButton();
return true;
@@ -2723,7 +2753,7 @@ export function createBhoteKoshiEventLayer({
_cinematicActive = false;
_lastCinematicProgress = null;
if (releaseCamera)
_viewer?.camera?.lookAtTransform?.(Cesium.Matrix4.IDENTITY);
cameraForScene()?.lookAtTransform?.(Cesium.Matrix4.IDENTITY);
syncCinematicButton();
renderHost.request('bhote-koshi-cinematic-stop');
}
@@ -2757,7 +2787,7 @@ export function createBhoteKoshiEventLayer({
}
function attachCameraInputRelease() {
_cameraInputTarget = _viewer?.scene?.canvas || null;
_cameraInputTarget = canvasForScene() || null;
for (const type of CINEMATIC_INPUT_EVENTS) {
_cameraInputTarget?.addEventListener?.(type, handleManualCameraInput);
}
@@ -2803,7 +2833,7 @@ export function createBhoteKoshiEventLayer({
}
function attachEvidenceCardActivation() {
_evidenceClickTarget = _viewer?.scene?.canvas || null;
_evidenceClickTarget = canvasForScene() || null;
_evidenceClickTarget?.addEventListener?.('click', handleEvidenceCardClick);
}
@@ -3273,6 +3303,7 @@ export function createBhoteKoshiEventLayer({
if (_panelRefs?.progressInput)
_panelRefs.progressInput.hidden = !scrubberAvailable;
_splitControl?.setVisible(imageryAvailable);
_native?.setVisible(imageryAvailable);
if (_beforeLayer) _beforeLayer.show = imageryAvailable;
if (_afterLayer) _afterLayer.show = imageryAvailable;
if (_panelRefs?.caveat) {
@@ -3391,6 +3422,7 @@ export function createBhoteKoshiEventLayer({
function setSplit(split) {
_split = clampUnit(split);
_splitControl?.setValue(_split);
_native?.setSplit(_split);
syncPanel();
renderHost.request('bhote-koshi-split');
}
@@ -3451,7 +3483,7 @@ export function createBhoteKoshiEventLayer({
_embeddedEvidenceIndex = -1;
_embeddedMedia?.hide?.();
setSplit(0.5);
focusView(_viewer, _event.viewpoints.corridor);
if (cameraForScene()) focusView(_viewer, _event.viewpoints.corridor, _engine);
}
function removeImagery() {
@@ -3462,9 +3494,59 @@ export function createBhoteKoshiEventLayer({
_imageryLayerCollection = null;
}
async function init(viewer) {
async function attachRenderer(viewer, {engine = null} = {}) {
_viewer = viewer;
_embeddedMedia = embeddedMediaFactory({ viewer });
_engine = engine;
if (!_viewer && _engine) _native = createNativeRenderer(engine);
_embeddedMedia?.attachScene?.(viewer,engine);
if (!_enabled) return;
// The catalog has rebound the incoming map controller before this call.
if (_mapStackController) {
_comparisonLease = acquireImageryComparison(_mapStackController, {
owner: BHOTE_KOSHI_LAYER_ID,
switchPolicy: _presentation === BHOTE_KOSHI_SCENE_PRESENTATION ? 'preserve' : 'esri',
});
await _comparisonLease.ready;
}
await createImagery(_event);
if (_floodDataSource) {
if (_native) _nativeFlood = _native.draw(_floodDataSource);
else await _viewer.dataSources.add(_floodDataSource);
}
registerDynamicCredit(engine ?? viewer, BHOTE_KOSHI_CREDIT);
_scenePathPositionCache.clear();
_lastCinematicProgress = null;
await syncTerrainComparison();
createSplitControl(_event);
syncPresentationMode();
attachCameraInputRelease();
attachEvidenceCardActivation();
updateProgress(_progress);
setSplit(_split);
}
async function detachRenderer() {
detachCameraInputRelease();
detachEvidenceCardActivation();
_splitControl?.destroy();
_splitControl = null;
_embeddedMedia?.attachScene?.(null,null);
removeImagery();
_nativeFlood?.destroy();
_nativeFlood = null;
if (_floodDataSource && _viewer) _viewer.dataSources.remove(_floodDataSource,false);
_native?.destroy();
_native = null;
_engine = _viewer = null;
_mapStackController?.clearTerrainComparison?.();
const lease = _comparisonLease;
_comparisonLease = _mapStackController = null;
await lease?.release();
}
async function init(viewer, options) {
await attachRenderer(viewer,options);
_embeddedMedia = embeddedMediaFactory({ viewer, engine: _engine });
await loadEvent();
console.log('[Data:BhoteKoshi] Event pack ready');
return true;
@@ -3480,7 +3562,7 @@ export function createBhoteKoshiEventLayer({
try {
const event = await loadEvent();
throwIfEnableCancelled(signal, _enabled);
registerDynamicCredit(viewer, BHOTE_KOSHI_CREDIT);
registerDynamicCredit(_engine ?? viewer, BHOTE_KOSHI_CREDIT);
if (_mapStackController) {
_comparisonLease = acquireImageryComparison(_mapStackController, {
owner: BHOTE_KOSHI_LAYER_ID,
@@ -3618,7 +3700,11 @@ export function createBhoteKoshiEventLayer({
closeFrame(slot.poster);
}
_evidenceFrameSlots = [];
if (_floodDataSource) _viewer.dataSources.remove(_floodDataSource, true);
_nativeFlood?.destroy();
_nativeFlood = null;
_native?.clear();
_providers = null;
if (_floodDataSource && _viewer) _viewer.dataSources.remove(_floodDataSource, true);
_floodDataSource = null;
_floodSurfaceMode = null;
_floodHalo = null;
@@ -3672,7 +3758,8 @@ export function createBhoteKoshiEventLayer({
_embeddedMedia?.destroy?.();
_embeddedMedia = null;
_event = null;
_viewer = null;
_native?.destroy();
_native = _engine = _viewer = null;
}
/**
@@ -3849,6 +3936,8 @@ export function createBhoteKoshiEventLayer({
source: 'Vantor + GeoPera',
updateInterval: 0,
init,
attachRenderer,
detachRenderer,
enable,
disable,
update: async () => true,
+1 -1
View File
@@ -130,7 +130,7 @@ export class LayerLifecycle {
for (const [id, entry] of [...this.layers]) {
await entry.toggleChain.catch(() => {});
if (entry.module.attachRenderer && entry.module.detachRenderer) {
if (entry.initialized) entry.module.detachRenderer(this.viewer);
if (entry.initialized) await entry.module.detachRenderer(this.viewer);
} else if (!(await this.destroyLayer(id))) {
throw new Error(`Cannot detach layer ${id}`);
}
+67
View File
@@ -0,0 +1,67 @@
import { Texture, LinearFilter } from 'three';
import { createImageryGround } from '../../recentImagery/render/navaraGround.js';
import { drawStorySource } from '../../shared/render/navaraStory.js';
export function createNepalEventDisplay(engine, native) {
const owner = { slots: { a: null, b: null }, preview: null, split: 0.5 };
const ground = createImageryGround(engine, native, owner, 'gevNepalEvent');
let disposed = false;
function clear() {
for (const id of ['a', 'b']) {
owner.slots[id]?.atlas.texture.dispose();
owner.slots[id] = null;
}
if (!disposed) ground.sync();
}
return {
draw: (data) => drawStorySource(engine, native, data),
async loadImages(box, providers) {
const images = await Promise.all(
providers.map((p) => p.requestImage(0, 0, 0)),
);
if (disposed) return;
clear();
for (const [i, id] of ['a', 'b'].entries()) {
const texture = new Texture(images[i]);
texture.flipY = false;
texture.minFilter = texture.magFilter = LinearFilter;
texture.generateMipmaps = false;
texture.needsUpdate = true;
owner.slots[id] = {
atlas: {
texture,
plan: {
geographic: true,
bounds: [
box.west,
box.north,
box.east - box.west,
box.south - box.north,
],
},
},
box,
alpha: 0.96,
split: i === 0 ? 'left' : 'right',
};
}
ground.sync();
engine.render.request('nepal-imagery');
},
setVisible(show) {
for (const slot of Object.values(owner.slots))
if (slot) slot.alpha = show ? 0.96 : 0;
engine.render.request('nepal-imagery');
},
setSplit(x) {
owner.split = x;
engine.render.request('nepal-split');
},
clear,
destroy() {
disposed = true;
clear();
ground.destroy();
},
};
}
+1 -1
View File
@@ -199,7 +199,7 @@ function buildState(discs) {
function groundEffectClass(EffectDesc, NavaraEffect) {
return class GroundEffectDesc extends EffectDesc {
static key = KEY;
static insertAfter = ['gevRecentImagery', 'toneMapping'];
static insertAfter = ['gevObservedWeather', 'gevNepalEvent', 'gevRecentImagery', 'toneMapping'];
static insertBefore = [
'gevFirmsGround',
'gevPreSharpenOverlay',
+22 -12
View File
@@ -6,14 +6,14 @@ import {
} from '../../shared/render/navaraGroundSurface.js';
const KEY = 'gevRecentImagery',
registered = new WeakSet();
registered = new WeakMap();
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;
uniform bool uPreviewVisible,uGeographicA,uGeographicB;
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));
}
@@ -21,10 +21,10 @@ 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) {
vec3 imageColor(vec3 color,sampler2D image,vec4 bounds,vec4 box,vec2 look,vec2 geo,vec2 mercator,float x,bool geographic) {
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;
vec2 at=((geographic?geo: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);
@@ -42,8 +42,8 @@ void mainImage(const in vec4 inputColor,const in vec2 uv,out vec4 outputColor) {
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);
color=imageColor(color,tA,uBoundsA,uBoxA,uLookA,geo,mercator,uv.x,uGeographicA);
color=imageColor(color,tB,uBoundsB,uBoxB,uLookB,geo,mercator,uv.x,uGeographicB);
if(uPreviewVisible && inBox(geo,uPreview)) {
vec2 pixelWidth=max(fwidth(geo),vec2(1e-7));
vec2 edges=min(geo-uPreview.xy,uPreview.zw-geo)/pixelWidth;
@@ -61,14 +61,18 @@ export function createImageryGround(
engine,
{ view, EffectDesc, NavaraEffect },
owner,
key = KEY,
) {
if (!view || !EffectDesc || !NavaraEffect)
throw new TypeError('Imagery requires Navara ground effects');
if (!registered.has(view)) {
if (!registered.has(view)) registered.set(view, new Set());
if (!registered.get(view).has(key)) {
class ImageryEffect extends EffectDesc {
static key = KEY;
static key = key;
static insertAfter = ['toneMapping'];
static insertBefore = [
'gevNepalEvent',
'gevObservedWeather',
'gevEarthquakeGround',
'gevFirmsGround',
'gevPreSharpenOverlay',
@@ -80,13 +84,15 @@ export function createImageryGround(
];
constructor(view, context, config) {
super(view, context, config);
this.owner = config[KEY].owner;
this.owner = config[key].owner;
}
createPass() {
this.effect = new Effect('GevRecentImagery', shader, {
attributes: EffectAttribute.CONVOLUTION,
uniforms: new Map([
...groundSurfaceUniforms(),
['uGeographicA', new Uniform(false)],
['uGeographicB', new Uniform(false)],
['uWorld', new Uniform(new Matrix4())],
['uSplit', new Uniform(0.5)],
['uPixelRatio', new Uniform(1)],
@@ -115,6 +121,7 @@ export function createImageryGround(
u.get('uPreviewVisible').value = Boolean(o.preview);
for (const id of ['A', 'B']) {
const r = o.slots[id.toLowerCase()];
u.get(`uGeographic${id}`).value = r?.atlas?.plan.geographic === true;
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);
@@ -129,8 +136,11 @@ export function createImageryGround(
super.onDestroy();
}
}
view.registerEffect(KEY, ImageryEffect);
registered.add(view);
class NepalEffect extends ImageryEffect {
static insertAfter = ['gevRecentImagery', 'toneMapping'];
}
view.registerEffect(key, key === KEY ? ImageryEffect : NepalEffect);
registered.get(view).add(key);
}
owner.empty = new DataTexture(new Uint8Array(4), 1, 1);
owner.empty.needsUpdate = true;
@@ -141,7 +151,7 @@ export function createImageryGround(
sync() {
const visible = Boolean(owner.slots.a || owner.slots.b || owner.preview);
if (visible && !handle)
handle = view.addEffect({ [KEY]: { owner }, visible: true });
handle = view.addEffect({ [key]: { owner }, visible: true });
else handle?.update({ visible });
},
destroy() {
@@ -212,6 +212,8 @@ function groundEffectClass(EffectDesc, NavaraEffect) {
static key = KEY;
static insertAfter = [
'gevEarthquakeGround',
'gevObservedWeather',
'gevNepalEvent',
'gevRecentImagery',
'toneMapping',
];
@@ -7,6 +7,8 @@ import fs from 'node:fs/promises';
// minimal look, whose pipeline has no toneMapping anchor.
const declarations = [
['gevRecentImagery', '../../recentImagery/render/navaraGround.js'],
['gevNepalEvent', '../../recentImagery/render/navaraGround.js'],
['gevObservedWeather', '../../weather/render/navaraSurface.js'],
['gevEarthquakeGround', '../../earthquakes/render/navara.js'],
['gevFirmsGround', './navaraGroundCells.js'],
['gevPreSharpenOverlay', '../../../globe/navara/overlay.js'],
@@ -18,7 +20,7 @@ const specs = await Promise.all(
source = source.split('class GevPreSharpenOverlayEffectDesc')[1];
const anchors = (name) =>
[
...source
...(key === 'gevNepalEvent' && name === 'insertAfter' ? source.split('class NepalEffect')[1] : source)
.match(new RegExp(`static ${name} = \\[([^\\]]*)\\]`))[1]
.matchAll(/'([^']+)'/g),
].map((m) => m[1]);
+1 -1
View File
@@ -89,7 +89,7 @@ function weatherPass(engine, { view, EffectDesc, NavaraEffect }) {
empty = null;
class WeatherEffect extends EffectDesc {
static key = KEY;
static insertAfter = ['toneMapping', 'gevRecentImagery'];
static insertAfter = ['gevNepalEvent', 'gevRecentImagery', 'toneMapping'];
static insertBefore = [
'gevEarthquakeGround',
'gevFirmsGround',
+1 -2
View File
@@ -234,7 +234,6 @@ const FRAME_TARGETS = new Map([
* refusal (UNAVAILABLE_ON_ENGINE) instead of running.
*/
export const VIEWER_ONLY_ACTIONS = Object.freeze([
'control_scene',
'annotate_map',
'clear_annotations',
'fly_route',
@@ -470,7 +469,7 @@ export function createGevActionRunner({
};
if (
!viewer &&
(VIEWER_ONLY_ACTIONS.includes(name) || CESIUM_LOOK_ACTIONS.includes(name))
((name === 'control_scene' && !sceneDirector) || VIEWER_ONLY_ACTIONS.includes(name) || CESIUM_LOOK_ACTIONS.includes(name))
)
return unavailableOnEngine(name, engine);
-1
View File
@@ -89,7 +89,6 @@ const follow = (engine, position) =>
test('the viewer-only actions are one exported list of real tools', () => {
assert.deepEqual(VIEWER_ONLY_ACTIONS, [
'control_scene',
'annotate_map',
'clear_annotations',
'fly_route',