mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
Merge current main into map-feature refactoring
This commit is contained in:
commit
fd6106ee01
48 files changed
+17164
-179
No files matched your search
@@ -20,6 +20,8 @@ import { createApplicationEarthquakes } from './layers/earthquakes.js';
|
||||
import { createApplicationCables } from './layers/submarineCables.js';
|
||||
import { createInfrastructureLayers } from '../data/infrastructure.js';
|
||||
import { localGeoJsonServices } from './localGeojsonServices.js';
|
||||
import { createBhoteKoshiEventLayer } from '../data/bhoteKoshiEvent.js';
|
||||
import { createBhoteKoshiLocatorLayer } from '../data/bhoteKoshiLocator.js';
|
||||
|
||||
const SOURCE_METHODS = Object.freeze({
|
||||
flights: ['getSnapshot'],
|
||||
@@ -55,6 +57,7 @@ export function createApplicationCatalog({
|
||||
metadata = LAYER_STATE_REGISTRY,
|
||||
vesselOptions,
|
||||
resolveAsset,
|
||||
nepalBoundaryResolver,
|
||||
}) {
|
||||
if (!signal?.addEventListener)
|
||||
throw new TypeError('An application lifetime signal is required');
|
||||
@@ -101,6 +104,10 @@ export function createApplicationCatalog({
|
||||
});
|
||||
const catalog = createLayerCatalog(
|
||||
[
|
||||
createBhoteKoshiEventLayer(),
|
||||
createBhoteKoshiLocatorLayer({
|
||||
boundaryResolver: nepalBoundaryResolver,
|
||||
}),
|
||||
flights,
|
||||
military,
|
||||
createApplicationEarthquakes({ source: sources.earthquakes }),
|
||||
|
||||
@@ -4,6 +4,7 @@ import assert from 'node:assert/strict';
|
||||
import { createApplicationCatalog } from './constructCatalog.js';
|
||||
import { createStandaloneLayerSources } from '../standalone/layerSources.js';
|
||||
import { catalogControlServices } from './catalog.js';
|
||||
import { LayerLifecycle } from '../data/lifecycle.js';
|
||||
|
||||
function fixtureSources(ids, calls) {
|
||||
const sources = createStandaloneLayerSources();
|
||||
@@ -38,7 +39,27 @@ test('catalogs construct distinct layers and classification from their supplied
|
||||
signal: b.signal,
|
||||
surface: fixtureSurface(b.signal),
|
||||
});
|
||||
assert.equal(first.layers.length, 19);
|
||||
assert.equal(first.layers.length, 21);
|
||||
assert.ok(first.get('transit'));
|
||||
assert.ok(first.get('bhote-koshi-2026'));
|
||||
assert.ok(first.get('bhote-koshi-locator'));
|
||||
const lifecycle = new LayerLifecycle({});
|
||||
for (const layer of first.layers) lifecycle.register(layer);
|
||||
const rows = lifecycle.getAll();
|
||||
for (const id of ['bhote-koshi-2026', 'bhote-koshi-locator']) {
|
||||
assert.equal(
|
||||
rows.find((row) => row.id === id)?.showInTogglePanel,
|
||||
false,
|
||||
`${id} remains registered for Scenes but is absent from Data Layers`,
|
||||
);
|
||||
assert.equal(typeof first.get(id).enable, 'function');
|
||||
assert.equal(typeof first.get(id).setParams, 'function');
|
||||
}
|
||||
assert.equal(
|
||||
rows.find((row) => row.id === 'flights')?.showInTogglePanel,
|
||||
true,
|
||||
'ordinary data layer entries remain visible',
|
||||
);
|
||||
assert.deepEqual(
|
||||
first.layers.map(({ id }) => id),
|
||||
second.layers.map(({ id }) => id),
|
||||
|
||||
+3
-1
@@ -2,7 +2,7 @@ import { LayerLifecycle } from '../data/lifecycle.js';
|
||||
import { LayerPresentation } from './layerPresentation.js';
|
||||
/** Register the application layer catalog before allowing state restoration. */
|
||||
export function createApplicationData({
|
||||
scene: { viewer },
|
||||
scene: { viewer, mapStackController },
|
||||
controls: { styleManager },
|
||||
catalog,
|
||||
allowQaRegistration,
|
||||
@@ -27,6 +27,8 @@ export function createApplicationData({
|
||||
throw new TypeError('An application layer catalog is required');
|
||||
for (const layer of catalog.layers) dataManager.register(layer);
|
||||
for (const layer of catalog.layers) layer.attachDataManager?.(dataManager);
|
||||
for (const layer of catalog.layers)
|
||||
layer.attachMapStackController?.(mapStackController);
|
||||
// Restoration starts only after the caller's complete registry is sealed.
|
||||
dataManager.finalizeRegistrations(catalog.metadata);
|
||||
if (allowQaRegistration) {
|
||||
|
||||
+7
-1
@@ -27,7 +27,13 @@ export function createApplicationTools({
|
||||
const { viewer, tileset, mapStackController, operations } = scene;
|
||||
const { styleManager, weatherEffects, cockpitCloudEffects } = controls;
|
||||
const { dataManager } = data;
|
||||
const sceneDirector = new SceneDirector(viewer, styleManager, dataManager);
|
||||
const sceneDirector = new SceneDirector(viewer, styleManager, dataManager, {
|
||||
isMapStackAvailable: (id) =>
|
||||
mapStackController?.isStackAvailable(id) === true,
|
||||
});
|
||||
dataManager.layers
|
||||
.get('bhote-koshi-2026')
|
||||
?.module.attachSceneController(sceneDirector);
|
||||
defer(() => sceneDirector.destroy());
|
||||
onSceneDirector?.(sceneDirector);
|
||||
const annotations = initAnnotations({
|
||||
|
||||
@@ -255,7 +255,7 @@ test('Cockpit owns a focused shared Display portal and compact Radio controls',
|
||||
css,
|
||||
/body\.scene-playback-mode\s*:is\([\s\S]*?#clear-selected-layers,[\s\S]*?#tilt-map-view,[\s\S]*?#north-up-view,[\s\S]*?#reset-globe-view[\s\S]*?\)\s*\{\s*display:\s*none !important;/,
|
||||
);
|
||||
assert.match(sceneDirector, /this\._running = true;\s*this\._setPlaybackActive\(true\);/);
|
||||
assert.match(sceneDirector, /this\._running = true;\s*this\._previewRun = preview;\s*if \(preview\) this\._setPlaybackActive\(true\);/);
|
||||
assert.match(sceneDirector, /styleManager\.setRecordingMode\(false\);\s*this\._setPlaybackActive\(false\);/);
|
||||
assert.match(SceneControls.prototype.setPlaybackActive.toString(), /document\.body\.classList\.toggle\('scene-playback-mode', active\)/);
|
||||
assert.equal((html.match(/id="hud-toggle"/g) || []).length, 1, 'HUD control must have one stateful DOM owner');
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,345 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import {
|
||||
createBhoteKoshiEmbeddedMedia,
|
||||
createTrimmedYouTubePlayback,
|
||||
embeddedMediaFrameUrl,
|
||||
publicEmbeddedMediaUrl,
|
||||
resolveEmbeddedMediaSource,
|
||||
} from './bhoteKoshiEmbeddedMedia.js';
|
||||
|
||||
function trimmedPlaybackFixture() {
|
||||
let now = 0;
|
||||
let nextId = 0;
|
||||
const timers = new Map();
|
||||
const globalRef = {
|
||||
setTimeout(fn, delay) { const id = ++nextId; timers.set(id, { at: now + delay, fn }); return id; },
|
||||
clearTimeout(id) { timers.delete(id); },
|
||||
};
|
||||
const advance = (ms) => {
|
||||
const until = now + ms;
|
||||
while (true) {
|
||||
const entry = [...timers].filter(([, item]) => item.at <= until).sort((a, b) => a[1].at - b[1].at)[0];
|
||||
if (!entry) break;
|
||||
const [id, item] = entry; timers.delete(id); now = item.at; item.fn();
|
||||
}
|
||||
now = until;
|
||||
};
|
||||
let events;
|
||||
const calls = [];
|
||||
let time = 0;
|
||||
let state = -1;
|
||||
const player = {
|
||||
mute: () => calls.push('mute'), playVideo: () => calls.push('play'),
|
||||
pauseVideo: () => calls.push('pause'), destroy: () => calls.push('destroy'),
|
||||
getCurrentTime: () => time,
|
||||
getPlayerState: () => state,
|
||||
};
|
||||
const api = { Player: class { constructor(_iframe, options) { events = options.events; return player; } } };
|
||||
const statuses = [];
|
||||
const session = createTrimmedYouTubePlayback({ iframe: {}, endAtSec: 7, globalRef,
|
||||
loadApi: () => api, onStatus: (phase) => statuses.push(phase) });
|
||||
return { session, advance, calls, timers, statuses, player, get events() { return events; }, setTime(value) { time = value; }, setState(value) { state = value; } };
|
||||
}
|
||||
|
||||
test('trimmed YouTube reconciles already-playing READY and missed PLAYING callbacks', async () => {
|
||||
for (const alreadyPlaying of [true, false]) {
|
||||
const f = trimmedPlaybackFixture();
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
if (alreadyPlaying) { f.setState(1); f.setTime(1); }
|
||||
f.events.onReady({ target: f.player });
|
||||
if (!alreadyPlaying) { f.setState(1); f.setTime(1); f.advance(100); }
|
||||
f.setTime(6); f.advance(5000);
|
||||
assert.deepEqual(f.session.getState(), { phase: 'playing', currentTime: 6 });
|
||||
f.setTime(7); f.advance(100);
|
||||
assert.deepEqual(f.session.getState(), { phase: 'completed', currentTime: 7 });
|
||||
assert.deepEqual(f.statuses, ['completed']);
|
||||
assert.equal(f.timers.size, 0);
|
||||
f.session.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('trimmed YouTube accepts ENDED before PLAYING and never replays a completed READY', async () => {
|
||||
for (const endedCallback of [true, false]) {
|
||||
const f = trimmedPlaybackFixture();
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
f.setState(0); f.setTime(7);
|
||||
if (endedCallback) f.events.onStateChange({ data: 0, target: f.player });
|
||||
f.events.onReady({ target: f.player });
|
||||
assert.deepEqual(f.session.getState(), { phase: 'completed', currentTime: 7 });
|
||||
assert.deepEqual(f.calls, ['pause']);
|
||||
assert.deepEqual(f.statuses, ['completed']);
|
||||
assert.equal(f.timers.size, 0);
|
||||
f.session.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('trimmed YouTube waits through delayed startup, stops at source 0:07, and releases its timers', async () => {
|
||||
const f = trimmedPlaybackFixture();
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
f.advance(3000);
|
||||
assert.equal(f.session.getState().phase, 'starting');
|
||||
f.events.onReady({ target: f.player });
|
||||
f.events.onStateChange({ data: 1 });
|
||||
f.setTime(4); f.advance(4000);
|
||||
assert.equal(f.session.getState().phase, 'playing', 'seven wall-clock seconds do not truncate a delayed clip');
|
||||
f.setTime(7); f.advance(100);
|
||||
assert.deepEqual(f.session.getState(), { phase: 'completed', currentTime: 7 });
|
||||
assert.deepEqual(f.statuses, ['completed']);
|
||||
assert.equal(f.timers.size, 0);
|
||||
assert.equal(f.calls.at(-1), 'pause');
|
||||
f.session.destroy();
|
||||
});
|
||||
|
||||
test('trimmed YouTube bounds blocked, failed, stalled and never-started playback', async () => {
|
||||
for (const outcome of ['blocked', 'unavailable', 'timeout', 'stalled']) {
|
||||
const f = trimmedPlaybackFixture();
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
if (outcome === 'blocked') f.events.onAutoplayBlocked();
|
||||
else if (outcome === 'unavailable') f.events.onError();
|
||||
else if (outcome === 'stalled') { f.events.onStateChange({ data: 1 }); f.advance(12000); }
|
||||
else f.advance(5000);
|
||||
assert.equal(f.session.getState().phase, outcome === 'stalled' ? 'timeout' : outcome);
|
||||
assert.equal(f.timers.size, 0);
|
||||
const calls = [...f.calls];
|
||||
f.events.onReady({ target: f.player });
|
||||
f.events.onStateChange({ data: 1 });
|
||||
assert.deepEqual(f.calls, calls, 'late provider callbacks cannot restart a terminal attempt');
|
||||
f.session.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('trimmed YouTube teardown revokes pending SDK and player callbacks', async () => {
|
||||
const f = trimmedPlaybackFixture();
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
f.session.destroy();
|
||||
f.events.onReady({ target: f.player });
|
||||
f.events.onStateChange({ data: 1 });
|
||||
f.events.onError(); f.advance(20000);
|
||||
assert.deepEqual(f.calls, ['destroy']);
|
||||
assert.deepEqual(f.statuses, []);
|
||||
assert.equal(f.timers.size, 0);
|
||||
let constructCount = 0;
|
||||
let resolveApi;
|
||||
const session = createTrimmedYouTubePlayback({ iframe: {}, endAtSec: 7,
|
||||
loadApi: () => new Promise((resolve) => { resolveApi = resolve; }) });
|
||||
await Promise.resolve();
|
||||
session.destroy();
|
||||
resolveApi({ Player: class { constructor() { constructCount++; } } });
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
assert.equal(constructCount, 0);
|
||||
});
|
||||
|
||||
test('pausing YouTube removes its frame and rejects a late load from the stopped generation', () => {
|
||||
const nodes = [];
|
||||
class Element extends EventTarget {
|
||||
constructor(tag) { super(); this.tag = tag; this.children = []; this.dataset = {}; this.classList = { add() {}, remove() {} }; nodes.push(this); }
|
||||
setAttribute() {}
|
||||
append(...children) { for (const child of children) { child.parent = this; this.children.push(child); } }
|
||||
remove() { if (this.parent) this.parent.children = this.parent.children.filter((child) => child !== this); this.parent = null; }
|
||||
}
|
||||
const documentRef = {
|
||||
body: new Element('body'),
|
||||
createElement: (tag) => new Element(tag),
|
||||
createElementNS: (_, tag) => new Element(tag),
|
||||
};
|
||||
const media = createBhoteKoshiEmbeddedMedia({ viewer: {}, documentRef, globalRef: {} });
|
||||
const options = { observation: { title: 'Clip', media: { sourceUrl: 'https://youtu.be/DbqRexFxv3k' } }, anchor: {}, autoplay: true };
|
||||
assert.equal(media.show(options), true);
|
||||
const frame = nodes.find((node) => node.tag === 'iframe');
|
||||
const card = nodes.find((node) => node.tag === 'section');
|
||||
assert.ok(card.parent);
|
||||
assert.equal(media.pause(), true);
|
||||
assert.equal(card.parent, null);
|
||||
frame.dispatchEvent(new Event('load'));
|
||||
assert.equal(card.parent, null);
|
||||
assert.equal(media.show(options), true, 'an explicit replay creates a fresh player');
|
||||
media.destroy();
|
||||
assert.equal(documentRef.body.children.length, 0);
|
||||
});
|
||||
|
||||
test('resolves supported embedded callout providers from canonical public URLs', () => {
|
||||
assert.deepEqual(
|
||||
resolveEmbeddedMediaSource('https://www.youtube.com/watch?v=DbqRexFxv3k'),
|
||||
{
|
||||
provider: 'youtube',
|
||||
id: 'DbqRexFxv3k',
|
||||
url: 'https://www.youtube.com/watch?v=DbqRexFxv3k',
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
resolveEmbeddedMediaSource('https://www.facebook.com/reel/1571491657757829').provider,
|
||||
'facebook',
|
||||
);
|
||||
assert.deepEqual(
|
||||
resolveEmbeddedMediaSource('https://x.com/Indowatchosint/status/2092640554568007958'),
|
||||
{
|
||||
provider: 'x',
|
||||
id: '2092640554568007958',
|
||||
url: 'https://x.com/Indowatchosint/status/2092640554568007958',
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
resolveEmbeddedMediaSource('https://twitter.com/Indowatchosint/status/2092640554568007958').provider,
|
||||
'x',
|
||||
);
|
||||
assert.equal(
|
||||
resolveEmbeddedMediaSource('https://www.youtube.com/shorts/DbqRexFxv3k').id,
|
||||
'DbqRexFxv3k',
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects deceptive hosts, non-post X links, private targets, and iframe markup', () => {
|
||||
for (const value of [
|
||||
'https://x.com.evil.example/user/status/2092640554568007958',
|
||||
'https://x.com/Indowatchosint',
|
||||
'https://youtube.com.evil.example/watch?v=DbqRexFxv3k',
|
||||
'http://www.youtube.com/watch?v=DbqRexFxv3k',
|
||||
'https://localhost/video.mp4',
|
||||
'<iframe src="https://x.com/user/status/2092640554568007958"></iframe>',
|
||||
]) {
|
||||
assert.equal(resolveEmbeddedMediaSource(value), null, value);
|
||||
}
|
||||
assert.equal(publicEmbeddedMediaUrl('https://user:[email protected]/name/status/123'), null);
|
||||
});
|
||||
|
||||
test('builds privacy-aware provider frames and keeps X on its widget runtime', () => {
|
||||
const youtube = resolveEmbeddedMediaSource('https://youtu.be/DbqRexFxv3k');
|
||||
const youtubeFrame = new URL(embeddedMediaFrameUrl(youtube, { autoplay: true }));
|
||||
assert.equal(youtubeFrame.hostname, 'www.youtube-nocookie.com');
|
||||
assert.equal(youtubeFrame.pathname, '/embed/DbqRexFxv3k');
|
||||
assert.equal(youtubeFrame.searchParams.get('autoplay'), '1');
|
||||
assert.equal(youtubeFrame.searchParams.get('mute'), '1');
|
||||
|
||||
const timestampedFrame = new URL(embeddedMediaFrameUrl(youtube, {
|
||||
autoplay: true,
|
||||
startAtSec: 26,
|
||||
}));
|
||||
assert.equal(timestampedFrame.searchParams.get('start'), '26');
|
||||
|
||||
const facebook = resolveEmbeddedMediaSource('https://www.facebook.com/watch/?v=456');
|
||||
const facebookFrame = new URL(embeddedMediaFrameUrl(facebook, { autoplay: false }));
|
||||
assert.equal(facebookFrame.hostname, 'www.facebook.com');
|
||||
assert.equal(facebookFrame.pathname, '/plugins/video.php');
|
||||
assert.equal(facebookFrame.searchParams.get('href'), facebook.url);
|
||||
assert.equal(facebookFrame.searchParams.get('autoplay'), 'false');
|
||||
|
||||
const x = resolveEmbeddedMediaSource('https://x.com/Indowatchosint/status/2092640554568007958');
|
||||
assert.equal(embeddedMediaFrameUrl(x), null);
|
||||
});
|
||||
|
||||
test('Mailung playback stops at source timestamp 0:07 without changing other clips', async () => {
|
||||
const event = JSON.parse(await readFile(new URL('../../public/events/bhote-koshi-2026/event.json', import.meta.url), 'utf8'));
|
||||
const bounded = event.evidenceSpine.filter(point => point.media?.embedEndAtSec != null);
|
||||
assert.deepEqual(bounded.map(point => point.id), ['mailung-bazzar']);
|
||||
const media = bounded[0].media;
|
||||
assert.equal(media.embedEndAtSec, 7);
|
||||
const source = resolveEmbeddedMediaSource(media.sourceUrl);
|
||||
for (const autoplay of [false, true]) {
|
||||
const url = new URL(embeddedMediaFrameUrl(source, {
|
||||
autoplay, startAtSec: media.clipInSec, endAtSec: media.embedEndAtSec,
|
||||
}));
|
||||
assert.equal(url.searchParams.get('end'), '7');
|
||||
}
|
||||
for (const endAtSec of [undefined, null, NaN, Infinity, -1, 0, 3]) {
|
||||
const url = new URL(embeddedMediaFrameUrl(source, {startAtSec: 3, endAtSec}));
|
||||
assert.equal(url.searchParams.has('end'), false);
|
||||
}
|
||||
});
|
||||
|
||||
function preloadFixture(options = {}) {
|
||||
class Element extends EventTarget {
|
||||
constructor(tag) {
|
||||
super(); this.tag = tag; this.children = []; this.dataset = {};
|
||||
this.classList = { add() {}, remove() {}, contains() { return false; } };
|
||||
}
|
||||
setAttribute() {}
|
||||
removeAttribute() {}
|
||||
append(...children) {
|
||||
for (const child of children) { child.remove(); child.parent = this; this.children.push(child); }
|
||||
}
|
||||
remove() {
|
||||
if (this.parent) this.parent.children = this.parent.children.filter(child => child !== this);
|
||||
this.parent = null;
|
||||
}
|
||||
}
|
||||
const documentRef = {
|
||||
body: new Element('body'),
|
||||
createElement: tag => new Element(tag),
|
||||
createElementNS: (_, tag) => new Element(tag),
|
||||
};
|
||||
const media = createBhoteKoshiEmbeddedMedia({ viewer: {}, documentRef, globalRef: {}, ...options });
|
||||
return { media, root: documentRef.body.children[0] };
|
||||
}
|
||||
|
||||
for (const operation of ['pause', 'hide', 'destroy']) {
|
||||
test(`${operation} removes a hidden YouTube preload`, () => {
|
||||
const { media, root } = preloadFixture();
|
||||
media.warm({ sourceUrl: 'https://youtu.be/DbqRexFxv3k' });
|
||||
assert.equal(root.children.length, 1);
|
||||
media[operation]();
|
||||
assert.equal(root.children.length, 0);
|
||||
media.destroy();
|
||||
});
|
||||
}
|
||||
|
||||
test('camera updates retain a preload until the visible player loads', () => {
|
||||
const { media, root } = preloadFixture();
|
||||
const observation = { media: { sourceUrl: 'https://youtu.be/DbqRexFxv3k' } };
|
||||
media.warm({ observation });
|
||||
const frame = root.children[0];
|
||||
media.hide({ preserveWarm: true });
|
||||
media.warm({ observation });
|
||||
assert.equal(root.children[0], frame);
|
||||
assert.equal(media.show({ observation, anchor: {} }), true);
|
||||
assert.equal(frame.parent, root, 'keep the preload while the visible player starts');
|
||||
const card = root.children.find(node => node.tag === 'section');
|
||||
const body = card.children.find(node => node.className === 'bhote-embedded-callout-body');
|
||||
const player = body.children.find(node => node.className === 'bhote-embedded-callout-player');
|
||||
player.children.find(node => node.tag === 'iframe').dispatchEvent(new Event('load'));
|
||||
assert.equal(frame.parent, null, 'visible player readiness releases the preload');
|
||||
media.destroy();
|
||||
assert.equal(root.children.length, 0);
|
||||
});
|
||||
|
||||
test('hiding revokes a pending Facebook SDK before it can mount or subscribe', async () => {
|
||||
let resolveApi;
|
||||
const calls = [];
|
||||
const { media, root } = preloadFixture({
|
||||
facebookLoader: () => new Promise(resolve => { resolveApi = resolve; }),
|
||||
});
|
||||
media.warm({ sourceUrl: 'https://www.facebook.com/reel/1571491657757829' });
|
||||
assert.equal(root.children.length, 1);
|
||||
media.hide();
|
||||
assert.equal(root.children.length, 0);
|
||||
resolveApi({ Event: { subscribe() { calls.push('subscribe'); } }, XFBML: { parse() { calls.push('parse'); } } });
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
assert.deepEqual(calls, []);
|
||||
media.destroy();
|
||||
});
|
||||
|
||||
test('pausing cancels a mounted Facebook preload and rejects its late ready callback', async () => {
|
||||
let ready;
|
||||
let unsubscribed = 0;
|
||||
const timers = new Set();
|
||||
const playerCalls = [];
|
||||
const { media, root } = preloadFixture({
|
||||
globalRef: { setTimeout(fn) { timers.add(fn); return fn; }, clearTimeout(fn) { timers.delete(fn); } },
|
||||
facebookLoader: async () => ({
|
||||
Event: { subscribe(_name, callback) { ready = callback; }, unsubscribe() { unsubscribed++; } },
|
||||
XFBML: { parse() {} },
|
||||
}),
|
||||
});
|
||||
media.warm({ sourceUrl: 'https://www.facebook.com/reel/1571491657757829' });
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
assert.equal(timers.size, 1);
|
||||
media.pause();
|
||||
assert.equal(root.children.length, 0);
|
||||
assert.equal(timers.size, 0);
|
||||
assert.equal(unsubscribed, 1);
|
||||
ready({ type: 'video', instance: { play() { playerCalls.push('play'); }, pause() { playerCalls.push('pause'); } } });
|
||||
assert.deepEqual(playerCalls, []);
|
||||
media.destroy();
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,499 @@
|
||||
/**
|
||||
* Simplified GeoPera river centerline from the point nearest the owner-provided
|
||||
* Debris-Dammed Lake observation through Trishuli Bazaar. Every fourth published
|
||||
* vertex is retained, plus the exact endpoint nearest the verified bazaar pin.
|
||||
*
|
||||
* Source: https://github.com/geo-pera/bhotekoshi-2026-reconstruction/blob/main/vectors/river_centerline.geojson
|
||||
* License: CC BY-NC 4.0 (derived data; Planet Labs PBC / Vantor open data)
|
||||
*/
|
||||
export const BHOTE_KOSHI_FLOOD_PATH = Object.freeze([
|
||||
Object.freeze([85.48476, 28.33255]),
|
||||
Object.freeze([85.48353, 28.33308]),
|
||||
Object.freeze([85.48231, 28.33279]),
|
||||
Object.freeze([85.48108, 28.33278]),
|
||||
Object.freeze([85.47985, 28.33304]),
|
||||
Object.freeze([85.47863, 28.33275]),
|
||||
Object.freeze([85.47741, 28.33301]),
|
||||
Object.freeze([85.47618, 28.33299]),
|
||||
Object.freeze([85.47496, 28.33325]),
|
||||
Object.freeze([85.47373, 28.33351]),
|
||||
Object.freeze([85.4725, 28.33377]),
|
||||
Object.freeze([85.47128, 28.33375]),
|
||||
Object.freeze([85.47005, 28.33401]),
|
||||
Object.freeze([85.46881, 28.33481]),
|
||||
Object.freeze([85.46759, 28.33506]),
|
||||
Object.freeze([85.46636, 28.33532]),
|
||||
Object.freeze([85.46513, 28.33585]),
|
||||
Object.freeze([85.4639, 28.33584]),
|
||||
Object.freeze([85.46267, 28.33609]),
|
||||
Object.freeze([85.46145, 28.33635]),
|
||||
Object.freeze([85.46022, 28.33661]),
|
||||
Object.freeze([85.45899, 28.33659]),
|
||||
Object.freeze([85.45777, 28.33685]),
|
||||
Object.freeze([85.45655, 28.33629]),
|
||||
Object.freeze([85.45533, 28.33628]),
|
||||
Object.freeze([85.45411, 28.336]),
|
||||
Object.freeze([85.45289, 28.33544]),
|
||||
Object.freeze([85.45167, 28.33543]),
|
||||
Object.freeze([85.45045, 28.33514]),
|
||||
Object.freeze([85.44922, 28.33513]),
|
||||
Object.freeze([85.448, 28.33511]),
|
||||
Object.freeze([85.44677, 28.33537]),
|
||||
Object.freeze([85.44554, 28.33563]),
|
||||
Object.freeze([85.44432, 28.33534]),
|
||||
Object.freeze([85.4431, 28.33533]),
|
||||
Object.freeze([85.44188, 28.33504]),
|
||||
Object.freeze([85.44065, 28.3353]),
|
||||
Object.freeze([85.43943, 28.33529]),
|
||||
Object.freeze([85.43821, 28.335]),
|
||||
Object.freeze([85.43699, 28.33472]),
|
||||
Object.freeze([85.43577, 28.33416]),
|
||||
Object.freeze([85.43456, 28.33334]),
|
||||
Object.freeze([85.43335, 28.33251]),
|
||||
Object.freeze([85.43214, 28.33141]),
|
||||
Object.freeze([85.43093, 28.33059]),
|
||||
Object.freeze([85.42971, 28.33057]),
|
||||
Object.freeze([85.42848, 28.33056]),
|
||||
Object.freeze([85.42726, 28.33027]),
|
||||
Object.freeze([85.42604, 28.33026]),
|
||||
Object.freeze([85.42482, 28.33025]),
|
||||
Object.freeze([85.4236, 28.32996]),
|
||||
Object.freeze([85.42238, 28.32968]),
|
||||
Object.freeze([85.42116, 28.32939]),
|
||||
Object.freeze([85.41995, 28.32829]),
|
||||
Object.freeze([85.41873, 28.32774]),
|
||||
Object.freeze([85.41752, 28.32718]),
|
||||
Object.freeze([85.41629, 28.32744]),
|
||||
Object.freeze([85.41508, 28.32634]),
|
||||
Object.freeze([85.41387, 28.32524]),
|
||||
Object.freeze([85.41297, 28.32415]),
|
||||
Object.freeze([85.41268, 28.32306]),
|
||||
Object.freeze([85.41239, 28.32198]),
|
||||
Object.freeze([85.41241, 28.3209]),
|
||||
Object.freeze([85.41273, 28.31982]),
|
||||
Object.freeze([85.41275, 28.31873]),
|
||||
Object.freeze([85.41276, 28.31765]),
|
||||
Object.freeze([85.41308, 28.31657]),
|
||||
Object.freeze([85.41249, 28.31548]),
|
||||
Object.freeze([85.4122, 28.3144]),
|
||||
Object.freeze([85.41252, 28.31332]),
|
||||
Object.freeze([85.41254, 28.31223]),
|
||||
Object.freeze([85.41194, 28.31114]),
|
||||
Object.freeze([85.41196, 28.31006]),
|
||||
Object.freeze([85.41167, 28.30897]),
|
||||
Object.freeze([85.41077, 28.30788]),
|
||||
Object.freeze([85.40955, 28.30705]),
|
||||
Object.freeze([85.40865, 28.30596]),
|
||||
Object.freeze([85.40744, 28.30513]),
|
||||
Object.freeze([85.40623, 28.30404]),
|
||||
Object.freeze([85.40503, 28.30294]),
|
||||
Object.freeze([85.40381, 28.30238]),
|
||||
Object.freeze([85.4026, 28.30156]),
|
||||
Object.freeze([85.40139, 28.30046]),
|
||||
Object.freeze([85.40019, 28.29936]),
|
||||
Object.freeze([85.39898, 28.29827]),
|
||||
Object.freeze([85.39777, 28.29717]),
|
||||
Object.freeze([85.39748, 28.29608]),
|
||||
Object.freeze([85.39658, 28.29499]),
|
||||
Object.freeze([85.39629, 28.2939]),
|
||||
Object.freeze([85.3957, 28.29281]),
|
||||
Object.freeze([85.39449, 28.29172]),
|
||||
Object.freeze([85.39328, 28.29062]),
|
||||
Object.freeze([85.39238, 28.28953]),
|
||||
Object.freeze([85.39117, 28.28897]),
|
||||
Object.freeze([85.38996, 28.28787]),
|
||||
Object.freeze([85.38875, 28.28678]),
|
||||
Object.freeze([85.38754, 28.28622]),
|
||||
Object.freeze([85.38632, 28.28593]),
|
||||
Object.freeze([85.38511, 28.28511]),
|
||||
Object.freeze([85.3839, 28.28401]),
|
||||
Object.freeze([85.383, 28.28292]),
|
||||
Object.freeze([85.38179, 28.28182]),
|
||||
Object.freeze([85.38089, 28.28073]),
|
||||
Object.freeze([85.37968, 28.28017]),
|
||||
Object.freeze([85.37846, 28.27934]),
|
||||
Object.freeze([85.37725, 28.27852]),
|
||||
Object.freeze([85.37666, 28.27743]),
|
||||
Object.freeze([85.37668, 28.27634]),
|
||||
Object.freeze([85.37669, 28.27526]),
|
||||
Object.freeze([85.37701, 28.27418]),
|
||||
Object.freeze([85.37642, 28.27309]),
|
||||
Object.freeze([85.37644, 28.27201]),
|
||||
Object.freeze([85.37615, 28.27092]),
|
||||
Object.freeze([85.37616, 28.26984]),
|
||||
Object.freeze([85.37648, 28.26876]),
|
||||
Object.freeze([85.37558, 28.26767]),
|
||||
Object.freeze([85.37499, 28.26658]),
|
||||
Object.freeze([85.37378, 28.26548]),
|
||||
Object.freeze([85.37288, 28.26439]),
|
||||
Object.freeze([85.37198, 28.26329]),
|
||||
Object.freeze([85.37169, 28.26221]),
|
||||
Object.freeze([85.37048, 28.26111]),
|
||||
Object.freeze([85.36928, 28.26001]),
|
||||
Object.freeze([85.36806, 28.25973]),
|
||||
Object.freeze([85.36685, 28.25863]),
|
||||
Object.freeze([85.36656, 28.25754]),
|
||||
Object.freeze([85.36566, 28.25645]),
|
||||
Object.freeze([85.36507, 28.25536]),
|
||||
Object.freeze([85.36478, 28.25427]),
|
||||
Object.freeze([85.36449, 28.25318]),
|
||||
Object.freeze([85.36512, 28.25211]),
|
||||
Object.freeze([85.36452, 28.25102]),
|
||||
Object.freeze([85.36423, 28.24993]),
|
||||
Object.freeze([85.36425, 28.24885]),
|
||||
Object.freeze([85.36335, 28.24776]),
|
||||
Object.freeze([85.36245, 28.24666]),
|
||||
Object.freeze([85.36124, 28.24556]),
|
||||
Object.freeze([85.36004, 28.24447]),
|
||||
Object.freeze([85.35882, 28.24391]),
|
||||
Object.freeze([85.35823, 28.24282]),
|
||||
Object.freeze([85.35733, 28.24173]),
|
||||
Object.freeze([85.35734, 28.24064]),
|
||||
Object.freeze([85.35797, 28.23957]),
|
||||
Object.freeze([85.35799, 28.23849]),
|
||||
Object.freeze([85.3577, 28.2374]),
|
||||
Object.freeze([85.35802, 28.23632]),
|
||||
Object.freeze([85.35834, 28.23524]),
|
||||
Object.freeze([85.35928, 28.23417]),
|
||||
Object.freeze([85.3596, 28.23309]),
|
||||
Object.freeze([85.36023, 28.23202]),
|
||||
Object.freeze([85.36024, 28.23093]),
|
||||
Object.freeze([85.36057, 28.22985]),
|
||||
Object.freeze([85.36058, 28.22877]),
|
||||
Object.freeze([85.3609, 28.22769]),
|
||||
Object.freeze([85.36092, 28.22661]),
|
||||
Object.freeze([85.36094, 28.22553]),
|
||||
Object.freeze([85.36126, 28.22445]),
|
||||
Object.freeze([85.36067, 28.22336]),
|
||||
Object.freeze([85.36007, 28.22227]),
|
||||
Object.freeze([85.35886, 28.22117]),
|
||||
Object.freeze([85.35796, 28.22008]),
|
||||
Object.freeze([85.35706, 28.21898]),
|
||||
Object.freeze([85.35616, 28.21789]),
|
||||
Object.freeze([85.35526, 28.21679]),
|
||||
Object.freeze([85.35467, 28.2157]),
|
||||
Object.freeze([85.35469, 28.21462]),
|
||||
Object.freeze([85.3547, 28.21354]),
|
||||
Object.freeze([85.35472, 28.21246]),
|
||||
Object.freeze([85.35443, 28.21137]),
|
||||
Object.freeze([85.35414, 28.21028]),
|
||||
Object.freeze([85.35416, 28.2092]),
|
||||
Object.freeze([85.35448, 28.20812]),
|
||||
Object.freeze([85.3548, 28.20704]),
|
||||
Object.freeze([85.35482, 28.20596]),
|
||||
Object.freeze([85.35392, 28.20486]),
|
||||
Object.freeze([85.35332, 28.20377]),
|
||||
Object.freeze([85.35303, 28.20269]),
|
||||
Object.freeze([85.35275, 28.2016]),
|
||||
Object.freeze([85.35246, 28.20052]),
|
||||
Object.freeze([85.35247, 28.19943]),
|
||||
Object.freeze([85.35188, 28.19834]),
|
||||
Object.freeze([85.35128, 28.19725]),
|
||||
Object.freeze([85.35038, 28.19616]),
|
||||
Object.freeze([85.3501, 28.19507]),
|
||||
Object.freeze([85.3495, 28.19398]),
|
||||
Object.freeze([85.34891, 28.19289]),
|
||||
Object.freeze([85.34862, 28.1918]),
|
||||
Object.freeze([85.34802, 28.19071]),
|
||||
Object.freeze([85.34743, 28.18962]),
|
||||
Object.freeze([85.34622, 28.18853]),
|
||||
Object.freeze([85.34593, 28.18744]),
|
||||
Object.freeze([85.34565, 28.18635]),
|
||||
Object.freeze([85.34475, 28.18526]),
|
||||
Object.freeze([85.34385, 28.18417]),
|
||||
Object.freeze([85.34325, 28.18308]),
|
||||
Object.freeze([85.34266, 28.18199]),
|
||||
Object.freeze([85.34237, 28.1809]),
|
||||
Object.freeze([85.34239, 28.17982]),
|
||||
Object.freeze([85.34271, 28.17874]),
|
||||
Object.freeze([85.34272, 28.17765]),
|
||||
Object.freeze([85.34274, 28.17657]),
|
||||
Object.freeze([85.34215, 28.17548]),
|
||||
Object.freeze([85.34216, 28.1744]),
|
||||
Object.freeze([85.34249, 28.17332]),
|
||||
Object.freeze([85.34189, 28.17223]),
|
||||
Object.freeze([85.34191, 28.17115]),
|
||||
Object.freeze([85.34192, 28.17006]),
|
||||
Object.freeze([85.34255, 28.16899]),
|
||||
Object.freeze([85.34226, 28.1679]),
|
||||
Object.freeze([85.34289, 28.16683]),
|
||||
Object.freeze([85.3423, 28.16574]),
|
||||
Object.freeze([85.3417, 28.16465]),
|
||||
Object.freeze([85.34048, 28.16436]),
|
||||
Object.freeze([85.33928, 28.16353]),
|
||||
Object.freeze([85.33807, 28.16271]),
|
||||
Object.freeze([85.33747, 28.16162]),
|
||||
Object.freeze([85.33718, 28.16053]),
|
||||
Object.freeze([85.33628, 28.15971]),
|
||||
Object.freeze([85.33507, 28.15888]),
|
||||
Object.freeze([85.33385, 28.15859]),
|
||||
Object.freeze([85.33295, 28.15777]),
|
||||
Object.freeze([85.33174, 28.15694]),
|
||||
Object.freeze([85.33053, 28.15639]),
|
||||
Object.freeze([85.32932, 28.15529]),
|
||||
Object.freeze([85.32842, 28.1542]),
|
||||
Object.freeze([85.32721, 28.15337]),
|
||||
Object.freeze([85.32631, 28.15228]),
|
||||
Object.freeze([85.3251, 28.15145]),
|
||||
Object.freeze([85.32389, 28.15089]),
|
||||
Object.freeze([85.32267, 28.15061]),
|
||||
Object.freeze([85.32147, 28.14978]),
|
||||
Object.freeze([85.32026, 28.14895]),
|
||||
Object.freeze([85.31904, 28.1484]),
|
||||
Object.freeze([85.31783, 28.14784]),
|
||||
Object.freeze([85.31663, 28.14674]),
|
||||
Object.freeze([85.31541, 28.14619]),
|
||||
Object.freeze([85.3142, 28.14536]),
|
||||
Object.freeze([85.313, 28.14426]),
|
||||
Object.freeze([85.3124, 28.14317]),
|
||||
Object.freeze([85.31181, 28.14208]),
|
||||
Object.freeze([85.31058, 28.14261]),
|
||||
Object.freeze([85.30936, 28.14232]),
|
||||
Object.freeze([85.30816, 28.14122]),
|
||||
Object.freeze([85.30695, 28.14012]),
|
||||
Object.freeze([85.30575, 28.13903]),
|
||||
Object.freeze([85.30485, 28.13793]),
|
||||
Object.freeze([85.30456, 28.13685]),
|
||||
Object.freeze([85.30366, 28.13575]),
|
||||
Object.freeze([85.30307, 28.13466]),
|
||||
Object.freeze([85.30248, 28.13357]),
|
||||
Object.freeze([85.30127, 28.13274]),
|
||||
Object.freeze([85.30067, 28.13165]),
|
||||
Object.freeze([85.30039, 28.13057]),
|
||||
Object.freeze([85.3001, 28.12948]),
|
||||
Object.freeze([85.29889, 28.12838]),
|
||||
Object.freeze([85.29799, 28.12729]),
|
||||
Object.freeze([85.29709, 28.12647]),
|
||||
Object.freeze([85.29588, 28.12591]),
|
||||
Object.freeze([85.29467, 28.12508]),
|
||||
Object.freeze([85.29347, 28.12398]),
|
||||
Object.freeze([85.29226, 28.12316]),
|
||||
Object.freeze([85.29105, 28.12206]),
|
||||
Object.freeze([85.28985, 28.12096]),
|
||||
Object.freeze([85.28864, 28.11986]),
|
||||
Object.freeze([85.28775, 28.11877]),
|
||||
Object.freeze([85.28653, 28.11821]),
|
||||
Object.freeze([85.28532, 28.11765]),
|
||||
Object.freeze([85.28411, 28.11683]),
|
||||
Object.freeze([85.2829, 28.116]),
|
||||
Object.freeze([85.28231, 28.11491]),
|
||||
Object.freeze([85.28172, 28.11382]),
|
||||
Object.freeze([85.28082, 28.11272]),
|
||||
Object.freeze([85.27961, 28.11217]),
|
||||
Object.freeze([85.2787, 28.11134]),
|
||||
Object.freeze([85.27749, 28.11079]),
|
||||
Object.freeze([85.27629, 28.10969]),
|
||||
Object.freeze([85.27508, 28.10859]),
|
||||
Object.freeze([85.27388, 28.10749]),
|
||||
Object.freeze([85.27267, 28.10667]),
|
||||
Object.freeze([85.27177, 28.10584]),
|
||||
Object.freeze([85.27056, 28.10474]),
|
||||
Object.freeze([85.26936, 28.10392]),
|
||||
Object.freeze([85.26815, 28.10309]),
|
||||
Object.freeze([85.26695, 28.10199]),
|
||||
Object.freeze([85.26574, 28.10089]),
|
||||
Object.freeze([85.26454, 28.09979]),
|
||||
Object.freeze([85.26364, 28.0987]),
|
||||
Object.freeze([85.26274, 28.09761]),
|
||||
Object.freeze([85.26153, 28.09678]),
|
||||
Object.freeze([85.26033, 28.09568]),
|
||||
Object.freeze([85.25911, 28.09539]),
|
||||
Object.freeze([85.2579, 28.09511]),
|
||||
Object.freeze([85.25668, 28.09509]),
|
||||
Object.freeze([85.25546, 28.09481]),
|
||||
Object.freeze([85.25425, 28.09398]),
|
||||
Object.freeze([85.25304, 28.09342]),
|
||||
Object.freeze([85.25183, 28.09286]),
|
||||
Object.freeze([85.25061, 28.09285]),
|
||||
Object.freeze([85.24939, 28.09256]),
|
||||
Object.freeze([85.24818, 28.09201]),
|
||||
Object.freeze([85.24696, 28.09199]),
|
||||
Object.freeze([85.24573, 28.09252]),
|
||||
Object.freeze([85.24451, 28.0925]),
|
||||
Object.freeze([85.24328, 28.09248]),
|
||||
Object.freeze([85.24207, 28.0922]),
|
||||
Object.freeze([85.24085, 28.09191]),
|
||||
Object.freeze([85.23963, 28.0919]),
|
||||
Object.freeze([85.23841, 28.09161]),
|
||||
Object.freeze([85.2372, 28.09132]),
|
||||
Object.freeze([85.23598, 28.09104]),
|
||||
Object.freeze([85.23476, 28.09102]),
|
||||
Object.freeze([85.23355, 28.09019]),
|
||||
Object.freeze([85.23234, 28.08964]),
|
||||
Object.freeze([85.23113, 28.08881]),
|
||||
Object.freeze([85.22993, 28.08771]),
|
||||
Object.freeze([85.22872, 28.08688]),
|
||||
Object.freeze([85.22782, 28.08579]),
|
||||
Object.freeze([85.22692, 28.08496]),
|
||||
Object.freeze([85.22571, 28.08414]),
|
||||
Object.freeze([85.2245, 28.08358]),
|
||||
Object.freeze([85.22329, 28.08302]),
|
||||
Object.freeze([85.22208, 28.08219]),
|
||||
Object.freeze([85.22087, 28.08164]),
|
||||
Object.freeze([85.21966, 28.08108]),
|
||||
Object.freeze([85.21876, 28.08026]),
|
||||
Object.freeze([85.21755, 28.07943]),
|
||||
Object.freeze([85.21635, 28.07833]),
|
||||
Object.freeze([85.21514, 28.07723]),
|
||||
Object.freeze([85.21394, 28.0764]),
|
||||
Object.freeze([85.21272, 28.07585]),
|
||||
Object.freeze([85.21151, 28.07529]),
|
||||
Object.freeze([85.2103, 28.075]),
|
||||
Object.freeze([85.2094, 28.07391]),
|
||||
Object.freeze([85.20881, 28.07282]),
|
||||
Object.freeze([85.20791, 28.07172]),
|
||||
Object.freeze([85.20762, 28.07063]),
|
||||
Object.freeze([85.20672, 28.06954]),
|
||||
Object.freeze([85.20552, 28.06844]),
|
||||
Object.freeze([85.20523, 28.06735]),
|
||||
Object.freeze([85.20495, 28.06627]),
|
||||
Object.freeze([85.20588, 28.0652]),
|
||||
Object.freeze([85.2062, 28.06412]),
|
||||
Object.freeze([85.20683, 28.06304]),
|
||||
Object.freeze([85.20716, 28.06196]),
|
||||
Object.freeze([85.20717, 28.06088]),
|
||||
Object.freeze([85.2078, 28.05981]),
|
||||
Object.freeze([85.20751, 28.05872]),
|
||||
Object.freeze([85.20814, 28.05765]),
|
||||
Object.freeze([85.20847, 28.05657]),
|
||||
Object.freeze([85.20787, 28.05548]),
|
||||
Object.freeze([85.20759, 28.05439]),
|
||||
Object.freeze([85.20699, 28.0533]),
|
||||
Object.freeze([85.2061, 28.0522]),
|
||||
Object.freeze([85.20489, 28.05138]),
|
||||
Object.freeze([85.20369, 28.05028]),
|
||||
Object.freeze([85.2031, 28.04919]),
|
||||
Object.freeze([85.2022, 28.04809]),
|
||||
Object.freeze([85.20191, 28.04701]),
|
||||
Object.freeze([85.20101, 28.04591]),
|
||||
Object.freeze([85.19981, 28.04508]),
|
||||
Object.freeze([85.1986, 28.04398]),
|
||||
Object.freeze([85.19739, 28.04343]),
|
||||
Object.freeze([85.1965, 28.04233]),
|
||||
Object.freeze([85.19529, 28.0415]),
|
||||
Object.freeze([85.19439, 28.04041]),
|
||||
Object.freeze([85.1938, 28.03932]),
|
||||
Object.freeze([85.19382, 28.03824]),
|
||||
Object.freeze([85.19384, 28.03715]),
|
||||
Object.freeze([85.19355, 28.03607]),
|
||||
Object.freeze([85.19357, 28.03498]),
|
||||
Object.freeze([85.19328, 28.0339]),
|
||||
Object.freeze([85.1936, 28.03282]),
|
||||
Object.freeze([85.19362, 28.03174]),
|
||||
Object.freeze([85.19364, 28.03065]),
|
||||
Object.freeze([85.19305, 28.02956]),
|
||||
Object.freeze([85.19215, 28.02847]),
|
||||
Object.freeze([85.19156, 28.02738]),
|
||||
Object.freeze([85.19097, 28.02629]),
|
||||
Object.freeze([85.18976, 28.02573]),
|
||||
Object.freeze([85.18854, 28.02571]),
|
||||
Object.freeze([85.18732, 28.02515]),
|
||||
Object.freeze([85.18611, 28.0246]),
|
||||
Object.freeze([85.18522, 28.0235]),
|
||||
Object.freeze([85.18493, 28.02242]),
|
||||
Object.freeze([85.18556, 28.02134]),
|
||||
Object.freeze([85.1868, 28.02027]),
|
||||
Object.freeze([85.18742, 28.0192]),
|
||||
Object.freeze([85.18714, 28.01811]),
|
||||
Object.freeze([85.18746, 28.01703]),
|
||||
Object.freeze([85.18656, 28.01594]),
|
||||
Object.freeze([85.18536, 28.01484]),
|
||||
Object.freeze([85.18446, 28.01375]),
|
||||
Object.freeze([85.18418, 28.01266]),
|
||||
Object.freeze([85.18389, 28.01157]),
|
||||
Object.freeze([85.1836, 28.01049]),
|
||||
Object.freeze([85.18362, 28.0094]),
|
||||
Object.freeze([85.18364, 28.00832]),
|
||||
Object.freeze([85.18366, 28.00724]),
|
||||
Object.freeze([85.18368, 28.00615]),
|
||||
Object.freeze([85.1843, 28.00508]),
|
||||
Object.freeze([85.18432, 28.004]),
|
||||
Object.freeze([85.18404, 28.00291]),
|
||||
Object.freeze([85.18375, 28.00182]),
|
||||
Object.freeze([85.18438, 28.00075]),
|
||||
Object.freeze([85.18501, 27.99967]),
|
||||
Object.freeze([85.18533, 27.9986]),
|
||||
Object.freeze([85.18596, 27.99752]),
|
||||
Object.freeze([85.18567, 27.99643]),
|
||||
Object.freeze([85.18477, 27.99534]),
|
||||
Object.freeze([85.18357, 27.99424]),
|
||||
Object.freeze([85.18237, 27.99314]),
|
||||
Object.freeze([85.18239, 27.99206]),
|
||||
Object.freeze([85.18302, 27.99098]),
|
||||
Object.freeze([85.18273, 27.9899]),
|
||||
Object.freeze([85.18152, 27.98907]),
|
||||
Object.freeze([85.18062, 27.98824]),
|
||||
Object.freeze([85.18034, 27.98716]),
|
||||
Object.freeze([85.18066, 27.98608]),
|
||||
Object.freeze([85.18037, 27.98499]),
|
||||
Object.freeze([85.18039, 27.98391]),
|
||||
Object.freeze([85.18041, 27.98283]),
|
||||
Object.freeze([85.18043, 27.98174]),
|
||||
Object.freeze([85.18075, 27.98067]),
|
||||
Object.freeze([85.18138, 27.97959]),
|
||||
Object.freeze([85.1814, 27.97851]),
|
||||
Object.freeze([85.18172, 27.97743]),
|
||||
Object.freeze([85.18204, 27.97635]),
|
||||
Object.freeze([85.18236, 27.97527]),
|
||||
Object.freeze([85.18299, 27.9742]),
|
||||
Object.freeze([85.18362, 27.97312]),
|
||||
Object.freeze([85.18425, 27.97205]),
|
||||
Object.freeze([85.18396, 27.97096]),
|
||||
Object.freeze([85.18275, 27.9704]),
|
||||
Object.freeze([85.18154, 27.97012]),
|
||||
Object.freeze([85.18033, 27.96956]),
|
||||
Object.freeze([85.17911, 27.96927]),
|
||||
Object.freeze([85.17821, 27.96818]),
|
||||
Object.freeze([85.17762, 27.96709]),
|
||||
Object.freeze([85.17703, 27.966]),
|
||||
Object.freeze([85.17613, 27.9649]),
|
||||
Object.freeze([85.17492, 27.96434]),
|
||||
Object.freeze([85.17371, 27.96406]),
|
||||
Object.freeze([85.1725, 27.9635]),
|
||||
Object.freeze([85.17128, 27.96321]),
|
||||
Object.freeze([85.17008, 27.96238]),
|
||||
Object.freeze([85.16888, 27.96129]),
|
||||
Object.freeze([85.16859, 27.9602]),
|
||||
Object.freeze([85.168, 27.95911]),
|
||||
Object.freeze([85.16771, 27.95802]),
|
||||
Object.freeze([85.16682, 27.95693]),
|
||||
Object.freeze([85.16561, 27.95637]),
|
||||
Object.freeze([85.1644, 27.95581]),
|
||||
Object.freeze([85.16318, 27.95552]),
|
||||
Object.freeze([85.16198, 27.95442]),
|
||||
Object.freeze([85.1623, 27.95335]),
|
||||
Object.freeze([85.16232, 27.95226]),
|
||||
Object.freeze([85.16234, 27.95118]),
|
||||
Object.freeze([85.16236, 27.9501]),
|
||||
Object.freeze([85.16238, 27.94902]),
|
||||
Object.freeze([85.1627, 27.94794]),
|
||||
Object.freeze([85.16241, 27.94685]),
|
||||
Object.freeze([85.16152, 27.94576]),
|
||||
Object.freeze([85.16093, 27.94466]),
|
||||
Object.freeze([85.16034, 27.94357]),
|
||||
Object.freeze([85.15974, 27.94248]),
|
||||
Object.freeze([85.15915, 27.94139]),
|
||||
Object.freeze([85.15887, 27.9403]),
|
||||
Object.freeze([85.15797, 27.93921]),
|
||||
Object.freeze([85.15738, 27.93812]),
|
||||
Object.freeze([85.15679, 27.93703]),
|
||||
Object.freeze([85.1562, 27.93594]),
|
||||
Object.freeze([85.15561, 27.93485]),
|
||||
Object.freeze([85.15532, 27.93376]),
|
||||
Object.freeze([85.15473, 27.93267]),
|
||||
Object.freeze([85.15352, 27.93184]),
|
||||
Object.freeze([85.15229, 27.93291]),
|
||||
Object.freeze([85.15105, 27.9337]),
|
||||
Object.freeze([85.15015, 27.93288]),
|
||||
Object.freeze([85.14987, 27.93179]),
|
||||
Object.freeze([85.14988, 27.93071]),
|
||||
Object.freeze([85.14929, 27.92962]),
|
||||
Object.freeze([85.14901, 27.92853]),
|
||||
Object.freeze([85.14994, 27.92746]),
|
||||
Object.freeze([85.15057, 27.92639]),
|
||||
Object.freeze([85.15089, 27.92531]),
|
||||
Object.freeze([85.15121, 27.92423]),
|
||||
Object.freeze([85.15123, 27.92315]),
|
||||
Object.freeze([85.15034, 27.92205]),
|
||||
Object.freeze([85.14943, 27.92177]),
|
||||
]);
|
||||
@@ -0,0 +1,117 @@
|
||||
// Overview pins follow the event pack through Devighat.
|
||||
// Charaudi remains a separate evidence shot, and none of these observation
|
||||
// locations extends the mapped flood path.
|
||||
export const INCIDENT_OVERVIEW_PLACES = Object.freeze(
|
||||
[
|
||||
[
|
||||
'immediate-collapse-viewpoint',
|
||||
'COLLAPSE VIEWPOINT',
|
||||
28.3632333,
|
||||
85.4423639,
|
||||
2850,
|
||||
'left',
|
||||
],
|
||||
[
|
||||
'debris-dammed-lake',
|
||||
'DAMMED LAKE',
|
||||
28.3346111,
|
||||
85.4861389,
|
||||
2750,
|
||||
'right',
|
||||
],
|
||||
[
|
||||
'second-landslide',
|
||||
'SECOND LANDSLIDE',
|
||||
28.3379706,
|
||||
85.4688823,
|
||||
2750,
|
||||
'left',
|
||||
],
|
||||
[
|
||||
'gyirong-border-gate',
|
||||
'BORDER GATE',
|
||||
28.2808338,
|
||||
85.3780248,
|
||||
1818,
|
||||
'right',
|
||||
],
|
||||
['timure-cluster', 'TIMURE', 28.2519361, 85.3665833, 1689, 'left'],
|
||||
[
|
||||
'syabru-besi',
|
||||
'SYABRU BESI',
|
||||
28.1602583333333,
|
||||
85.3325666666667,
|
||||
1417,
|
||||
'right',
|
||||
],
|
||||
['dhunche', 'DHUNCHE', 28.1131861111111, 85.2953305555556, 1300, 'left'],
|
||||
[
|
||||
'mailung-upper-trishuli',
|
||||
'UPPER TRISHULI',
|
||||
28.090925,
|
||||
85.2322916666667,
|
||||
950,
|
||||
'right',
|
||||
],
|
||||
[
|
||||
'mailung-bazzar',
|
||||
'MAILUNG',
|
||||
28.0761666666667,
|
||||
85.2125277777778,
|
||||
800,
|
||||
'left',
|
||||
],
|
||||
[
|
||||
'dandagaun',
|
||||
'DANDAGAUN',
|
||||
28.0751944444444,
|
||||
85.2076944444444,
|
||||
780,
|
||||
'right-lower',
|
||||
],
|
||||
[
|
||||
'dandagaun-viewpoint',
|
||||
'DANDAGAUN VIEWPOINT',
|
||||
28.0354,
|
||||
85.1894361111111,
|
||||
700,
|
||||
'right',
|
||||
],
|
||||
[
|
||||
'betrawati-bazaar',
|
||||
'BETRAWATI',
|
||||
27.9758992572246,
|
||||
85.1835331689033,
|
||||
600,
|
||||
'left',
|
||||
],
|
||||
[
|
||||
'bhainse',
|
||||
'BHAINSE',
|
||||
27.9623111111111,
|
||||
85.1739777777778,
|
||||
500,
|
||||
'right-lower',
|
||||
],
|
||||
[
|
||||
'bidur-trishuli-bridge',
|
||||
'BIDUR BRIDGE',
|
||||
27.920858,
|
||||
85.148029,
|
||||
510,
|
||||
'left',
|
||||
],
|
||||
[
|
||||
'devighat-taadi-khola-bridge',
|
||||
'DEVIGHAT BRIDGE',
|
||||
27.8585278,
|
||||
85.1090318,
|
||||
350,
|
||||
'right',
|
||||
],
|
||||
].map(([id, title, lat, lon, heightM, placement]) =>
|
||||
Object.freeze({ id, title, lat, lon, heightM, placement }),
|
||||
),
|
||||
);
|
||||
|
||||
export const INCIDENT_OVERVIEW_HOLD_SEC = 11;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,671 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { INCIDENT_OVERVIEW_HOLD_SEC, INCIDENT_OVERVIEW_PLACES } from './bhoteKoshiIncidentPlaces.js';
|
||||
import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { SceneDirector } from '../scenes/director.js';
|
||||
import {
|
||||
anchoredCalloutPresentation,
|
||||
BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION,
|
||||
BHOTE_KOSHI_FLOOD_PATH_PRESENTATION,
|
||||
BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION,
|
||||
BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID,
|
||||
BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION,
|
||||
BHOTE_KOSHI_REGIONAL_PRESENTATION,
|
||||
BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
|
||||
closeRegionRing,
|
||||
createBhoteKoshiLocatorLayer,
|
||||
largestGeoJsonRing,
|
||||
NEPAL_CONTEXT_PRESENTATION,
|
||||
revealPolylinePositions,
|
||||
} from './bhoteKoshiLocator.js';
|
||||
|
||||
function viewerFixture() {
|
||||
const added = [];
|
||||
const removed = [];
|
||||
return {
|
||||
creditDisplay: { addStaticCredit() {} },
|
||||
dataSources: {
|
||||
add(source) { added.push(source); },
|
||||
remove(source) { removed.push(source); },
|
||||
},
|
||||
_test: { added, removed },
|
||||
};
|
||||
}
|
||||
|
||||
function nonRunningAnimation() {
|
||||
return {
|
||||
scheduleFrame() { return 1; },
|
||||
cancelFrame() {},
|
||||
};
|
||||
}
|
||||
|
||||
function overlayHostFixture() {
|
||||
const publications = [];
|
||||
const clears = [];
|
||||
return {
|
||||
setEntries(sourceId, entries, options) {
|
||||
publications.push({ sourceId, entries, options });
|
||||
},
|
||||
clearSource(sourceId) {
|
||||
clears.push(sourceId);
|
||||
},
|
||||
_test: { publications, clears },
|
||||
};
|
||||
}
|
||||
|
||||
test('Bhote Koshi locator closes valid region rings once', () => {
|
||||
assert.deepEqual(closeRegionRing([[80, 26], [88, 26], [88, 30]]), [
|
||||
[80, 26], [88, 26], [88, 30], [80, 26],
|
||||
]);
|
||||
assert.deepEqual(closeRegionRing([[80, 26], [88, 26], [80, 26]]), [
|
||||
[80, 26], [88, 26], [80, 26],
|
||||
]);
|
||||
assert.deepEqual(closeRegionRing([[80, 26], ['bad', 27]]), []);
|
||||
});
|
||||
|
||||
test('Bhote Koshi locator selects the largest GeoJSON country exterior', () => {
|
||||
const main = [[80, 26], [88, 26], [88, 30], [80, 30], [80, 26]];
|
||||
const island = [[82, 25], [83, 25], [82, 25]];
|
||||
assert.equal(largestGeoJsonRing({ type: 'Polygon', coordinates: [main] }), main);
|
||||
assert.equal(largestGeoJsonRing({
|
||||
type: 'MultiPolygon',
|
||||
coordinates: [[island], [main]],
|
||||
}), main);
|
||||
assert.deepEqual(largestGeoJsonRing({ type: 'Point', coordinates: [84, 28] }), []);
|
||||
});
|
||||
|
||||
test('Bhote Koshi locator reveals a border progressively', () => {
|
||||
const positions = [
|
||||
new Cesium.Cartesian3(0, 0, 0),
|
||||
new Cesium.Cartesian3(10, 0, 0),
|
||||
new Cesium.Cartesian3(20, 0, 0),
|
||||
];
|
||||
assert.deepEqual(revealPolylinePositions(positions, 0), [positions[0], positions[0]]);
|
||||
assert.deepEqual(revealPolylinePositions(positions, 0.25), [
|
||||
positions[0],
|
||||
new Cesium.Cartesian3(5, 0, 0),
|
||||
]);
|
||||
assert.equal(revealPolylinePositions(positions, 1), positions);
|
||||
});
|
||||
|
||||
test('Bhote Koshi locator stages its anchored callout from dot to leader to text', () => {
|
||||
assert.deepEqual(anchoredCalloutPresentation(0), {
|
||||
alpha: 0,
|
||||
leaderProgress: 0,
|
||||
contentAlpha: 0,
|
||||
scale: 0.96,
|
||||
});
|
||||
assert.deepEqual(anchoredCalloutPresentation(1200), {
|
||||
alpha: 1,
|
||||
leaderProgress: 1,
|
||||
contentAlpha: 1,
|
||||
scale: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test('Bhote Koshi regional locator sequences Nepal, border, then the incident', async () => {
|
||||
const viewer = viewerFixture();
|
||||
const overlayHost = overlayHostFixture();
|
||||
const renders = [];
|
||||
let nowMs = 0;
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
boundaryResolver: async () => ({
|
||||
name: 'Nepal',
|
||||
ring: [[80, 26], [88, 26], [88, 30], [80, 30]],
|
||||
}),
|
||||
requestRender: (reason) => renders.push(reason),
|
||||
overlayHost,
|
||||
now: () => nowMs,
|
||||
...nonRunningAnimation(),
|
||||
});
|
||||
|
||||
assert.equal(await layer.init(viewer), true);
|
||||
assert.equal(await layer.enable(viewer, { origin: 'scene' }), true);
|
||||
assert.equal(viewer._test.added.length, 1);
|
||||
const source = viewer._test.added[0];
|
||||
const ids = source.entities.values.map((entity) => entity.id);
|
||||
for (const id of [
|
||||
'nepal-context-halo',
|
||||
'nepal-border-fill',
|
||||
'nepal-border-highlight',
|
||||
'bhote-koshi-incident-halo',
|
||||
]) {
|
||||
assert.ok(ids.includes(id), `${id} belongs to the regional sequence`);
|
||||
}
|
||||
const incidentHalo = source.entities.getById('bhote-koshi-incident-halo');
|
||||
const border = source.entities.getById('nepal-border-highlight');
|
||||
const publication = overlayHost._test.publications.at(-1);
|
||||
assert.equal(publication.sourceId, BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID);
|
||||
assert.equal(publication.entries.length, 2);
|
||||
const nepal = publication.entries.find(({ id }) => id === 'nepal-context-callout');
|
||||
const incident = publication.entries.find(({ id }) => id === 'bhote-koshi-incident-callout');
|
||||
assert.equal(nepal.variant, 'label');
|
||||
assert.equal(nepal.title, 'NEPAL');
|
||||
assert.equal(nepal.anchorDot, true);
|
||||
assert.equal(incident.variant, 'card');
|
||||
assert.equal(incident.paintLane, 'thumbnail');
|
||||
assert.equal(incident.title, 'BHOTE KOSHI FLOOD INCIDENT');
|
||||
assert.deepEqual(incident.details, ['NEPAL · 26 AUG 2026']);
|
||||
assert.equal(incident.anchorDot, true);
|
||||
assert.equal(incident.leaderStyle, 'elbow');
|
||||
assert.equal('image' in incident, false);
|
||||
assert.equal('requireImage' in incident, false);
|
||||
assert.equal(incident.sourceAlpha(), 0);
|
||||
assert.equal(incident.leaderProgress(), 0);
|
||||
assert.equal(incident.contentAlpha(), 0);
|
||||
assert.equal(incident.presentationScale(), 0.96);
|
||||
assert.equal(nepal.sourceAlpha(), 0);
|
||||
assert.ok(border.polyline.positions.getValue().length < 5);
|
||||
assert.equal(incidentHalo.show.getValue(), false);
|
||||
assert.equal(incidentHalo.point.pixelSize.getValue(), 16);
|
||||
nowMs = 3100;
|
||||
assert.equal(nepal.sourceAlpha(), 1);
|
||||
assert.equal(border.polyline.positions.getValue().length, 5);
|
||||
assert.equal(incidentHalo.show.getValue(), false);
|
||||
nowMs = 3300;
|
||||
assert.equal(incidentHalo.show.getValue(), true);
|
||||
assert.equal(incident.sourceAlpha(), 0);
|
||||
nowMs = 4500;
|
||||
assert.equal(incident.sourceAlpha(), 1);
|
||||
assert.equal(incident.leaderProgress(), 1);
|
||||
assert.equal(incident.contentAlpha(), 1);
|
||||
assert.equal(incident.presentationScale(), 1);
|
||||
assert.equal(layer.getStats().status, 'nominal');
|
||||
assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION });
|
||||
assert.ok(renders.includes('bhote-koshi-locator-boundary'));
|
||||
|
||||
assert.equal(await layer.disable(), true);
|
||||
assert.equal(viewer._test.removed.length, 1);
|
||||
assert.equal(overlayHost._test.clears.at(-1), BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID);
|
||||
assert.equal(layer.getStats().status, 'idle');
|
||||
});
|
||||
|
||||
test('Bhote Koshi locator switches between Nepal context and the regional incident treatment', async () => {
|
||||
const viewer = viewerFixture();
|
||||
const overlayHost = overlayHostFixture();
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
boundaryResolver: async () => ({
|
||||
name: 'Nepal',
|
||||
ring: [[80, 26], [88, 26], [88, 30], [80, 30]],
|
||||
}),
|
||||
requestRender() {},
|
||||
overlayHost,
|
||||
...nonRunningAnimation(),
|
||||
});
|
||||
|
||||
layer.setParams({ presentation: NEPAL_CONTEXT_PRESENTATION });
|
||||
await layer.enable(viewer);
|
||||
assert.deepEqual(viewer._test.added[0].entities.values.map((entity) => entity.id), [
|
||||
'nepal-context-halo',
|
||||
]);
|
||||
const nepalLabel = overlayHost._test.publications.at(-1).entries[0];
|
||||
assert.equal(nepalLabel.variant, 'label');
|
||||
assert.equal(nepalLabel.placement, 'right');
|
||||
assert.equal(nepalLabel.anchorDot, true);
|
||||
assert.equal(layer.getStats().coverage, 'Nepal context');
|
||||
assert.deepEqual(layer.getRowControls().chips.map(({ id, active }) => ({ id, active })), [
|
||||
{ id: 'nepal-context', active: true },
|
||||
{ id: 'bhote-koshi-regional', active: false },
|
||||
{ id: 'bhote-koshi-city-context', active: false },
|
||||
{ id: 'bhote-koshi-incident-places', active: false },
|
||||
{ id: 'bhote-koshi-flood-path', active: false },
|
||||
{ id: 'bhote-koshi-path-overview', active: false },
|
||||
{ id: 'bhote-koshi-trigger-record', active: false },
|
||||
]);
|
||||
|
||||
layer.setParams({ presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION });
|
||||
assert.ok(viewer._test.added[0].entities.getById('nepal-border-highlight'));
|
||||
assert.equal(viewer._test.added[0].entities.getById('bhote-koshi-city-kathmandu'), undefined);
|
||||
assert.equal(
|
||||
overlayHost._test.publications.at(-1).entries
|
||||
.find(({ id }) => id === 'bhote-koshi-incident-callout').variant,
|
||||
'card',
|
||||
);
|
||||
|
||||
layer.setParams({ presentation: NEPAL_CONTEXT_PRESENTATION });
|
||||
assert.deepEqual(viewer._test.added[0].entities.values.map((entity) => entity.id), [
|
||||
'nepal-context-halo',
|
||||
]);
|
||||
await layer.destroy();
|
||||
});
|
||||
|
||||
test('Incident Corridor reveals all fifteen overview pins and keeps layout active through the sequence', async () => {
|
||||
const viewer = viewerFixture();
|
||||
const overlayHost = overlayHostFixture();
|
||||
let nowMs = 0;
|
||||
let pulseFrame = null;
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
boundaryResolver: async () => null, requestRender() {},
|
||||
overlayHost,
|
||||
now: () => nowMs,
|
||||
scheduleFrame(callback) { pulseFrame = callback; return 1; },
|
||||
cancelFrame() {},
|
||||
});
|
||||
layer.setParams({ presentation: BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION });
|
||||
await layer.enable(viewer);
|
||||
const source = viewer._test.added[0];
|
||||
const event = JSON.parse(await readFile(new URL('../../public/events/bhote-koshi-2026/event.json', import.meta.url), 'utf8'));
|
||||
assert.equal(event.evidenceSpine.length, 16);
|
||||
assert.equal(event.evidenceSpine.at(-1).id, 'charaudi');
|
||||
assert.equal(source.entities.values.length, 15);
|
||||
const labels = overlayHost._test.publications.at(-1).entries;
|
||||
assert.equal(labels.length, 15);
|
||||
assert.equal(overlayHost._test.publications.at(-1).options.moving, true);
|
||||
for (const [index, point] of INCIDENT_OVERVIEW_PLACES.entries()) {
|
||||
const id = `bhote-koshi-place-${point.id}`;
|
||||
const pin = labels.find((entry) => entry.id === id);
|
||||
assert.ok(pin, point.id);
|
||||
assert.equal(pin.title, `${String(index + 1).padStart(2, '0')} · ${point.title}`);
|
||||
assert.equal(pin.variant, 'label');
|
||||
const location = Cesium.Cartographic.fromCartesian(pin.position);
|
||||
assert.ok(Math.abs(Cesium.Math.toDegrees(location.latitude) - point.lat) < 1e-6);
|
||||
assert.ok(Math.abs(Cesium.Math.toDegrees(location.longitude) - point.lon) < 1e-6);
|
||||
nowMs = 349 + index * 650;
|
||||
assert.equal(pin.sourceAlpha(), 0);
|
||||
nowMs += 1;
|
||||
assert.equal(pin.sourceAlpha(), 0);
|
||||
assert.equal(source.entities.values.filter(entity => entity.id.endsWith('-halo') && entity.show.getValue()).length, 1);
|
||||
nowMs += 520;
|
||||
assert.equal(pin.sourceAlpha(), 1);
|
||||
}
|
||||
assert.ok(nowMs < INCIDENT_OVERVIEW_HOLD_SEC * 1000);
|
||||
// A repeat halo cycle must not erase already revealed labels.
|
||||
nowMs = 350 + 15 * 650;
|
||||
assert.equal(source.entities.getById('bhote-koshi-place-immediate-collapse-viewpoint-halo').show.getValue(), true);
|
||||
assert.equal(labels.find(({ id }) => id === 'bhote-koshi-place-devighat-taadi-khola-bridge').sourceAlpha(), 1);
|
||||
assert.equal(labels.some(({ id }) => id === 'bhote-koshi-place-charaudi'), false);
|
||||
assert.equal(typeof pulseFrame, 'function');
|
||||
pulseFrame();
|
||||
assert.equal(overlayHost._test.publications.at(-1).options.moving, false);
|
||||
layer.setParams({ presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION });
|
||||
assert.equal(source.entities.values.some(entity => entity.id.startsWith('bhote-koshi-place-')), false);
|
||||
await layer.destroy();
|
||||
});
|
||||
|
||||
test('Bhote Koshi flood path draws a solid cyan sourced route after the place shot', async () => {
|
||||
const viewer = viewerFixture();
|
||||
const overlayHost = overlayHostFixture();
|
||||
let nowMs = 0;
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
boundaryResolver: async () => null,
|
||||
requestRender() {},
|
||||
overlayHost,
|
||||
now: () => nowMs,
|
||||
...nonRunningAnimation(),
|
||||
});
|
||||
|
||||
layer.setParams({ presentation: BHOTE_KOSHI_FLOOD_PATH_PRESENTATION });
|
||||
await layer.enable(viewer);
|
||||
const source = viewer._test.added[0];
|
||||
const path = source.entities.getById('bhote-koshi-flood-path');
|
||||
const labels = overlayHost._test.publications.at(-1).entries;
|
||||
const trigger = labels.find(({ id }) => id === 'bhote-koshi-place-immediate-collapse-viewpoint');
|
||||
const bidur = labels.find(({ id }) => id === 'bhote-koshi-place-bidur-trishuli-bridge');
|
||||
|
||||
assert.equal(source.entities.values.length, 1);
|
||||
assert.equal(labels.length, 15);
|
||||
assert.equal(bidur.title, '14 · BIDUR BRIDGE');
|
||||
for (const place of INCIDENT_OVERVIEW_PLACES) {
|
||||
const pin = labels.find(({ id }) => id === `bhote-koshi-place-${place.id}`);
|
||||
assert.equal(pin.sourceAlpha(), 1, place.id);
|
||||
}
|
||||
assert.equal(path.polyline.clampToGround.getValue(), true);
|
||||
assert.ok(path.polyline.material instanceof Cesium.ColorMaterialProperty);
|
||||
assert.ok(path.polyline.material.color.getValue().equalsEpsilon(
|
||||
Cesium.Color.fromCssColorString('#20e7f2').withAlpha(0.96),
|
||||
Cesium.Math.EPSILON7,
|
||||
));
|
||||
assert.equal(path.show.getValue(), false);
|
||||
assert.equal(trigger.sourceAlpha(), 1);
|
||||
assert.equal(bidur.sourceAlpha(), 1);
|
||||
|
||||
nowMs = 250;
|
||||
assert.equal(path.show.getValue(), true);
|
||||
assert.equal(path.polyline.positions.getValue().length, 2);
|
||||
nowMs = 2250;
|
||||
const halfwayCount = path.polyline.positions.getValue().length;
|
||||
assert.ok(Math.abs(halfwayCount - BHOTE_KOSHI_FLOOD_PATH.length / 2) <= 2);
|
||||
nowMs = 4250;
|
||||
assert.equal(path.polyline.positions.getValue().length, BHOTE_KOSHI_FLOOD_PATH.length);
|
||||
assert.deepEqual(BHOTE_KOSHI_FLOOD_PATH[0], [85.48476, 28.33255]);
|
||||
// More observation pins must not extend or straighten the sourced route.
|
||||
assert.deepEqual(path.polyline.positions.getValue(), BHOTE_KOSHI_FLOOD_PATH.map(([lon, lat]) => (
|
||||
Cesium.Cartesian3.fromDegrees(lon, lat)
|
||||
)));
|
||||
assert.deepEqual(BHOTE_KOSHI_FLOOD_PATH.at(-1), [85.14943, 27.92177]);
|
||||
assert.equal(layer.getStats().coverage, 'Bhote Koshi · Flood path');
|
||||
assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_FLOOD_PATH_PRESENTATION });
|
||||
await layer.destroy();
|
||||
});
|
||||
|
||||
test('Bhote Koshi path-overview presentation keeps the route settled without Nepal border', async () => {
|
||||
const viewer = viewerFixture();
|
||||
const overlayHost = overlayHostFixture();
|
||||
let nowMs = 0;
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
boundaryResolver: async () => ({
|
||||
name: 'Nepal',
|
||||
ring: [[80, 26], [88, 26], [88, 30], [80, 30]],
|
||||
}),
|
||||
requestRender() {},
|
||||
overlayHost,
|
||||
now: () => nowMs,
|
||||
...nonRunningAnimation(),
|
||||
});
|
||||
|
||||
layer.setParams({ presentation: BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION });
|
||||
await layer.enable(viewer);
|
||||
const source = viewer._test.added[0];
|
||||
const path = source.entities.getById('bhote-koshi-flood-path');
|
||||
const labels = overlayHost._test.publications.at(-1).entries;
|
||||
|
||||
assert.equal(source.entities.values.length, 1);
|
||||
assert.equal(labels.length, 15);
|
||||
for (const [index, place] of INCIDENT_OVERVIEW_PLACES.entries()) {
|
||||
const pin = labels.find(({ id }) => id === `bhote-koshi-place-${place.id}`);
|
||||
assert.equal(pin.title, `${String(index + 1).padStart(2, '0')} · ${place.title}`);
|
||||
assert.equal(pin.sourceAlpha(), 1);
|
||||
}
|
||||
assert.equal(path.show.getValue(), true);
|
||||
assert.equal(path.polyline.positions.getValue().length, BHOTE_KOSHI_FLOOD_PATH.length);
|
||||
assert.ok(Cesium.Cartesian3.equals(path.polyline.positions.getValue()[0],
|
||||
Cesium.Cartesian3.fromDegrees(85.48476, 28.33255)));
|
||||
assert.equal(source.entities.getById('nepal-border-fill'), undefined);
|
||||
assert.equal(source.entities.getById('nepal-border-highlight'), undefined);
|
||||
assert.equal(
|
||||
source.entities.getById('bhote-koshi-place-immediate-collapse-viewpoint-halo'),
|
||||
undefined,
|
||||
);
|
||||
assert.equal(layer.getStats().coverage, 'Bhote Koshi · Path overview');
|
||||
assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION });
|
||||
await layer.destroy();
|
||||
});
|
||||
|
||||
test('Bhote Koshi trigger-record presentation reveals sourced incident details', async () => {
|
||||
const viewer = viewerFixture();
|
||||
const overlayHost = overlayHostFixture();
|
||||
let nowMs = 0;
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
boundaryResolver: async () => null,
|
||||
requestRender() {},
|
||||
overlayHost,
|
||||
now: () => nowMs,
|
||||
...nonRunningAnimation(),
|
||||
});
|
||||
|
||||
layer.setParams({
|
||||
presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
|
||||
preview: true,
|
||||
});
|
||||
await layer.enable(viewer);
|
||||
const source = viewer._test.added[0];
|
||||
const publication = overlayHost._test.publications.at(-1);
|
||||
const record = publication.entries[0];
|
||||
|
||||
assert.deepEqual(source.entities.values.map(({ id }) => id), [
|
||||
'bhote-koshi-flood-path',
|
||||
'bhote-koshi-trigger-record-halo',
|
||||
]);
|
||||
assert.equal(record.title, 'USGS INITIAL REPORT');
|
||||
assert.deepEqual(record.details, [
|
||||
'26 AUG 2026 · 08:37 NPT',
|
||||
'28.2710° N · 85.5150° E',
|
||||
'INITIAL M4.4 · RECLASSIFIED M5.2 LANDSLIDE',
|
||||
'USGS EVENT us7000tbwb · OPEN SOURCE',
|
||||
]);
|
||||
assert.equal(record.interactive, true);
|
||||
assert.equal(typeof record.activate, 'function');
|
||||
assert.equal(record.sourceAlpha(), 0);
|
||||
nowMs = 1200;
|
||||
assert.equal(record.sourceAlpha(), 1);
|
||||
assert.equal(source.entities.getById('nepal-border-highlight'), undefined);
|
||||
assert.equal(layer.getStats().coverage, 'Bhote Koshi · Trigger record');
|
||||
assert.deepEqual(layer.getParams(), {
|
||||
presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
|
||||
});
|
||||
await layer.destroy();
|
||||
});
|
||||
|
||||
test('Bhote Koshi trigger-record waits for the scene camera, zooms, then orbits', async () => {
|
||||
const viewer = viewerFixture();
|
||||
const overlayHost = overlayHostFixture();
|
||||
const moveStart = new Cesium.Event();
|
||||
const moveEnd = new Cesium.Event();
|
||||
const flights = [];
|
||||
let armedCallback = null;
|
||||
viewer.camera = {
|
||||
moveStart,
|
||||
moveEnd,
|
||||
flyTo(options) { flights.push(options); },
|
||||
cancelFlight() {},
|
||||
};
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
boundaryResolver: async () => null,
|
||||
requestRender() {},
|
||||
overlayHost,
|
||||
scheduleDelay(callback) {
|
||||
armedCallback = callback;
|
||||
return 1;
|
||||
},
|
||||
cancelDelay() {},
|
||||
...nonRunningAnimation(),
|
||||
});
|
||||
|
||||
layer.setParams({ presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION });
|
||||
await layer.enable(viewer);
|
||||
assert.equal(flights.length, 0);
|
||||
assert.equal(typeof armedCallback, 'function');
|
||||
moveStart.raiseEvent();
|
||||
assert.equal(flights.length, 0);
|
||||
moveEnd.raiseEvent();
|
||||
assert.equal(flights.length, 1);
|
||||
assert.equal(flights[0].duration, 4.6);
|
||||
const destination = Cesium.Cartographic.fromCartesian(flights[0].destination);
|
||||
assert.ok(Math.abs(Cesium.Math.toDegrees(destination.latitude) - 28.368) < 1e-6);
|
||||
assert.ok(Math.abs(Cesium.Math.toDegrees(destination.longitude) - 85.473) < 1e-6);
|
||||
assert.ok(Math.abs(destination.height - 10526) < 1e-4);
|
||||
flights[0].complete();
|
||||
assert.equal(flights.length, 2);
|
||||
assert.equal(flights[1].duration, 2.8);
|
||||
const orbit = Cesium.Cartographic.fromCartesian(flights[1].destination);
|
||||
assert.ok(Math.abs(Cesium.Math.toDegrees(orbit.latitude) - 28.3769) < 1e-6);
|
||||
assert.ok(Math.abs(Cesium.Math.toDegrees(orbit.longitude) - 85.4923) < 1e-6);
|
||||
assert.ok(Math.abs(orbit.height - 10526) < 1e-4);
|
||||
await layer.destroy();
|
||||
});
|
||||
|
||||
for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
|
||||
test(`Scene Stop revokes locator ${phase} and stale callbacks cannot own a successor`, async () => {
|
||||
const viewer = viewerFixture();
|
||||
const timers = new Map();
|
||||
const callbacks = [];
|
||||
const flights = [];
|
||||
let nextTimer = 0;
|
||||
const moveStart = new Cesium.Event();
|
||||
const moveEnd = new Cesium.Event();
|
||||
viewer.camera = {
|
||||
moveStart, moveEnd,
|
||||
flyTo(options) { flights.push(options); },
|
||||
cancelFlight() {},
|
||||
};
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
boundaryResolver: async () => null,
|
||||
overlayHost: overlayHostFixture(),
|
||||
requestRender() {},
|
||||
scheduleDelay(callback) {
|
||||
callbacks.push(callback);
|
||||
timers.set(++nextTimer, callback);
|
||||
return nextTimer;
|
||||
},
|
||||
cancelDelay(id) { timers.delete(id); },
|
||||
...nonRunningAnimation(),
|
||||
});
|
||||
const director = {
|
||||
dataManager: { layers: new Map([[layer.id, { module: layer }]]) },
|
||||
viewer,
|
||||
_sceneSeekGeneration: 0,
|
||||
_loadGeneration: 0,
|
||||
_cancelActiveSceneTravel: SceneDirector.prototype._cancelActiveSceneTravel,
|
||||
};
|
||||
try {
|
||||
layer.setParams({ presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION });
|
||||
await layer.enable(viewer);
|
||||
if (phase === 'move-end') moveStart.raiseEvent();
|
||||
if (phase === 'approach' || phase === 'orbit') callbacks[0]();
|
||||
if (phase === 'orbit') flights[0].complete();
|
||||
const oldFlights = [...flights];
|
||||
SceneDirector.prototype.stopScene.call(director);
|
||||
assert.equal(timers.size, 0, 'Stop cancels the actual delayed work');
|
||||
assert.equal(moveStart.numberOfListeners, 0);
|
||||
assert.equal(moveEnd.numberOfListeners, 0);
|
||||
callbacks[0]();
|
||||
moveEnd.raiseEvent();
|
||||
for (const flight of oldFlights) flight.complete();
|
||||
assert.equal(flights.length, oldFlights.length, 'no post-Stop approach or orbit');
|
||||
assert.equal(layer.getStats().count, 1, 'Stop preserves the visible locator');
|
||||
|
||||
layer.setParams({ presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION });
|
||||
callbacks.at(-1)();
|
||||
const successor = flights.at(-1);
|
||||
const count = flights.length;
|
||||
callbacks[0]();
|
||||
for (const flight of oldFlights) { flight.cancel(); flight.complete(); }
|
||||
assert.equal(flights.length, count, 'old callbacks cannot add a flight');
|
||||
successor.complete();
|
||||
assert.equal(flights.length, count + 1, 'successor retains its own orbit');
|
||||
} finally { await layer.destroy(); }
|
||||
});
|
||||
}
|
||||
|
||||
test('Bhote Koshi locator keeps the regional context settled while nearby cities stagger in', async () => {
|
||||
const viewer = viewerFixture();
|
||||
const overlayHost = overlayHostFixture();
|
||||
let nowMs = 0;
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
boundaryResolver: async () => ({
|
||||
name: 'Nepal',
|
||||
ring: [[80, 26], [88, 26], [88, 30], [80, 30]],
|
||||
}),
|
||||
requestRender() {},
|
||||
overlayHost,
|
||||
now: () => nowMs,
|
||||
...nonRunningAnimation(),
|
||||
});
|
||||
|
||||
layer.setParams({ presentation: BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION });
|
||||
await layer.enable(viewer);
|
||||
const source = viewer._test.added[0];
|
||||
const borderFill = source.entities.getById('nepal-border-fill');
|
||||
const entries = overlayHost._test.publications.at(-1).entries;
|
||||
const incident = entries.find(({ id }) => id === 'bhote-koshi-incident-callout');
|
||||
const kathmandu = entries.find(({ id }) => id === 'bhote-koshi-city-kathmandu');
|
||||
const bhaktapur = entries.find(({ id }) => id === 'bhote-koshi-city-bhaktapur');
|
||||
const dhulikhel = entries.find(({ id }) => id === 'bhote-koshi-city-dhulikhel');
|
||||
|
||||
assert.equal(borderFill.show.getValue(), true);
|
||||
assert.equal(incident.contentAlpha(), 1);
|
||||
assert.equal(kathmandu.title, 'KATHMANDU');
|
||||
assert.equal(bhaktapur.title, 'BHAKTAPUR');
|
||||
assert.equal(dhulikhel.title, 'DHULIKHEL');
|
||||
assert.ok([kathmandu, bhaktapur, dhulikhel].every(({ variant, anchorDot }) => (
|
||||
variant === 'label' && anchorDot === true
|
||||
)));
|
||||
assert.equal(kathmandu.sourceAlpha(), 0);
|
||||
assert.equal(bhaktapur.sourceAlpha(), 0);
|
||||
assert.equal(dhulikhel.sourceAlpha(), 0);
|
||||
|
||||
nowMs = 350;
|
||||
assert.equal(kathmandu.sourceAlpha(), 0);
|
||||
assert.equal(bhaktapur.sourceAlpha(), 0);
|
||||
nowMs = 850;
|
||||
assert.equal(kathmandu.sourceAlpha(), 1);
|
||||
assert.ok(bhaktapur.sourceAlpha() > 0);
|
||||
assert.ok(dhulikhel.sourceAlpha() > 0);
|
||||
nowMs = 1300;
|
||||
assert.equal(dhulikhel.sourceAlpha(), 1);
|
||||
assert.equal(layer.getStats().coverage, 'Bhote Koshi · Nearby cities');
|
||||
assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION });
|
||||
await layer.destroy();
|
||||
});
|
||||
|
||||
test('Bhote Koshi locator degrades to labels when the regional boundary is unavailable', async () => {
|
||||
const viewer = viewerFixture();
|
||||
const overlayHost = overlayHostFixture();
|
||||
const originalWarn = console.warn;
|
||||
console.warn = () => {};
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
boundaryResolver: async () => null,
|
||||
requestRender() {},
|
||||
overlayHost,
|
||||
...nonRunningAnimation(),
|
||||
});
|
||||
try {
|
||||
await layer.enable(viewer);
|
||||
const ids = viewer._test.added[0].entities.values.map((entity) => entity.id);
|
||||
assert.ok(ids.includes('nepal-context-halo'));
|
||||
assert.ok(ids.includes('bhote-koshi-incident-halo'));
|
||||
assert.equal(ids.includes('bhote-koshi-city-kathmandu'), false);
|
||||
assert.equal(ids.includes('nepal-border-highlight'), false);
|
||||
assert.deepEqual(overlayHost._test.publications.at(-1).entries.map(({ id }) => id), [
|
||||
'nepal-context-callout',
|
||||
'bhote-koshi-incident-callout',
|
||||
]);
|
||||
assert.equal(layer.getStats().status, 'degraded');
|
||||
} finally {
|
||||
console.warn = originalWarn;
|
||||
await layer.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('Bhote Koshi regional locator waits for its boundary before starting the full sequence', async () => {
|
||||
const viewer = viewerFixture();
|
||||
const overlayHost = overlayHostFixture();
|
||||
let releaseBoundary;
|
||||
const boundary = new Promise((resolve) => { releaseBoundary = resolve; });
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
boundaryResolver: async () => boundary,
|
||||
requestRender() {},
|
||||
overlayHost,
|
||||
...nonRunningAnimation(),
|
||||
});
|
||||
|
||||
const enabling = layer.enable(viewer);
|
||||
assert.ok(viewer._test.added[0].entities.getById('nepal-context-halo'));
|
||||
assert.equal(viewer._test.added[0].entities.getById('nepal-border-highlight'), undefined);
|
||||
assert.equal(viewer._test.added[0].entities.getById('bhote-koshi-incident-halo'), undefined);
|
||||
assert.equal(overlayHost._test.publications.length, 1);
|
||||
assert.deepEqual(overlayHost._test.publications[0].entries.map(({ id }) => id), [
|
||||
'nepal-context-callout',
|
||||
]);
|
||||
|
||||
releaseBoundary({ ring: [[80, 26], [88, 26], [88, 30], [80, 30]] });
|
||||
await enabling;
|
||||
assert.ok(viewer._test.added[0].entities.getById('bhote-koshi-incident-halo'));
|
||||
assert.ok(viewer._test.added[0].entities.getById('nepal-border-highlight'));
|
||||
assert.equal(viewer._test.added[0].entities.getById('bhote-koshi-city-kathmandu'), undefined);
|
||||
assert.equal(overlayHost._test.publications.length, 2);
|
||||
assert.deepEqual(overlayHost._test.publications.at(-1).entries.map(({ id }) => id), [
|
||||
'nepal-context-callout',
|
||||
'bhote-koshi-incident-callout',
|
||||
]);
|
||||
await layer.destroy();
|
||||
});
|
||||
|
||||
test('Bhote Koshi locator cancels a stale boundary resolution and removes its surface', async () => {
|
||||
const viewer = viewerFixture();
|
||||
const controller = new AbortController();
|
||||
let releaseBoundary;
|
||||
const boundary = new Promise((resolve) => { releaseBoundary = resolve; });
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
boundaryResolver: async () => boundary,
|
||||
requestRender() {},
|
||||
...nonRunningAnimation(),
|
||||
});
|
||||
const enabling = layer.enable(viewer, { signal: controller.signal });
|
||||
controller.abort();
|
||||
releaseBoundary({ ring: [[80, 26], [88, 26], [88, 30]] });
|
||||
await assert.rejects(enabling, (error) => error?.name === 'AbortError');
|
||||
assert.equal(viewer._test.removed.length, 1);
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js';
|
||||
|
||||
export const SOURCE_PATH_BEAT_IDS = Object.freeze([
|
||||
'debris-dammed-lake',
|
||||
'second-landslide',
|
||||
'dhunche',
|
||||
'mailung-upper-trishuli',
|
||||
'mailung-bazzar',
|
||||
'dandagaun',
|
||||
'dandagaun-viewpoint',
|
||||
'betrawati-bazaar',
|
||||
'bhainse',
|
||||
'bidur-trishuli-bridge',
|
||||
]);
|
||||
|
||||
function nearestVertex(point, route) {
|
||||
if (!point || !Number.isFinite(point.lon) || !Number.isFinite(point.lat))
|
||||
return -1;
|
||||
const longitudeScale = Math.cos((point.lat * Math.PI) / 180);
|
||||
let nearest = -1;
|
||||
let distance = 1000; // Witness viewpoints can sit beside the river, not on it.
|
||||
route.forEach(([lon, lat], index) => {
|
||||
const metres =
|
||||
Math.hypot((lon - point.lon) * longitudeScale, lat - point.lat) * 111195;
|
||||
if (metres < distance) {
|
||||
distance = metres;
|
||||
nearest = index;
|
||||
}
|
||||
});
|
||||
return nearest;
|
||||
}
|
||||
|
||||
/** Split the already-bundled GeoPera centreline; never connect witness pins. */
|
||||
export function buildSourceShotPaths(event, route = BHOTE_KOSHI_FLOOD_PATH) {
|
||||
const observations = new Map(
|
||||
(event.evidenceSpine || []).map((point) => [point.id, point]),
|
||||
);
|
||||
const result = new Map();
|
||||
const add = (id, from, to, { joinComparison = false } = {}) => {
|
||||
let start = nearestVertex(from, route);
|
||||
const end = nearestVertex(to, route);
|
||||
if (start < 0 || end <= start) return;
|
||||
const startHeight = from.fallbackElevationM ?? from.elevationM;
|
||||
const endHeight = to.fallbackElevationM ?? to.elevationM;
|
||||
if (!Number.isFinite(startHeight) || !Number.isFinite(endHeight)) return;
|
||||
if (joinComparison) {
|
||||
// The raster corridor ends between simplified centreline vertices.
|
||||
// Snapping to its nearest vertex can move the head upstream on entry.
|
||||
// Keep that already-sourced endpoint and continue to the next vertex
|
||||
// downstream of it, rather than replaying the upstream half-segment.
|
||||
const [lon, lat] = route[start];
|
||||
const [nextLon, nextLat] = route[start + 1];
|
||||
const scale = Math.cos((from.lat * Math.PI) / 180);
|
||||
const along =
|
||||
(from.lon - lon) * (nextLon - lon) * scale * scale +
|
||||
(from.lat - lat) * (nextLat - lat);
|
||||
if (along > 0) start++;
|
||||
}
|
||||
const coordinates = route.slice(start, end + 1);
|
||||
if (
|
||||
joinComparison &&
|
||||
(coordinates[0][0] !== from.lon || coordinates[0][1] !== from.lat)
|
||||
) {
|
||||
coordinates.unshift([from.lon, from.lat]);
|
||||
}
|
||||
result.set(
|
||||
id,
|
||||
coordinates.map(([lon, lat], index) => ({
|
||||
lon,
|
||||
lat,
|
||||
// Stable source profile for explicit source-elevation shots; otherwise a
|
||||
// fallback when neither rendered mesh nor elevation terrain can be sampled.
|
||||
fallbackElevationM:
|
||||
startHeight +
|
||||
((endHeight - startHeight) * index) / (coordinates.length - 1),
|
||||
})),
|
||||
);
|
||||
};
|
||||
add(
|
||||
'debris-dammed-lake',
|
||||
observations.get('debris-dammed-lake'),
|
||||
observations.get('second-landslide'),
|
||||
);
|
||||
add(
|
||||
'second-landslide',
|
||||
observations.get('second-landslide'),
|
||||
observations.get('gyirong-border-gate'),
|
||||
);
|
||||
let previous = event.reconstruction?.corridor?.at(-1);
|
||||
for (const id of SOURCE_PATH_BEAT_IDS.slice(2)) {
|
||||
const point = observations.get(id);
|
||||
add(id, previous, point, { joinComparison: id === 'dhunche' });
|
||||
// Missing coverage breaks continuity rather than inventing a long connector.
|
||||
previous = point;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { buildSourceShotPaths, SOURCE_PATH_BEAT_IDS } from './bhoteKoshiShotPaths.js';
|
||||
import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js';
|
||||
|
||||
const event = JSON.parse(await readFile(new URL('../../public/events/bhote-koshi-2026/event.json', import.meta.url)));
|
||||
|
||||
test('overview and evidence paths start at the lake reach, never the earlier trigger reach', () => {
|
||||
const lakeStart = [85.48476, 28.33255];
|
||||
assert.deepEqual(BHOTE_KOSHI_FLOOD_PATH[0], lakeStart);
|
||||
assert.equal(BHOTE_KOSHI_FLOOD_PATH.some(([lon, lat]) => lon === 85.5306 && lat === 28.2916), false);
|
||||
const paths = buildSourceShotPaths(event);
|
||||
const first = paths.get('debris-dammed-lake')[0];
|
||||
assert.deepEqual([first.lon, first.lat], lakeStart);
|
||||
assert.deepEqual(BHOTE_KOSHI_FLOOD_PATH.slice(0, paths.get('debris-dammed-lake').length),
|
||||
paths.get('debris-dammed-lake').map(({lon, lat}) => [lon, lat]));
|
||||
});
|
||||
|
||||
test('ten shot paths use only the existing centreline and join at shared endpoints', () => {
|
||||
const paths = buildSourceShotPaths(event);
|
||||
assert.deepEqual([...paths.keys()], SOURCE_PATH_BEAT_IDS);
|
||||
const vertices = new Set(BHOTE_KOSHI_FLOOD_PATH.map(([lon, lat]) => `${lon},${lat}`));
|
||||
const boundary = event.reconstruction.corridor.at(-1);
|
||||
vertices.add(`${boundary.lon},${boundary.lat}`); // Exact sourced comparison handoff.
|
||||
for (const points of paths.values()) {
|
||||
assert.ok(points.length >= 2);
|
||||
assert.ok(points.every(({ lon, lat, fallbackElevationM }) => (
|
||||
vertices.has(`${lon},${lat}`) && Number.isFinite(fallbackElevationM)
|
||||
)));
|
||||
}
|
||||
const coordinate = (point) => [point.lon, point.lat];
|
||||
for (const ids of [SOURCE_PATH_BEAT_IDS.slice(0, 2), SOURCE_PATH_BEAT_IDS.slice(2)]) {
|
||||
for (let index = 1; index < ids.length; index++) {
|
||||
assert.deepEqual(coordinate(paths.get(ids[index - 1]).at(-1)), coordinate(paths.get(ids[index])[0]));
|
||||
}
|
||||
}
|
||||
assert.deepEqual(coordinate(paths.get('bidur-trishuli-bridge').at(-1)), BHOTE_KOSHI_FLOOD_PATH.at(-1));
|
||||
for (const id of ['immediate-collapse-viewpoint', 'devighat-taadi-khola-bridge', 'charaudi']) {
|
||||
assert.equal(paths.has(id), false, 'out-of-coverage media must not gain a fabricated river route');
|
||||
}
|
||||
});
|
||||
|
||||
test('missing and distant observations fail closed without connecting witness coordinates', () => {
|
||||
const broken = structuredClone(event);
|
||||
broken.evidenceSpine.find(({ id }) => id === 'dhunche').lon = 0;
|
||||
const paths = buildSourceShotPaths(broken);
|
||||
assert.equal(paths.has('dhunche'), false);
|
||||
assert.equal(paths.has('mailung-upper-trishuli'), false);
|
||||
assert.equal(buildSourceShotPaths({}).size, 0);
|
||||
});
|
||||
|
||||
test('Dhunche starts at the exact comparison endpoint without stepping upstream first', () => {
|
||||
const boundary = event.reconstruction.corridor.at(-1);
|
||||
const path = buildSourceShotPaths(event).get('dhunche');
|
||||
assert.deepEqual([path[0].lon, path[0].lat], [boundary.lon, boundary.lat]);
|
||||
assert.ok(path[1].lat < boundary.lat, 'the first new vertex is downstream of the comparison endpoint');
|
||||
});
|
||||
@@ -312,6 +312,28 @@ export function transitFeedCredit(feed) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Registered when the Bhote Koshi event reconstruction activates. */
|
||||
export const BHOTE_KOSHI_CREDIT = {
|
||||
key: 'bhote-koshi-2026',
|
||||
html:
|
||||
'Bhote Koshi 2026 event imagery and derived reconstruction: ' +
|
||||
'<a href="https://vantor.com/company/open-data-program" target="_blank" rel="noopener">Vantor Open Data</a> ' +
|
||||
'and <a href="https://github.com/geo-pera/bhotekoshi-2026-reconstruction" target="_blank" rel="noopener">GeoPera</a> ' +
|
||||
'(CC BY-NC 4.0); terrain © Re:Earth / Mapterhorn (CC BY 4.0)',
|
||||
};
|
||||
|
||||
/** Registered when the scene-friendly Nepal incident locator activates. */
|
||||
export const BHOTE_KOSHI_LOCATOR_CREDIT = {
|
||||
key: 'bhote-koshi-locator',
|
||||
html:
|
||||
'Nepal administrative boundary and nearby-city locations: ' +
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
|
||||
'(ODbL 1.0); incident-place anchors use an owner-curated, source-verified geolocation union; ' +
|
||||
'flood corridor derived from the ' +
|
||||
'<a href="https://github.com/geo-pera/bhotekoshi-2026-reconstruction/blob/main/vectors/river_centerline.geojson" target="_blank" rel="noopener">GeoPera river centerline</a> ' +
|
||||
'(CC BY-NC 4.0)',
|
||||
};
|
||||
|
||||
/** @type {Set<string>} Keys of dynamic credits already registered this session. */
|
||||
const _dynamicCreditKeys = new Set();
|
||||
|
||||
|
||||
@@ -312,6 +312,16 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
|
||||
token: 'p',
|
||||
disposition: 'enabled-only',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'bhote-koshi-2026',
|
||||
token: 'h',
|
||||
disposition: 'enabled-only',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'bhote-koshi-locator',
|
||||
token: 'z',
|
||||
disposition: 'enabled-only',
|
||||
}),
|
||||
Object.freeze({ id: 'bikeshare', token: 'b', disposition: 'enabled-only' }),
|
||||
Object.freeze({
|
||||
id: 'cctv',
|
||||
|
||||
@@ -158,8 +158,9 @@ function encode(state) {
|
||||
|
||||
test('production registry is exact, canonical, and rejects incomplete contracts', async () => {
|
||||
assert.equal(validateLayerStateRegistry(), true);
|
||||
assert.equal(REGISTERED_LAYER_IDS.length, 19);
|
||||
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 19);
|
||||
assert.equal(REGISTERED_LAYER_IDS.length, 21);
|
||||
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 21);
|
||||
assert.ok(REGISTERED_LAYER_IDS.includes('transit'));
|
||||
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
|
||||
assert.throws(
|
||||
() => validateLayerStateRegistry([...LAYER_STATE_REGISTRY, LAYER_STATE_REGISTRY[0]]),
|
||||
@@ -226,8 +227,14 @@ test('v2 codec distinguishes absent from empty and keeps canonical deterministic
|
||||
});
|
||||
|
||||
test('unknown enabled-layer tokens reject the payload instead of becoming an empty set', () => {
|
||||
assert.equal(decodeLayerStateParams(new URLSearchParams('v=2&l=z')), null);
|
||||
assert.equal(decodeLayerStateParams(new URLSearchParams('v=2&l=c.z')), null);
|
||||
assert.equal(decodeLayerStateParams(new URLSearchParams('v=2&l=unknown')), null);
|
||||
assert.equal(decodeLayerStateParams(new URLSearchParams('v=2&l=c.unknown')), null);
|
||||
});
|
||||
|
||||
test('Nepal event and locator have distinct enabled-only share tokens', () => {
|
||||
const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=h.z'));
|
||||
assert.deepEqual(decoded.enabledLayerIds, ['bhote-koshi-2026', 'bhote-koshi-locator']);
|
||||
assert.ok(encode(decoded).includes('l=h.z'));
|
||||
});
|
||||
|
||||
test('unknown and forbidden option fields are ignored while missing options use codec defaults', () => {
|
||||
|
||||
+16
-4
@@ -180,12 +180,24 @@ export class MapSourceController {
|
||||
async _activateGlobeStack(stack, gen) {
|
||||
const resolution = await this._getImageryProvider(stack);
|
||||
if (gen !== this._switchGen) return;
|
||||
this._removeImageryLayer();
|
||||
this._imageryLayer = this._createImageryLayer(resolution.provider);
|
||||
this._activeImageryProvider = resolution.provider;
|
||||
this.viewer.imageryLayers.add(this._imageryLayer, 0);
|
||||
// Scene shots reapply their map stack at every handoff. Keep the live
|
||||
// layer (and its loaded tiles) when the resolved provider is unchanged;
|
||||
// rebuilding it exposes the bare globe while imagery loads again.
|
||||
if (
|
||||
!this._imageryLayer ||
|
||||
this._activeImageryProvider !== resolution.provider
|
||||
) {
|
||||
this._removeImageryLayer();
|
||||
this._imageryLayer = this._createImageryLayer(resolution.provider);
|
||||
this._activeImageryProvider = resolution.provider;
|
||||
this.viewer.imageryLayers.add(this._imageryLayer, 0);
|
||||
}
|
||||
const source = this._sources.get(resolution.effectiveStackId);
|
||||
this._credits.show(source?.credit || null);
|
||||
// A repeated request still owns a new switch generation. Rebind its
|
||||
// failure listener so fallback remains live without accumulating listeners.
|
||||
this._removeImageryErrorListener?.();
|
||||
this._removeImageryErrorListener = null;
|
||||
this._watchProvider(resolution, gen);
|
||||
this._showTileset(null);
|
||||
this.viewer.scene.globe.show = true;
|
||||
|
||||
@@ -124,14 +124,68 @@ test('an additional imagery source needs no controller branch and owns its cache
|
||||
assert.equal(reads, 1);
|
||||
assert.equal(terrainReads, 1);
|
||||
assert.equal(env.imagery.length, 1);
|
||||
assert.equal(env.removed[0].destroy, true);
|
||||
assert.equal(
|
||||
env.removed.length,
|
||||
0,
|
||||
'same provider keeps its loaded imagery layer',
|
||||
);
|
||||
env.controller.destroy();
|
||||
env.controller.destroy();
|
||||
await settle();
|
||||
assert.equal(env.imagery.length, 0);
|
||||
assert.equal(env.removed[0].destroy, true);
|
||||
assert.equal(destroys, 1);
|
||||
});
|
||||
|
||||
test('repeated Esri shot handoffs retain imagery and keep tile fallback live', async () => {
|
||||
const env = publicFixture();
|
||||
await env.controller.setStack('esri-imagery');
|
||||
const layer = env.imagery[0];
|
||||
const generation = env.controller.getSwitchGeneration();
|
||||
const errors = env.providers.get('esri-imagery').errorEvent;
|
||||
for (let i = 0; i < 3; i++) await env.controller.setStack('esri-imagery');
|
||||
assert.equal(env.imagery[0], layer);
|
||||
assert.equal(env.removed.length, 0);
|
||||
assert.equal(errors.size, 1);
|
||||
assert.equal(env.controller.getSwitchGeneration(), generation + 3);
|
||||
errors.raise();
|
||||
errors.raise();
|
||||
await settle();
|
||||
assert.equal(env.controller.getActiveId(), 'osm');
|
||||
assert.equal(env.removed.length, 1);
|
||||
assert.equal(errors.size, 0);
|
||||
env.controller.destroy();
|
||||
});
|
||||
|
||||
test('returning to the live provider supersedes a pending switch without rebuilding imagery', async () => {
|
||||
const env = publicFixture();
|
||||
await env.controller.setStack('esri-imagery');
|
||||
const layer = env.imagery[0];
|
||||
let resolve;
|
||||
env.registry.sources.find(
|
||||
(source) => source.descriptor.id === 'osm',
|
||||
).imagery = () =>
|
||||
new Promise((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
const pending = env.controller.setStack('osm');
|
||||
await settle();
|
||||
await env.controller.setStack('esri-imagery');
|
||||
resolve(env.providers.get('osm'));
|
||||
await pending;
|
||||
assert.equal(env.controller.getActiveId(), 'esri-imagery');
|
||||
assert.equal(env.imagery[0], layer);
|
||||
assert.equal(env.removed.length, 0);
|
||||
await env.controller.setStack('photoreal');
|
||||
assert.equal(env.imagery.length, 0);
|
||||
assert.equal(env.tileset.show, true);
|
||||
await env.controller.setStack('esri-imagery');
|
||||
assert.notEqual(env.imagery[0], layer, 'a removed layer must be recreated');
|
||||
assert.equal(env.viewer.scene.globe.show, true);
|
||||
assert.equal(env.tileset.show, false);
|
||||
env.controller.destroy();
|
||||
});
|
||||
|
||||
test('a destroyed controller aborts creation and disposes a late provider without touching the scene', async () => {
|
||||
let resolve,
|
||||
signal,
|
||||
|
||||
@@ -2,7 +2,67 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createIonImagery } from './imagery.js';
|
||||
import { createWorldTerrain } from './terrain.js';
|
||||
import { createWorldTerrain, createKeylessTerrain } from './terrain.js';
|
||||
import { createDefaultMapSources } from './defaultSources.js';
|
||||
|
||||
test('Esri uses Re:Earth without keys and preserves ion terrain when configured', async () => {
|
||||
const originalTerrain = Cesium.CesiumTerrainProvider.fromUrl;
|
||||
const originalResource = Cesium.IonResource.fromAssetId;
|
||||
const calls = [];
|
||||
try {
|
||||
Cesium.CesiumTerrainProvider.fromUrl = async (resource) => {
|
||||
calls.push(resource);
|
||||
return { resource };
|
||||
};
|
||||
Cesium.IonResource.fromAssetId = async () =>
|
||||
assert.fail('keyless mode must not acquire ion terrain');
|
||||
const keyless = createDefaultMapSources();
|
||||
const esri = keyless.sources.find(
|
||||
({ descriptor }) => descriptor.id === 'esri-imagery',
|
||||
);
|
||||
assert.equal(esri.available, true);
|
||||
assert.equal(esri.terrain.id, 'keyless');
|
||||
assert.equal(esri.terrain.create, createKeylessTerrain);
|
||||
await esri.terrain.create();
|
||||
assert.deepEqual(calls, [
|
||||
'https://terrain.reearth.land/cesium-mesh/ellipsoid',
|
||||
]);
|
||||
assert.equal(
|
||||
keyless.sources.find(({ descriptor }) => descriptor.id === 'photoreal')
|
||||
.available,
|
||||
false,
|
||||
);
|
||||
for (const credentials of [
|
||||
{ googleApiKey: 'test-key' },
|
||||
{ cesiumToken: 'test-token' },
|
||||
]) {
|
||||
const keyed = createDefaultMapSources({
|
||||
...credentials,
|
||||
googleTileset: { show: true },
|
||||
});
|
||||
assert.equal(
|
||||
keyed.sources.find(({ descriptor }) => descriptor.id === 'photoreal')
|
||||
.available,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
keyed.sources.find(({ descriptor }) => descriptor.id === 'esri-imagery')
|
||||
.terrain.id,
|
||||
credentials.cesiumToken ? 'world' : 'keyless',
|
||||
);
|
||||
const failedTileset = createDefaultMapSources(credentials);
|
||||
assert.equal(
|
||||
failedTileset.sources.find(
|
||||
({ descriptor }) => descriptor.id === 'photoreal',
|
||||
).available,
|
||||
false,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
Cesium.CesiumTerrainProvider.fromUrl = originalTerrain;
|
||||
Cesium.IonResource.fromAssetId = originalResource;
|
||||
}
|
||||
});
|
||||
|
||||
test('imagery and terrain pass their own ion token without relying on SDK defaults', async () => {
|
||||
const originalImagery = Cesium.IonImageryProvider.fromAssetId;
|
||||
|
||||
@@ -112,6 +112,7 @@ export const WORLD_OVERLAY_OCCLUDER_SELECTORS = Object.freeze([
|
||||
'#space-mission-panel',
|
||||
'#space-mission-panel-host',
|
||||
'#military-awareness-panel',
|
||||
'#bhote-koshi-event-panel',
|
||||
// Cockpit: solid backdrop-filled windows only (both bounded to
|
||||
// `min(340px, 28vw)` wide and `min(42vh, 410px)` tall, both `hidden` until
|
||||
// toggled). Every other cockpit selector was removed — see the block comment.
|
||||
@@ -503,7 +504,24 @@ export function normalizeOverlayEntry(sourceId, entry) {
|
||||
: 'keyhole',
|
||||
horizonCull: entry.horizonCull !== false,
|
||||
terrainOcclusion: entry.terrainOcclusion === true,
|
||||
sourceAlpha: clamp01(entry.sourceAlpha ?? entry.alpha, 1),
|
||||
sourceAlpha:
|
||||
typeof (entry.sourceAlpha ?? entry.alpha) === 'function'
|
||||
? (entry.sourceAlpha ?? entry.alpha)
|
||||
: clamp01(entry.sourceAlpha ?? entry.alpha, 1),
|
||||
presentationScale:
|
||||
typeof entry.presentationScale === 'function'
|
||||
? entry.presentationScale
|
||||
: Math.max(0, Number(entry.presentationScale) || 1),
|
||||
leaderProgress:
|
||||
typeof entry.leaderProgress === 'function'
|
||||
? entry.leaderProgress
|
||||
: clamp01(entry.leaderProgress, 1),
|
||||
contentAlpha:
|
||||
typeof entry.contentAlpha === 'function'
|
||||
? entry.contentAlpha
|
||||
: clamp01(entry.contentAlpha, 1),
|
||||
anchorDot: entry.anchorDot === true,
|
||||
leaderStyle: entry.leaderStyle === 'elbow' ? 'elbow' : 'straight',
|
||||
temporalAlpha: clamp01(entry.temporalAlpha, 1),
|
||||
gapPx: Number.isFinite(Number(entry.gapPx))
|
||||
? Math.max(0, Number(entry.gapPx))
|
||||
@@ -1722,6 +1740,8 @@ function getProjectionRecord(entry) {
|
||||
candidate: null,
|
||||
distanceAlpha: 1,
|
||||
paintScale: 1,
|
||||
leaderProgress: 1,
|
||||
contentAlpha: 1,
|
||||
altitudeAlpha: 1,
|
||||
sourceAlpha: 1,
|
||||
protectedPlacement: null,
|
||||
@@ -1784,6 +1804,47 @@ function resetFrameDomains() {
|
||||
}
|
||||
}
|
||||
|
||||
// Evaluate optional scene presentation outside the shared projection hot path.
|
||||
// Passing records (rather than intermediate doubles) also avoids boxing the
|
||||
// ordinary moving-source workload at a non-inlined call boundary.
|
||||
function applyPresentation(entry, source, record) {
|
||||
try {
|
||||
const scale = Number(
|
||||
typeof entry.presentationScale === 'function'
|
||||
? entry.presentationScale()
|
||||
: entry.presentationScale,
|
||||
);
|
||||
record.paintScale *= Number.isFinite(scale) ? Math.max(0, scale) : 1;
|
||||
const alpha = Number(
|
||||
typeof entry.sourceAlpha === 'function'
|
||||
? entry.sourceAlpha()
|
||||
: entry.sourceAlpha,
|
||||
);
|
||||
record.sourceAlpha =
|
||||
source.options.alpha *
|
||||
(Number.isFinite(alpha) ? Math.max(0, Math.min(1, alpha)) : 1);
|
||||
const leader = Number(
|
||||
typeof entry.leaderProgress === 'function'
|
||||
? entry.leaderProgress()
|
||||
: entry.leaderProgress,
|
||||
);
|
||||
record.leaderProgress = Number.isFinite(leader)
|
||||
? Math.max(0, Math.min(1, leader))
|
||||
: 1;
|
||||
const content = Number(
|
||||
typeof entry.contentAlpha === 'function'
|
||||
? entry.contentAlpha()
|
||||
: entry.contentAlpha,
|
||||
);
|
||||
record.contentAlpha = Number.isFinite(content)
|
||||
? Math.max(0, Math.min(1, content))
|
||||
: 1;
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function snapshotAndProject(entry, source, viewProjection, keyhole) {
|
||||
const record = getProjectionRecord(entry);
|
||||
let position;
|
||||
@@ -1908,7 +1969,18 @@ function snapshotAndProject(entry, source, viewProjection, keyhole) {
|
||||
? 0
|
||||
: 1
|
||||
: altitudeFade(cameraAltitude, record.altitudeOptions);
|
||||
record.sourceAlpha = source.options.alpha * entry.sourceAlpha;
|
||||
record.leaderProgress = 1;
|
||||
record.contentAlpha = 1;
|
||||
if (
|
||||
entry.presentationScale !== 1 ||
|
||||
entry.leaderProgress !== 1 ||
|
||||
entry.contentAlpha !== 1 ||
|
||||
typeof entry.sourceAlpha === 'function'
|
||||
) {
|
||||
if (!applyPresentation(entry, source, record)) return null;
|
||||
} else {
|
||||
record.sourceAlpha = source.options.alpha * entry.sourceAlpha;
|
||||
}
|
||||
if (
|
||||
record.distanceAlpha <= 0 ||
|
||||
record.paintScale <= 0 ||
|
||||
@@ -2398,7 +2470,14 @@ function paintEntryItem(item, keyhole) {
|
||||
keyholeAlpha;
|
||||
if (finalAlpha <= 0.001) return;
|
||||
if (record.paintScale === 1) {
|
||||
paintOverlayEntry(_ctx, entry, placement, finalAlpha);
|
||||
paintOverlayEntry(
|
||||
_ctx,
|
||||
entry,
|
||||
placement,
|
||||
finalAlpha,
|
||||
record.leaderProgress,
|
||||
record.contentAlpha,
|
||||
);
|
||||
} else {
|
||||
const scaled = localizeScaledPlacement(
|
||||
placement,
|
||||
@@ -2408,7 +2487,14 @@ function paintEntryItem(item, keyhole) {
|
||||
_ctx.save();
|
||||
_ctx.translate(placement.rect.x, placement.rect.y);
|
||||
_ctx.scale(record.paintScale, record.paintScale);
|
||||
paintOverlayEntry(_ctx, entry, scaled, finalAlpha);
|
||||
paintOverlayEntry(
|
||||
_ctx,
|
||||
entry,
|
||||
scaled,
|
||||
finalAlpha,
|
||||
record.leaderProgress,
|
||||
record.contentAlpha,
|
||||
);
|
||||
_ctx.restore();
|
||||
}
|
||||
publishPaintRect(item);
|
||||
|
||||
@@ -1821,6 +1821,85 @@ test('CCTV host binding preserves the smoothstep scale and 0.45/0.35 fade curve'
|
||||
}
|
||||
});
|
||||
|
||||
test('scene presentation samples once per frame and recovers after a callback failure', () => {
|
||||
const env = installMockEnvironment({ width: 500, height: 400, dpr: 1 });
|
||||
const alphas = [];
|
||||
Object.defineProperty(env.ctx, 'globalAlpha', {
|
||||
configurable: true,
|
||||
get() { return alphas.at(-1) ?? 1; },
|
||||
set(value) { alphas.push(value); },
|
||||
});
|
||||
initWorldOverlay(env.viewer);
|
||||
const entry = createCctvThumbnailOverlayEntry({
|
||||
id: 'scene-presentation', position: position(), title: 'SCENE',
|
||||
frameSlot: { frame: { ready: true }, stamp: 1 }, active: true,
|
||||
});
|
||||
Object.assign(entry, {
|
||||
horizonCull: false, maxDistance: Number.POSITIVE_INFINITY, edgeFade: 'none',
|
||||
altitudeScale: null, altitudeFadeEnd: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
let scale = 0.5;
|
||||
let alpha = 0.8;
|
||||
let leader = 0.5;
|
||||
let content = 0.25;
|
||||
let fail = false;
|
||||
const sampled = { scale: 0, alpha: 0, leader: 0, content: 0 };
|
||||
entry.presentationScale = () => { sampled.scale++; return scale; };
|
||||
entry.sourceAlpha = () => {
|
||||
sampled.alpha++;
|
||||
if (fail) throw new Error('presentation unavailable');
|
||||
return alpha;
|
||||
};
|
||||
entry.leaderProgress = () => { sampled.leader++; return leader; };
|
||||
entry.contentAlpha = () => { sampled.content++; return content; };
|
||||
const publish = () => setOverlayEntries('scene-presentation', [entry], {
|
||||
cohortLimit: 1, collisionCapacity: 0,
|
||||
});
|
||||
const frame = () => {
|
||||
env.ctx.calls.length = 0;
|
||||
alphas.length = 0;
|
||||
env.postRender.raise();
|
||||
};
|
||||
try {
|
||||
publish();
|
||||
frame();
|
||||
assert.deepEqual(sampled, { scale: 1, alpha: 1, leader: 1, content: 1 });
|
||||
assert.equal(env.ctx.calls.find(([name]) => name === 'scale')?.[1], 0.5);
|
||||
assert.ok(Math.abs(alphas.at(-1) - 0.2) < 1e-12);
|
||||
assert.ok(env.ctx.calls.some(([name]) => name === 'drawImage'));
|
||||
|
||||
fail = true;
|
||||
assert.doesNotThrow(frame);
|
||||
assert.equal(env.ctx.calls.some(([name]) => name === 'drawImage'), false);
|
||||
assert.deepEqual(sampled, { scale: 2, alpha: 2, leader: 1, content: 1 });
|
||||
|
||||
fail = false;
|
||||
scale = Number.NaN;
|
||||
alpha = 2;
|
||||
leader = Number.NaN;
|
||||
content = 2;
|
||||
frame();
|
||||
assert.equal(env.ctx.calls.some(([name]) => name === 'scale'), false);
|
||||
assert.equal(alphas.at(-1), 1);
|
||||
assert.ok(env.ctx.calls.some(([name]) => name === 'drawImage'));
|
||||
|
||||
scale = 0;
|
||||
frame();
|
||||
assert.equal(env.ctx.calls.some(([name]) => name === 'drawImage'), false);
|
||||
|
||||
// Reusing the record for an ordinary card must not retain animated values.
|
||||
Object.assign(entry, {
|
||||
presentationScale: 1, sourceAlpha: 1, leaderProgress: 1, contentAlpha: 1,
|
||||
});
|
||||
publish();
|
||||
frame();
|
||||
assert.equal(alphas.at(-1), 1);
|
||||
assert.ok(env.ctx.calls.some(([name]) => name === 'drawImage'));
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('CCTV leader remains one CSS pixel at the 0.35 altitude scale point', () => {
|
||||
const env = installMockEnvironment({ width: 500, height: 400, dpr: 1 });
|
||||
env.viewer.camera.positionCartographic.height = 9500;
|
||||
|
||||
@@ -384,7 +384,10 @@ export function measureOverlayEntry(ctx, entry, out = {}) {
|
||||
out.padY = Math.max(0, Number(entry?.thumbnailPadTop) || 0);
|
||||
out.padBottom = Math.max(0, Number(entry?.thumbnailPadBottom) || 0);
|
||||
out.titleGap = Math.max(0, Number(entry?.thumbnailTitleGap) || 0);
|
||||
out.titleH = Math.max(0, Number(entry?.thumbnailTitleHeight) || out.titleH);
|
||||
const thumbnailTitleHeight = Number(entry?.thumbnailTitleHeight);
|
||||
out.titleH = Number.isFinite(thumbnailTitleHeight)
|
||||
? Math.max(0, thumbnailTitleHeight)
|
||||
: out.titleH;
|
||||
out.thumbW = Math.max(1, Number(entry?.thumbnailWidth) || 96);
|
||||
out.thumbH = Math.max(1, Number(entry?.thumbnailHeight) || 54);
|
||||
out.w = out.thumbW + out.padX * 2;
|
||||
@@ -540,34 +543,90 @@ export function placementVariants(
|
||||
return out;
|
||||
}
|
||||
|
||||
function drawLeader(ctx, placement, accent) {
|
||||
function drawLeader(
|
||||
ctx,
|
||||
placement,
|
||||
accent,
|
||||
progress = 1,
|
||||
width = 1,
|
||||
style = 'straight',
|
||||
) {
|
||||
const reveal = Math.max(0, Math.min(1, Number(progress) || 0));
|
||||
if (reveal <= 0) return;
|
||||
ctx.strokeStyle = accent || WORLD_OVERLAY_STYLE.leader;
|
||||
// Leaders are screen-space strokes. Card content may be painted inside an
|
||||
// altitude/distance scale transform, so counter-scale the canvas width to
|
||||
// retain the shipped one-CSS-pixel leader at every card size.
|
||||
ctx.lineWidth = 1 / (placement.paintScale || 1);
|
||||
ctx.beginPath();
|
||||
if (placement.leaderOffset === 0) {
|
||||
ctx.moveTo(placement.leadFromX, placement.leadFromY);
|
||||
} else if (placement.corner === 'above' || placement.corner === 'below') {
|
||||
ctx.moveTo(
|
||||
placement.leadFromX,
|
||||
placement.leadFromY + placement.leaderOffset,
|
||||
);
|
||||
} else {
|
||||
ctx.moveTo(
|
||||
placement.leadFromX + placement.leaderOffset,
|
||||
placement.leadFromY,
|
||||
);
|
||||
ctx.lineWidth = width / (placement.paintScale || 1);
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
let startX = placement.leadFromX;
|
||||
let startY = placement.leadFromY;
|
||||
if (placement.leaderOffset !== 0) {
|
||||
if (placement.corner === 'above' || placement.corner === 'below') {
|
||||
startY += placement.leaderOffset;
|
||||
} else {
|
||||
startX += placement.leaderOffset;
|
||||
}
|
||||
}
|
||||
ctx.lineTo(placement.leadToX, placement.leadToY);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(startX, startY);
|
||||
if (
|
||||
style === 'elbow' &&
|
||||
(placement.corner === 'left' || placement.corner === 'right')
|
||||
) {
|
||||
const elbowY =
|
||||
placement.rect.y + Math.min(Math.max(12, placement.rect.h * 0.12), 26);
|
||||
const verticalLength = Math.abs(elbowY - startY);
|
||||
const horizontalLength = Math.abs(placement.leadToX - startX);
|
||||
const totalLength = verticalLength + horizontalLength;
|
||||
const visibleLength = totalLength * reveal;
|
||||
if (visibleLength <= verticalLength && verticalLength > 0) {
|
||||
const direction = Math.sign(elbowY - startY);
|
||||
ctx.lineTo(startX, startY + visibleLength * direction);
|
||||
} else {
|
||||
ctx.lineTo(startX, elbowY);
|
||||
const remaining = Math.max(0, visibleLength - verticalLength);
|
||||
const direction = Math.sign(placement.leadToX - startX);
|
||||
ctx.lineTo(
|
||||
startX + Math.min(horizontalLength, remaining) * direction,
|
||||
elbowY,
|
||||
);
|
||||
}
|
||||
ctx.stroke();
|
||||
return;
|
||||
}
|
||||
ctx.lineTo(
|
||||
startX + (placement.leadToX - startX) * reveal,
|
||||
startY + (placement.leadToY - startY) * reveal,
|
||||
);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function drawCardChrome(ctx, entry, placement, selected = false) {
|
||||
function drawAnchorDot(ctx, entry, placement) {
|
||||
if (!entry.anchorDot) return;
|
||||
const paintScale = placement.paintScale || 1;
|
||||
const radius = WORLD_OVERLAY_STYLE.anchorDotRadius / paintScale;
|
||||
ctx.beginPath();
|
||||
ctx.arc(placement.anchorX, placement.anchorY, radius, 0, Math.PI * 2);
|
||||
ctx.fillStyle = entry.accent || WORLD_OVERLAY_STYLE.accent;
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = WORLD_OVERLAY_STYLE.anchorDotStroke;
|
||||
ctx.lineWidth = WORLD_OVERLAY_STYLE.anchorDotStrokeWidth / paintScale;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function drawCardChrome(
|
||||
ctx,
|
||||
entry,
|
||||
placement,
|
||||
selected = false,
|
||||
drawLeaderLine = true,
|
||||
) {
|
||||
const { x, y, w, h } = placement.rect;
|
||||
const accent = entry.accent || WORLD_OVERLAY_STYLE.accent;
|
||||
drawLeader(ctx, placement, accent);
|
||||
if (drawLeaderLine)
|
||||
drawLeader(ctx, placement, accent, 1, 1, entry.leaderStyle);
|
||||
ctx.beginPath();
|
||||
roundedRectPath(ctx, x, y, w, h);
|
||||
ctx.fillStyle = selected
|
||||
@@ -821,18 +880,47 @@ export function paintTrack(ctx, entry, placement, alpha = 1) {
|
||||
return placement.rect;
|
||||
}
|
||||
|
||||
/** Paint a standard detail card. */
|
||||
export function paintCard(ctx, entry, placement, alpha = 1) {
|
||||
/** Paint a standard detail card, with an opt-in staged anchor/leader reveal. */
|
||||
export function paintCard(
|
||||
ctx,
|
||||
entry,
|
||||
placement,
|
||||
alpha = 1,
|
||||
leaderProgress = 1,
|
||||
contentAlpha = 1,
|
||||
) {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
drawCardChrome(ctx, entry, placement, false);
|
||||
if (entry.anchorDot) {
|
||||
drawLeader(
|
||||
ctx,
|
||||
placement,
|
||||
entry.accent || WORLD_OVERLAY_STYLE.accent,
|
||||
leaderProgress,
|
||||
WORLD_OVERLAY_STYLE.leaderWidth,
|
||||
entry.leaderStyle,
|
||||
);
|
||||
drawAnchorDot(ctx, entry, placement);
|
||||
ctx.globalAlpha =
|
||||
alpha * Math.max(0, Math.min(1, Number(contentAlpha) || 0));
|
||||
drawCardChrome(ctx, entry, placement, false, false);
|
||||
} else {
|
||||
drawCardChrome(ctx, entry, placement, false);
|
||||
}
|
||||
drawCardText(ctx, entry, placement, false);
|
||||
ctx.restore();
|
||||
return placement.rect;
|
||||
}
|
||||
|
||||
/** Paint a thumbnail card. The image slot remains source-owned. */
|
||||
export function paintThumbnail(ctx, entry, placement, alpha = 1) {
|
||||
export function paintThumbnail(
|
||||
ctx,
|
||||
entry,
|
||||
placement,
|
||||
alpha = 1,
|
||||
leaderProgress = 1,
|
||||
contentAlpha = 1,
|
||||
) {
|
||||
const layout = entry._overlayLayout || {};
|
||||
const { x, y, w, h } = placement.rect;
|
||||
const padX = Number.isFinite(layout.padX) ? layout.padX : 4;
|
||||
@@ -848,7 +936,12 @@ export function paintThumbnail(ctx, entry, placement, alpha = 1) {
|
||||
ctx,
|
||||
placement,
|
||||
entry.thumbnailLeaderColor || WORLD_OVERLAY_STYLE.leader,
|
||||
leaderProgress,
|
||||
entry.anchorDot ? WORLD_OVERLAY_STYLE.leaderWidth : 1,
|
||||
entry.leaderStyle,
|
||||
);
|
||||
drawAnchorDot(ctx, entry, placement);
|
||||
ctx.globalAlpha = alpha * Math.max(0, Math.min(1, Number(contentAlpha) || 0));
|
||||
ctx.beginPath();
|
||||
roundedRectPath(ctx, x, y, w, h, Number(entry.thumbnailRadius) || 4);
|
||||
ctx.fillStyle = entry.thumbnailBackground || WORLD_OVERLAY_STYLE.background;
|
||||
@@ -884,11 +977,13 @@ export function paintThumbnail(ctx, entry, placement, alpha = 1) {
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
ctx.fillStyle = entry.thumbnailTitleColor || WORLD_OVERLAY_STYLE.title;
|
||||
ctx.font = entry.thumbnailTitleFont || WORLD_OVERLAY_STYLE.fontTitle;
|
||||
ctx.fillText(
|
||||
titleChars > 0 ? title.slice(0, titleChars) : title,
|
||||
imageX,
|
||||
imageY + thumbH + titleH - 3,
|
||||
);
|
||||
if (title && titleH > 0) {
|
||||
ctx.fillText(
|
||||
titleChars > 0 ? title.slice(0, titleChars) : title,
|
||||
imageX,
|
||||
imageY + thumbH + titleH - 3,
|
||||
);
|
||||
}
|
||||
ctx.restore();
|
||||
return placement.rect;
|
||||
}
|
||||
@@ -911,7 +1006,7 @@ export function paintTracked(ctx, entry, placement, alpha = 1) {
|
||||
const accent = entry.accent || WORLD_OVERLAY_STYLE.accent;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
drawLeader(ctx, placement, accent);
|
||||
drawLeader(ctx, placement, accent, 1, 1, entry.leaderStyle);
|
||||
ctx.beginPath();
|
||||
roundedRectPath(ctx, x, y, w, h, 5);
|
||||
ctx.fillStyle = WORLD_OVERLAY_STYLE.background;
|
||||
@@ -1018,16 +1113,40 @@ export function paintDetectionCallout(ctx, callout, alpha = 1) {
|
||||
}
|
||||
|
||||
/** Dispatch a normalized entry to its pure variant painter. */
|
||||
export function paintOverlayEntry(ctx, entry, placement, alpha = 1) {
|
||||
export function paintOverlayEntry(
|
||||
ctx,
|
||||
entry,
|
||||
placement,
|
||||
alpha = 1,
|
||||
leaderProgress = 1,
|
||||
contentAlpha = 1,
|
||||
) {
|
||||
if (entry.cardStyle === 'tactical')
|
||||
return paintTacticalCard(ctx, entry, placement, alpha);
|
||||
if (entry.variant === 'tracked')
|
||||
return paintTracked(ctx, entry, placement, alpha);
|
||||
if (entry.selected || entry.variant === 'selected')
|
||||
return paintSelected(ctx, entry, placement, alpha);
|
||||
if (entry.variant === 'thumbnail')
|
||||
return paintThumbnail(ctx, entry, placement, alpha);
|
||||
if (entry.variant === 'card') return paintCard(ctx, entry, placement, alpha);
|
||||
if (entry.variant === 'thumbnail') {
|
||||
return paintThumbnail(
|
||||
ctx,
|
||||
entry,
|
||||
placement,
|
||||
alpha,
|
||||
leaderProgress,
|
||||
contentAlpha,
|
||||
);
|
||||
}
|
||||
if (entry.variant === 'card') {
|
||||
return paintCard(
|
||||
ctx,
|
||||
entry,
|
||||
placement,
|
||||
alpha,
|
||||
leaderProgress,
|
||||
contentAlpha,
|
||||
);
|
||||
}
|
||||
if (entry.variant === 'track')
|
||||
return paintTrack(ctx, entry, placement, alpha);
|
||||
return paintLabel(ctx, entry, placement, alpha);
|
||||
|
||||
@@ -24,6 +24,10 @@ export const WORLD_OVERLAY_STYLE = Object.freeze({
|
||||
fontTrackedTitle: '600 13px "JetBrains Mono", monospace',
|
||||
fontTrackedDetail: '500 11px "JetBrains Mono", monospace',
|
||||
radius: 4,
|
||||
anchorDotRadius: 3.2,
|
||||
anchorDotStrokeWidth: 1,
|
||||
anchorDotStroke: 'rgba(4, 12, 16, 0.96)',
|
||||
leaderWidth: 1.35,
|
||||
});
|
||||
|
||||
/** CCTV's field-tested thumbnail-card overrides on top of shared card chrome. */
|
||||
|
||||
+1635
-88
File diff suppressed because it is too large.
Load diff
@@ -17,7 +17,550 @@ import test from 'node:test';
|
||||
|
||||
import { SceneDirector } from './director.js';
|
||||
import { SCENE_TRACKING_PARAM_KEYS } from './scenePolicy.js';
|
||||
import { SCENE_RECIPES } from './recipes.js';
|
||||
import { SCENE_RECIPES, getSceneAppendRecipeById } from './recipes.js';
|
||||
|
||||
const NEPAL_ORIGINAL_SHOT_TITLES = [
|
||||
'Global Incident Context',
|
||||
'Nepal-Focused Globe Rotation',
|
||||
'Bhote Koshi Regional Approach',
|
||||
'Bhote Koshi Nearby Cities',
|
||||
'Bhote Koshi Incident Corridor',
|
||||
'Bhote Koshi Flood Path',
|
||||
'Bhote Koshi Corridor Overview',
|
||||
'Bhote Koshi Upper Valley',
|
||||
];
|
||||
|
||||
function nepalProjectFixture() {
|
||||
const project = structuredClone(PROJECT_FIXTURE);
|
||||
project.scenes[0].title = 'Nepal Flood Incident';
|
||||
project.scenes[0].shots = NEPAL_ORIGINAL_SHOT_TITLES.map((title, index) => ({
|
||||
...structuredClone(PROJECT_FIXTURE.scenes[0].shots[index % 2]),
|
||||
id: `nepal-original-${index + 1}`,
|
||||
title,
|
||||
camera: {
|
||||
lat: 27 + index / 10,
|
||||
lon: 85 + index / 10,
|
||||
alt: 1000 + index * 100,
|
||||
heading: index * 10,
|
||||
pitch: -35 - index,
|
||||
roll: 0,
|
||||
},
|
||||
visual: {
|
||||
...structuredClone(PROJECT_FIXTURE.scenes[0].shots[index % 2].visual),
|
||||
mapStack: 'photoreal',
|
||||
},
|
||||
layers: {
|
||||
...structuredClone(PROJECT_FIXTURE.scenes[0].shots[index % 2].layers),
|
||||
'bhote-koshi-locator': {
|
||||
enabled: index > 0,
|
||||
params: { presentation: `fixture-${index + 1}` },
|
||||
},
|
||||
},
|
||||
}));
|
||||
project.scenes[0].shots[7].layers['bhote-koshi-locator'] = {
|
||||
enabled: true,
|
||||
params: { presentation: 'bhote-koshi-trigger-record' },
|
||||
};
|
||||
project.scenes[0].shots[7].holdSec = 8;
|
||||
return project;
|
||||
}
|
||||
|
||||
function legacyThreeShotNepalProjectFixture() {
|
||||
const project = structuredClone(PROJECT_FIXTURE);
|
||||
project.scenes[0].title = 'Nepal Flood Incident';
|
||||
project.scenes[0].shots = [0, 1, 2].map((index) => ({
|
||||
...structuredClone(PROJECT_FIXTURE.scenes[0].shots[index % 2]),
|
||||
id: `legacy-nepal-${index + 1}`,
|
||||
title: `Shot ${index + 1}`,
|
||||
camera: {
|
||||
lat: 20 + index,
|
||||
lon: 80 + index,
|
||||
alt: 1000000 + index * 100000,
|
||||
heading: index * 15,
|
||||
pitch: -50 - index,
|
||||
roll: 0,
|
||||
},
|
||||
}));
|
||||
return project;
|
||||
}
|
||||
|
||||
function legacyDefaultProjectWithoutNepalFixture() {
|
||||
const project = structuredClone(PROJECT_FIXTURE);
|
||||
project.scenes[0].id = 'bhote-koshi-flood';
|
||||
project.scenes[0].title = 'Bhote Koshi Flood Reconstruction';
|
||||
project.scenes[0].shots = [{
|
||||
...structuredClone(PROJECT_FIXTURE.scenes[0].shots[0]),
|
||||
id: 'standalone-shot',
|
||||
title: 'Shot 1',
|
||||
}];
|
||||
return project;
|
||||
}
|
||||
|
||||
|
||||
test('Mailung clip trim estimates seven seconds and its exit, including older saved holds', () => {
|
||||
const { director, restore } = makeDirector();
|
||||
try {
|
||||
const scene = director._project.scenes[0];
|
||||
const shot = scene.shots[0];
|
||||
shot.sourcePackId = 'bhote-koshi-nepal-evidence-pack';
|
||||
shot.durationSec = 4.2;
|
||||
shot.holdSec = 15;
|
||||
shot.layers = { 'bhote-koshi-2026': { enabled: true, params: {
|
||||
presentation: 'scene-beat', beatId: 'mailung-bazzar',
|
||||
} } };
|
||||
assert.equal(director._effectiveShotHoldSec(scene, shot), 7.65);
|
||||
assert.equal(director._shotRuntimeDurationSec(scene, shot), 4.2 + 7.65);
|
||||
const state = director._layerStatesForShot(scene, shot)['bhote-koshi-2026'];
|
||||
assert.equal(state.params.sceneContext.holdSec, 7.65);
|
||||
assert.equal(state.params.sceneControls.mediaPlaybackHoldSec, 7);
|
||||
assert.equal(state.params.sceneControls.minimumHoldSec, 7.65);
|
||||
assert.equal(shot.holdSec, 15, 'runtime correction does not rewrite saved shots');
|
||||
shot.layers['bhote-koshi-2026'].params.beatId = 'dandagaun';
|
||||
assert.equal(director._effectiveShotHoldSec(scene, shot), 15, 'other shots keep their authored hold');
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test('Incident Corridor gives all overview pins time to reveal without rewriting saved shots', () => {
|
||||
const { director, restore } = makeDirector();
|
||||
try {
|
||||
const scene = director._project.scenes[0];
|
||||
const shot = scene.shots[0];
|
||||
shot.holdSec = 0.9;
|
||||
shot.layers = { 'bhote-koshi-locator': {
|
||||
enabled: true, params: { presentation: 'bhote-koshi-incident-places' },
|
||||
} };
|
||||
assert.equal(director._effectiveShotHoldSec(scene, shot), 11);
|
||||
assert.equal(shot.holdSec, 0.9);
|
||||
shot.holdSec = 15;
|
||||
assert.equal(director._effectiveShotHoldSec(scene, shot), 15);
|
||||
shot.holdSec = 0.9;
|
||||
shot.layers['bhote-koshi-locator'].params.presentation = 'bhote-koshi-flood-path';
|
||||
assert.equal(director._effectiveShotHoldSec(scene, shot), 0.9);
|
||||
shot.layers['bhote-koshi-locator'].params.presentation = 'bhote-koshi-incident-places';
|
||||
shot.layers['bhote-koshi-locator'].enabled = false;
|
||||
assert.equal(director._effectiveShotHoldSec(scene, shot), 0.9);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test('scene clock seek resolves the exact shot phase and camera in both directions', async () => {
|
||||
const { director, restore } = makeDirector();
|
||||
const loads = [];
|
||||
director._loadShot = async (sceneId, shotId, options) => {
|
||||
loads.push({ sceneId, shotId, options });
|
||||
return { started: true, shotId };
|
||||
};
|
||||
try {
|
||||
assert.equal(await director.seekScene('scene-1', 0), true);
|
||||
assert.equal(await director.seekScene('scene-1', 0.99), true);
|
||||
assert.equal(await director.seekScene('scene-1', 0.25), true);
|
||||
assert.equal(await director.seekScene('missing', 0.5), false);
|
||||
assert.deepEqual(loads.map(({ sceneId, shotId }) => ({ sceneId, shotId })), [
|
||||
{ sceneId: 'scene-1', shotId: 'shot-a' },
|
||||
{ sceneId: 'scene-1', shotId: 'shot-b' },
|
||||
{ sceneId: 'scene-1', shotId: 'shot-a' },
|
||||
]);
|
||||
assert.equal(loads[0].options.sceneSeek.sceneProgress, 0);
|
||||
assert.equal(loads[0].options.sceneSeek.cameraProgress, 0);
|
||||
assert.ok(loads[1].options.sceneSeek.cameraProgress > 0.9);
|
||||
assert.equal(loads[1].options.sceneSeek.shotIndex, 1);
|
||||
assert.equal(loads[2].options.sceneSeek.sceneProgress, 0.25);
|
||||
assert.equal(loads[2].options.sceneSeek.cameraProgress, 0.5);
|
||||
assert.equal(loads[2].options.sceneSeek.camera.lat, 10);
|
||||
assert.equal(loads[2].options.sceneSeek.camera.lon, 20);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('scene clock subscribers receive authoritative forward playback snapshots', () => {
|
||||
const { director, restore } = makeDirector();
|
||||
const snapshots = [];
|
||||
const unsubscribe = director.subscribeSceneClock((snapshot) => snapshots.push(snapshot));
|
||||
try {
|
||||
const scene = director._project.scenes[0];
|
||||
director._publishSceneClock(scene, scene.shots[0], 0.1, { running: true });
|
||||
director._publishSceneClock(scene, scene.shots[1], 0.3, { running: true });
|
||||
unsubscribe();
|
||||
director._publishSceneClock(scene, scene.shots[1], 0.4, { running: false });
|
||||
assert.deepEqual(snapshots.map(({ shotId, sceneElapsedSec, running }) => ({
|
||||
shotId, sceneElapsedSec, running,
|
||||
})), [
|
||||
{ shotId: 'shot-a', sceneElapsedSec: 0.1, running: true },
|
||||
{ shotId: 'shot-b', sceneElapsedSec: 0.3, running: true },
|
||||
]);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test('the Nepal evidence pack appends once and applies the approved corridor framing', () => {
|
||||
const project = nepalProjectFixture();
|
||||
const originalShots = structuredClone(project.scenes[0].shots);
|
||||
const { director, restore } = makeDirector({ project });
|
||||
try {
|
||||
const scene = director._project.scenes[0];
|
||||
const originalShotIds = scene.shots.map(({ id }) => id);
|
||||
const first = director.appendShotPack('scene-1', 'bhote-koshi-nepal-evidence-pack');
|
||||
assert.deepEqual(first, {
|
||||
appended: true,
|
||||
updated: false,
|
||||
shotCount: 17,
|
||||
patchedShotCount: 14,
|
||||
firstShotId: scene.shots[8].id,
|
||||
});
|
||||
assert.deepEqual(scene.shots.slice(0, 8).map(({ id }) => id), originalShotIds);
|
||||
assert.equal(scene.shots.length, 25);
|
||||
assert.ok(scene.releaseLayerIds.includes('bhote-koshi-2026'));
|
||||
assert.ok(scene.releaseLayerIds.includes('bhote-koshi-locator'));
|
||||
assert.equal(scene.appliedShotPacks[0].id, 'bhote-koshi-nepal-evidence-pack');
|
||||
assert.equal(scene.appliedShotPacks[0].version, 18);
|
||||
assert.deepEqual(
|
||||
Object.keys(scene.appliedShotPacks[0].shotBindings),
|
||||
[...NEPAL_ORIGINAL_SHOT_TITLES, ...scene.shots.slice(8).map(({ title }) => title)],
|
||||
);
|
||||
assert.deepEqual(
|
||||
scene.shots.map((shot) => shot.visual.mapStack),
|
||||
[
|
||||
...Array(25).fill('photoreal'),
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
scene.shots.map((shot) => shot.layers['bhote-koshi-2026'].params.beatId),
|
||||
[
|
||||
'immediate-collapse-viewpoint',
|
||||
'debris-dammed-lake',
|
||||
'gyirong-border-gate',
|
||||
'timure-cluster',
|
||||
'syabru-besi',
|
||||
'bidur-trishuli-bridge',
|
||||
'gyirong-border-gate',
|
||||
'immediate-collapse-viewpoint',
|
||||
'immediate-collapse-viewpoint', 'debris-dammed-lake', 'second-landslide',
|
||||
'gyirong-border-gate', 'timure-cluster', 'syabru-besi', 'dhunche',
|
||||
'mailung-upper-trishuli', 'mailung-bazzar', 'dandagaun', 'dandagaun-viewpoint',
|
||||
'betrawati-bazaar', 'bhainse', 'bidur-trishuli-bridge',
|
||||
'devighat-taadi-khola-bridge', 'charaudi', 'final-view',
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
scene.shots[8].layers['bhote-koshi-2026'].params.sceneControls,
|
||||
{ evidenceMediaAutoplay: true },
|
||||
);
|
||||
assert.deepEqual(
|
||||
scene.shots[9].layers['bhote-koshi-2026'].params.sceneControls,
|
||||
{ evidenceMediaAutoplay: true },
|
||||
);
|
||||
assert.deepEqual(
|
||||
scene.shots[11].layers['bhote-koshi-2026'].params.sceneControls,
|
||||
{
|
||||
imageryComparison: true,
|
||||
evidenceSequence: true,
|
||||
evidenceSequenceDurationSec: 2.5,
|
||||
deferEvidenceUntilCameraSettled: true,
|
||||
evidenceRevealDurationSec: 2.5,
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
scene.shots.slice(12, 14).map(
|
||||
(shot) => shot.layers['bhote-koshi-2026'].params.sceneControls,
|
||||
),
|
||||
[
|
||||
{
|
||||
imageryComparison: true,
|
||||
evidenceSequence: true,
|
||||
evidenceSequenceDurationSec: 4,
|
||||
},
|
||||
{
|
||||
imageryComparison: true,
|
||||
evidenceSequence: true,
|
||||
evidenceSequenceDurationSec: 4,
|
||||
},
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
scene.shots[21].layers['bhote-koshi-2026'].params.sceneControls,
|
||||
{
|
||||
evidenceSequence: true,
|
||||
evidenceSequenceDurationSec: 4,
|
||||
},
|
||||
);
|
||||
for (const shot of scene.shots) {
|
||||
assert.equal(shot.layers['bhote-koshi-2026'].enabled, true);
|
||||
assert.equal(shot.layers['bhote-koshi-2026'].params.presentation, 'scene-beat');
|
||||
}
|
||||
assert.deepEqual(
|
||||
scene.shots.slice(0, 8).filter((_, index) => index !== 4).map(({ camera }) => camera),
|
||||
originalShots.filter((_, index) => index !== 4).map(({ camera }) => camera),
|
||||
);
|
||||
assert.deepEqual(scene.shots[4].camera, {
|
||||
lat: 28.0529,
|
||||
lon: 85.2189,
|
||||
alt: 126931,
|
||||
heading: 0,
|
||||
pitch: -90,
|
||||
roll: 0,
|
||||
});
|
||||
assert.equal(scene.shots[4].holdSec, 11);
|
||||
assert.deepEqual(
|
||||
scene.shots.slice(0, 8).map((shot) => shot.layers['bhote-koshi-locator']),
|
||||
originalShots.map((shot) => shot.layers['bhote-koshi-locator']),
|
||||
);
|
||||
assert.equal(new Set(scene.shots.map(({ id }) => id)).size, 25);
|
||||
assert.deepEqual(
|
||||
director.appendShotPack('scene-1', 'bhote-koshi-nepal-evidence-pack'),
|
||||
{ appended: false, reason: 'already-appended' },
|
||||
);
|
||||
assert.equal(scene.shots.length, 25);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('installed v12 Nepal pack inserts ten points without replacing renamed cameras', () => {
|
||||
const first = makeDirector({ project: nepalProjectFixture() });
|
||||
let legacy;
|
||||
const recipe = getSceneAppendRecipeById('bhote-koshi-nepal-evidence-pack');
|
||||
try {
|
||||
first.director.appendShotPack('scene-1', recipe.id);
|
||||
legacy = structuredClone(first.director._project);
|
||||
const scene = legacy.scenes[0];
|
||||
scene.shots = scene.shots.filter((shot) => !shot.sourcePackId
|
||||
|| recipe.previousRequiredSourcePackBeatIds.includes(shot.layers['bhote-koshi-2026'].params.beatId));
|
||||
scene.appliedShotPacks[0].version = 12;
|
||||
const ids = new Set(scene.shots.map(({ id }) => id));
|
||||
scene.appliedShotPacks[0].shotBindings = Object.fromEntries(
|
||||
Object.entries(scene.appliedShotPacks[0].shotBindings).filter(([, id]) => ids.has(id)));
|
||||
scene.shots[8].title = 'My collapse camera';
|
||||
scene.shots[8].camera.heading = 149;
|
||||
} finally { first.restore(); }
|
||||
const originalShots = structuredClone(legacy.scenes[0].shots);
|
||||
const upgraded = makeDirector({ project: legacy });
|
||||
try {
|
||||
const scene = upgraded.director._project.scenes[0];
|
||||
assert.equal(scene.shots.length, 25);
|
||||
for (const old of originalShots) {
|
||||
const current = scene.shots.find(({ id }) => id === old.id);
|
||||
assert.ok(current);
|
||||
assert.deepEqual(current.camera, old.camera);
|
||||
assert.equal(current.title, old.title);
|
||||
}
|
||||
assert.deepEqual(scene.shots.filter((shot) => shot.sourcePackId).map((shot) =>
|
||||
shot.layers['bhote-koshi-2026'].params.beatId), recipe.requiredSourcePackBeatIds);
|
||||
assert.equal(scene.appliedShotPacks[0].version, 18);
|
||||
assert.equal(upgraded.director.appendShotPack(scene.id, recipe.id).reason, 'already-appended');
|
||||
} finally { upgraded.restore(); }
|
||||
});
|
||||
|
||||
|
||||
test('a legacy three-shot Nepal browser project bootstraps to the current 25-shot sequence', () => {
|
||||
const project = legacyThreeShotNepalProjectFixture();
|
||||
const originalIds = project.scenes[0].shots.map(({ id }) => id);
|
||||
const originalCameras = project.scenes[0].shots.map(({ camera }) => structuredClone(camera));
|
||||
const { director, restore } = makeDirector({ project });
|
||||
try {
|
||||
const scene = director._project.scenes[0];
|
||||
assert.equal(scene.shots.length, 25);
|
||||
assert.deepEqual(scene.shots.map(({ title }) => title),
|
||||
getSceneAppendRecipeById('bhote-koshi-nepal-evidence-pack').requiredShotTitles);
|
||||
assert.deepEqual(scene.shots.slice(0, 3).map(({ id }) => id), originalIds);
|
||||
assert.deepEqual(scene.shots.slice(0, 3).map(({ camera }) => camera), originalCameras);
|
||||
assert.equal(scene.appliedShotPacks[0].id, 'bhote-koshi-nepal-evidence-pack');
|
||||
assert.equal(scene.appliedShotPacks[0].version, 18);
|
||||
assert.equal(director._selectedSceneId, scene.id);
|
||||
assert.equal(director._selectedShotId, scene.shots[0].id);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('the Nepal evidence pack refuses a partial inventory without mutating the scene', () => {
|
||||
const project = nepalProjectFixture();
|
||||
project.scenes[0].shots[3].title = 'Renamed Nearby Cities';
|
||||
const { director, restore } = makeDirector({ project });
|
||||
try {
|
||||
const sceneBefore = structuredClone(director._project.scenes[0]);
|
||||
assert.deepEqual(
|
||||
director.appendShotPack('scene-1', 'bhote-koshi-nepal-evidence-pack'),
|
||||
{
|
||||
appended: false,
|
||||
updated: false,
|
||||
reason: 'shot-inventory-mismatch',
|
||||
},
|
||||
);
|
||||
assert.deepEqual(director._project.scenes[0], sceneBefore);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('the Nepal pack upgrades the upper-valley shots without duplicating evidence beats', () => {
|
||||
const initial = makeDirector({ project: nepalProjectFixture() });
|
||||
initial.director.appendShotPack('scene-1', 'bhote-koshi-nepal-evidence-pack');
|
||||
const project = structuredClone(initial.director._project);
|
||||
initial.restore();
|
||||
project.scenes[0].appliedShotPacks = [{
|
||||
id: 'bhote-koshi-nepal-evidence-pack',
|
||||
version: 4,
|
||||
}];
|
||||
for (const shot of project.scenes[0].shots.slice(0, 8)) {
|
||||
delete shot.layers['bhote-koshi-2026'];
|
||||
}
|
||||
const originalLength = project.scenes[0].shots.length;
|
||||
const originalIds = project.scenes[0].shots.map(({ id }) => id);
|
||||
const originalCameras = project.scenes[0].shots.map(({ camera }) => structuredClone(camera));
|
||||
const originalLocators = project.scenes[0].shots.map(
|
||||
(shot) => structuredClone(shot.layers['bhote-koshi-locator']),
|
||||
);
|
||||
const { director, restore } = makeDirector({ project });
|
||||
try {
|
||||
const scene = director._project.scenes[0];
|
||||
assert.deepEqual(
|
||||
director.appendShotPack('scene-1', 'bhote-koshi-nepal-evidence-pack'),
|
||||
{ appended: false, reason: 'already-appended' },
|
||||
);
|
||||
assert.equal(scene.shots.length, originalLength);
|
||||
assert.deepEqual(scene.shots.map(({ id }) => id), originalIds);
|
||||
assert.deepEqual(scene.shots.map(({ camera }) => camera), originalCameras);
|
||||
assert.deepEqual(
|
||||
scene.shots.map((shot) => shot.layers['bhote-koshi-locator']),
|
||||
originalLocators,
|
||||
);
|
||||
assert.deepEqual(scene.shots[8].camera, {
|
||||
lat: 28.417,
|
||||
lon: 85.3937,
|
||||
alt: 9850,
|
||||
heading: 146,
|
||||
pitch: -34,
|
||||
roll: 0,
|
||||
});
|
||||
assert.equal(scene.appliedShotPacks[0].version, 18);
|
||||
assert.equal(Object.keys(scene.appliedShotPacks[0].shotBindings).length, 25);
|
||||
assert.ok(scene.shots.every((shot) => (
|
||||
shot.layers['bhote-koshi-2026']?.enabled === true
|
||||
&& shot.layers['bhote-koshi-2026']?.params?.presentation === 'scene-beat'
|
||||
)));
|
||||
const afterUpgrade = structuredClone(scene);
|
||||
assert.deepEqual(
|
||||
director.appendShotPack('scene-1', 'bhote-koshi-nepal-evidence-pack'),
|
||||
{ appended: false, reason: 'already-appended' },
|
||||
);
|
||||
assert.deepEqual(scene, afterUpgrade);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test('an older default project gains the complete selectable Nepal scene once', () => {
|
||||
const project = legacyDefaultProjectWithoutNepalFixture();
|
||||
const { director, restore } = makeDirector({
|
||||
project,
|
||||
data: { registered: [...REGISTERED, 'bhote-koshi-2026', 'bhote-koshi-locator'] },
|
||||
});
|
||||
try {
|
||||
const nepal = director._project.scenes.find(({ title }) => title === 'Nepal Flood Incident');
|
||||
assert.ok(nepal);
|
||||
assert.equal(nepal.shots.length, 25);
|
||||
assert.equal(nepal.shots[0].title, 'Global Incident Context');
|
||||
assert.equal(nepal.shots.at(-1).title, 'Final view');
|
||||
assert.deepEqual(director._project.installedBuiltInSceneIds, ['bhote-koshi-nepal-scene']);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a previously installed Nepal scene stays deleted when its marker remains', () => {
|
||||
const project = legacyDefaultProjectWithoutNepalFixture();
|
||||
project.installedBuiltInSceneIds = ['bhote-koshi-nepal-scene'];
|
||||
const { director, restore } = makeDirector({ project });
|
||||
try {
|
||||
assert.equal(
|
||||
director._project.scenes.some(({ title }) => title === 'Nepal Flood Incident'),
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('public defaults include Nepal without an extra standalone flood recipe', () => {
|
||||
assert.equal(SCENE_RECIPES.some((item) => item.id === 'bhote-koshi-flood'), false);
|
||||
assert.equal(SCENE_RECIPES.filter((item) => item.id === 'bhote-koshi-nepal-scene').length, 1);
|
||||
});
|
||||
|
||||
|
||||
test('an existing public default project gains Nepal without replacing authored shots', () => {
|
||||
const project = structuredClone(PROJECT_FIXTURE);
|
||||
project.scenes[0].id = 'flights-radar';
|
||||
const original = structuredClone(project.scenes[0].shots);
|
||||
const { director, restore } = makeDirector({ project });
|
||||
try {
|
||||
assert.deepEqual(director._project.scenes[0].shots.map(({ id, camera }) => ({ id, camera })),
|
||||
original.map(({ id, camera }) => ({ id, camera })));
|
||||
assert.equal(director._project.scenes[1].title, 'Nepal Flood Incident');
|
||||
assert.equal(director._project.scenes[1].shots.length, 25);
|
||||
assert.deepEqual(director._project.installedBuiltInSceneIds, ['bhote-koshi-nepal-scene']);
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test('Nepal comparison shots load Esri beneath Vantor even from a saved OSM or photoreal shot', async () => {
|
||||
const { director, styleManager, restore } = makeDirector({
|
||||
project: nepalProjectFixture(),
|
||||
data: { registered: [...REGISTERED, 'bhote-koshi-2026', 'bhote-koshi-locator'] },
|
||||
});
|
||||
try {
|
||||
director.appendShotPack('scene-1', 'bhote-koshi-nepal-evidence-pack');
|
||||
const scene = director._project.scenes[0];
|
||||
const comparisonShots = scene.shots.filter((shot) =>
|
||||
director._layerStatesForShot(scene, shot)['bhote-koshi-2026']?.params?.sceneControls?.imageryComparison);
|
||||
assert.ok(comparisonShots.length >= 2, 'the real comparison beats must be exercised');
|
||||
for (const shot of comparisonShots) {
|
||||
for (const prior of ['osm', 'photoreal']) {
|
||||
shot.visual.mapStack = prior;
|
||||
await director.loadShot(scene.id, shot.id, { flyDuration: 0.2 });
|
||||
assert.equal(styleManager.visualStates.at(-1).mapStack, 'esri-imagery', shot.title);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await director.destroy();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('all saved Nepal shots choose a usable map in keyed and keyless runtimes without rewriting the project', async () => {
|
||||
let photorealAvailable = false;
|
||||
const { director, styleManager, restore } = makeDirector({
|
||||
project: nepalProjectFixture(),
|
||||
isMapStackAvailable: (id) => id === 'photoreal' && photorealAvailable,
|
||||
data: { registered: [...REGISTERED, 'bhote-koshi-2026', 'bhote-koshi-locator'] },
|
||||
});
|
||||
try {
|
||||
director.appendShotPack('scene-1', 'bhote-koshi-nepal-evidence-pack');
|
||||
const scene = director._project.scenes[0];
|
||||
const savedShots = structuredClone(scene.shots);
|
||||
for (const available of [false, true, false]) {
|
||||
photorealAvailable = available;
|
||||
for (const shot of scene.shots) {
|
||||
const comparison = director._layerStatesForShot(scene, shot)
|
||||
['bhote-koshi-2026']?.params?.sceneControls?.imageryComparison;
|
||||
const visual = director._visualStateForShot(shot);
|
||||
assert.equal(visual.mapStack, available && !comparison ? 'photoreal' : 'esri-imagery', shot.title);
|
||||
}
|
||||
await director.loadShot(scene.id, scene.shots[0].id, { flyDuration: 0.2 });
|
||||
assert.equal(styleManager.visualStates.at(-1).mapStack, available ? 'photoreal' : 'esri-imagery');
|
||||
}
|
||||
assert.deepEqual(scene.shots, savedShots);
|
||||
const unrelated = { visual: { mapStack: 'photoreal' }, layers: { flights: { enabled: true } } };
|
||||
assert.equal(director._visualStateForShot(unrelated), unrelated.visual, 'other scenes keep their provider policy');
|
||||
} finally {
|
||||
await director.destroy();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
/** The layer registry as main.js builds it (src/main.js dataManager.register calls). */
|
||||
const REGISTERED = [
|
||||
@@ -98,6 +641,10 @@ function fakeDataManager({ registered = REGISTERED, refuse = () => false } = {})
|
||||
const committed = [];
|
||||
return {
|
||||
setEnabledCalls,
|
||||
subscribeVisibilityRequests(listener) {
|
||||
this.visibilityListener = listener;
|
||||
return () => { this.visibilityListener = null; };
|
||||
},
|
||||
setParamsCalls,
|
||||
committed,
|
||||
getAll: () => registered.map((id) => ({ id, enabled: !!enabled.get(id) })),
|
||||
@@ -174,13 +721,67 @@ function makeDirector(options = {}) {
|
||||
const viewer = fakeViewer();
|
||||
const styleManager = fakeStyleManager(options.style);
|
||||
const dataManager = fakeDataManager(options.data);
|
||||
const director = new SceneDirector(viewer, styleManager, dataManager);
|
||||
const director = new SceneDirector(viewer, styleManager, dataManager, {
|
||||
isMapStackAvailable: options.isMapStackAvailable,
|
||||
});
|
||||
// Telemetry is only accumulated during a run; observable-failure assertions
|
||||
// need the accumulator without driving a whole run.
|
||||
director._activeRun = { events: [] };
|
||||
return { director, viewer, styleManager, dataManager, restore };
|
||||
}
|
||||
|
||||
test('scene camera waits for provider completion and fade rather than the saved media hold', async () => {
|
||||
const { director, viewer, dataManager, restore } = makeDirector();
|
||||
let release;
|
||||
let pending = true;
|
||||
const gate = new Promise((resolve) => { release = resolve; });
|
||||
try {
|
||||
const scene = director._project.scenes[0];
|
||||
scene.shots[0].layers.flights.params = { beatId: 'trimmed' };
|
||||
dataManager.layers = new Map([['flights', { module: {
|
||||
getSceneShotMediaHold: (id) => id === 'trimmed' ? { pending, maxWaitMs: 20000 } : null,
|
||||
} }]]);
|
||||
const waits = [];
|
||||
director._sleep = async (ms) => { waits.push(ms); if (ms === 70) await gate; };
|
||||
const run = director.startScene('scene-1', { single: true, preview: false });
|
||||
await settle(40);
|
||||
assert.equal(viewer.flights.length, 1, 'no next flight while source is starting, playing or fading');
|
||||
assert.deepEqual(waits, [70]);
|
||||
pending = false; release();
|
||||
await run;
|
||||
assert.equal(viewer.flights.length, 2, 'the next shot starts immediately after owned media completion');
|
||||
} finally { release(); await director.destroy(); restore(); }
|
||||
});
|
||||
|
||||
test('Stop cancels a provider-owned shot hold and late completion cannot fly the next camera', async () => {
|
||||
const { director, viewer, dataManager, restore } = makeDirector();
|
||||
let release;
|
||||
const gate = new Promise((resolve) => { release = resolve; });
|
||||
try {
|
||||
dataManager.layers = new Map([['flights', { module: {
|
||||
getSceneShotMediaHold: () => ({ pending: true, maxWaitMs: 20000 }),
|
||||
} }]]);
|
||||
director._sleep = () => gate;
|
||||
const run = director.startScene('scene-1', { single: true, preview: false });
|
||||
await settle(40);
|
||||
assert.equal(viewer.flights.length, 1);
|
||||
director.stopScene('test stop'); release(); await run;
|
||||
assert.equal(viewer.flights.length, 1);
|
||||
assert.equal(director.running, false);
|
||||
} finally { release(); await director.destroy(); restore(); }
|
||||
});
|
||||
|
||||
test('a provider-owned hold fails boundedly if its owner never settles', async () => {
|
||||
const { director, restore } = makeDirector();
|
||||
try {
|
||||
director.dataManager.layers = new Map([['flights', { module: {
|
||||
getSceneShotMediaHold: () => ({ pending: true, maxWaitMs: 0 }),
|
||||
} }]]);
|
||||
const scene = director._project.scenes[0];
|
||||
await assert.rejects(director._holdShot(scene, scene.shots[0], { cancelled: false }), /bounded playback window/);
|
||||
} finally { await director.destroy(); restore(); }
|
||||
});
|
||||
|
||||
/** The layer map a shipped recipe declares, in normalized form. */
|
||||
function recipeLayers(recipeId) {
|
||||
const recipe = SCENE_RECIPES.find((item) => item.id === recipeId);
|
||||
@@ -189,6 +790,104 @@ function recipeLayers(recipeId) {
|
||||
);
|
||||
}
|
||||
|
||||
test('destroyed directors refuse seek, replay, adjacent and continuation without writes', async () => {
|
||||
const { director, viewer, dataManager, restore } = makeDirector();
|
||||
try {
|
||||
await director.destroy();
|
||||
director._loadedSceneId = 'scene-1';
|
||||
assert.equal(await director.seekScene('scene-1', 0), false);
|
||||
assert.equal((await director.replayShot('scene-1', 'shot-a')).started, false);
|
||||
assert.equal(await director.loadAdjacentShot('scene-1', 'shot-a', 1), false);
|
||||
assert.equal((await director.continueScene('scene-1', 'shot-a')).started, false);
|
||||
assert.equal(viewer.flights.length, 0);
|
||||
assert.equal(dataManager.setParamsCalls.length, 0);
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test('replay, adjacent and seek report layer refusal rather than success', async () => {
|
||||
const { director, restore } = makeDirector({ data: { refuse: () => true } });
|
||||
try {
|
||||
assert.equal((await director.replayShot('scene-1', 'shot-a')).started, false);
|
||||
assert.equal(await director.loadAdjacentShot('scene-1', 'shot-a', 1), false);
|
||||
assert.equal(await director.seekScene('scene-1', 0), false);
|
||||
} finally { await director.destroy(); restore(); }
|
||||
});
|
||||
|
||||
test('a seek waiting for run teardown is revoked by newer Stop, Load, Start or destroy', async () => {
|
||||
for (const action of ['stop', 'load', 'start', 'destroy']) {
|
||||
const { director, restore } = makeDirector();
|
||||
try {
|
||||
let release;
|
||||
director._waitForRunIdle = () => new Promise((resolve) => { release = resolve; });
|
||||
director._running = true;
|
||||
director._runToken = { cancelled: false };
|
||||
const seek = director.seekScene('scene-1', 0);
|
||||
assert.equal(director._pendingWork.has(seek), true);
|
||||
let newer;
|
||||
if (action === 'stop') director.stopScene();
|
||||
if (action === 'load') newer = director.loadShot('scene-1', 'shot-b');
|
||||
if (action === 'start') newer = director.startScene('scene-1');
|
||||
if (action === 'destroy') newer = director.destroy();
|
||||
director._running = false;
|
||||
release();
|
||||
assert.equal(await seek, false, action);
|
||||
await newer;
|
||||
} finally { await director.destroy(); restore(); }
|
||||
}
|
||||
});
|
||||
|
||||
test('explicit scene-layer OFF revokes continuation during enable, flight and hold; internal OFF does not', async () => {
|
||||
for (const phase of ['enable', 'flight', 'hold']) {
|
||||
const { director, dataManager, restore } = makeDirector();
|
||||
try {
|
||||
director._project.scenes[0].releaseLayerIds = ['flights'];
|
||||
let release;
|
||||
const gate = new Promise((resolve) => { release = resolve; });
|
||||
if (phase === 'enable') {
|
||||
const enable = dataManager.setEnabled.bind(dataManager);
|
||||
dataManager.setEnabled = async (...args) => { await gate; return enable(...args); };
|
||||
} else if (phase === 'flight') director._flyCamera = () => gate;
|
||||
else director._sleep = () => gate;
|
||||
director._project.scenes[0].shots[0].holdSec = 1;
|
||||
const run = director.startScene('scene-1', { single: true, preview: false });
|
||||
await settle(40);
|
||||
const token = director._runToken;
|
||||
dataManager.visibilityListener({ layerId: 'flights', enabled: false, origin: 'scene' });
|
||||
assert.equal(token.cancelled, false);
|
||||
dataManager.visibilityListener({ layerId: 'flights', enabled: false, origin: 'user' });
|
||||
assert.equal(token.cancelled, true, phase);
|
||||
dataManager.visibilityListener({ layerId: 'flights', enabled: false, origin: 'tool' });
|
||||
release();
|
||||
await run;
|
||||
assert.equal(dataManager.committed.some(({ id, enabled }) => id === 'traffic' && enabled), false);
|
||||
} finally { await director.destroy(); restore(); }
|
||||
}
|
||||
});
|
||||
|
||||
test('scene preview owns recording chrome while panel playback leaves it available', async () => {
|
||||
for (const preview of [true, false]) {
|
||||
const { director, styleManager, restore } = makeDirector();
|
||||
const recording = [];
|
||||
const playback = [];
|
||||
let release;
|
||||
const flight = new Promise((resolve) => { release = resolve; });
|
||||
director._flyCamera = () => flight;
|
||||
styleManager.setRecordingMode = (active) => recording.push(active);
|
||||
director._setPlaybackActive = (active) => playback.push(active);
|
||||
try {
|
||||
const run = director.startScene('scene-1', { single: true, preview });
|
||||
await settle(40);
|
||||
assert.deepEqual(recording, preview ? [true] : []);
|
||||
assert.deepEqual(playback, preview ? [true] : []);
|
||||
director.stopScene();
|
||||
release();
|
||||
await run;
|
||||
assert.deepEqual(recording, preview ? [true, false] : []);
|
||||
assert.deepEqual(playback, preview ? [true, false] : []);
|
||||
} finally { release(); await director.destroy(); restore(); }
|
||||
}
|
||||
});
|
||||
|
||||
test('the director reconciles only the layers a shot declares', async () => {
|
||||
// Regression: _applyLayerStates walked the LIVE registry and forced every
|
||||
// undeclared layer off, tearing down CCTV/vessels/fires with no restore pass.
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
import { SOURCE_PATH_BEAT_IDS } from '../data/bhoteKoshiShotPaths.js';
|
||||
|
||||
/**
|
||||
* Nepal evidence expansion. Coordinates and camera framing come from
|
||||
* the source event pack; existing authored cameras remain owned by the Scene.
|
||||
*/
|
||||
const ADDITIONAL_SHOTS = [
|
||||
{
|
||||
id: 'second-landslide',
|
||||
title: 'Second landslide',
|
||||
lat: 28.399234,
|
||||
lon: 85.535806,
|
||||
alt: 10145,
|
||||
heading: 224,
|
||||
pitch: -38,
|
||||
hold: 11,
|
||||
},
|
||||
{
|
||||
id: 'dhunche',
|
||||
title: 'Dhunche',
|
||||
lat: 28.161431,
|
||||
lon: 85.324278,
|
||||
alt: 6212,
|
||||
heading: 208,
|
||||
pitch: -39,
|
||||
hold: 16,
|
||||
},
|
||||
{
|
||||
id: 'mailung-upper-trishuli',
|
||||
title: 'Mailung / Upper Trishuli bridge',
|
||||
lat: 28.140642,
|
||||
lon: 85.265996,
|
||||
alt: 6353,
|
||||
heading: 211,
|
||||
pitch: -40,
|
||||
hold: 13,
|
||||
},
|
||||
{
|
||||
id: 'mailung-bazzar',
|
||||
title: 'Mailung Bazzar, Dandaguan',
|
||||
lat: 28.126526,
|
||||
lon: 85.248027,
|
||||
alt: 6331,
|
||||
heading: 212,
|
||||
pitch: -40,
|
||||
hold: 15,
|
||||
},
|
||||
{
|
||||
id: 'dandagaun',
|
||||
title: 'Dandagaun, Rasuwa',
|
||||
lat: 28.124424,
|
||||
lon: 85.245154,
|
||||
alt: 6311,
|
||||
heading: 214,
|
||||
pitch: -40,
|
||||
hold: 15,
|
||||
},
|
||||
{
|
||||
id: 'dandagaun-viewpoint',
|
||||
title: 'Dandagaun viewpoint restaurant',
|
||||
lat: 28.086663,
|
||||
lon: 85.229914,
|
||||
alt: 6740,
|
||||
heading: 215,
|
||||
pitch: -41,
|
||||
hold: 13,
|
||||
},
|
||||
{
|
||||
id: 'betrawati-bazaar',
|
||||
title: 'Betrawati Bazaar',
|
||||
lat: 28.029827,
|
||||
lon: 85.227694,
|
||||
alt: 7034,
|
||||
heading: 216,
|
||||
pitch: -41,
|
||||
hold: 12,
|
||||
},
|
||||
{
|
||||
id: 'bhainse',
|
||||
title: 'Bhainse',
|
||||
lat: 28.023087,
|
||||
lon: 85.229449,
|
||||
alt: 8051,
|
||||
heading: 219,
|
||||
pitch: -41,
|
||||
hold: 13,
|
||||
},
|
||||
{
|
||||
id: 'devighat-taadi-khola-bridge',
|
||||
title: 'Devighat / Taadi Khola Bridge',
|
||||
lat: 27.921132,
|
||||
lon: 85.177113,
|
||||
alt: 9056,
|
||||
heading: 224,
|
||||
pitch: -42,
|
||||
hold: 13,
|
||||
},
|
||||
{
|
||||
id: 'charaudi',
|
||||
title: 'Charaudi',
|
||||
lat: 27.865095,
|
||||
lon: 84.838424,
|
||||
alt: 10347,
|
||||
heading: 232,
|
||||
pitch: -42,
|
||||
hold: 11,
|
||||
},
|
||||
{
|
||||
id: 'final-view',
|
||||
title: 'Final view',
|
||||
lat: 28.0834,
|
||||
lon: 85.206,
|
||||
alt: 113520,
|
||||
heading: 64,
|
||||
pitch: -83,
|
||||
roll: 174,
|
||||
duration: 4,
|
||||
hold: 11,
|
||||
locatorPresentation: 'bhote-koshi-incident-places',
|
||||
},
|
||||
];
|
||||
const EVIDENCE_ORDER = [
|
||||
'immediate-collapse-viewpoint',
|
||||
'debris-dammed-lake',
|
||||
'second-landslide',
|
||||
'gyirong-border-gate',
|
||||
'timure-cluster',
|
||||
'syabru-besi',
|
||||
'dhunche',
|
||||
'mailung-upper-trishuli',
|
||||
'mailung-bazzar',
|
||||
'dandagaun',
|
||||
'dandagaun-viewpoint',
|
||||
'betrawati-bazaar',
|
||||
'bhainse',
|
||||
'bidur-trishuli-bridge',
|
||||
'devighat-taadi-khola-bridge',
|
||||
'charaudi',
|
||||
'final-view',
|
||||
];
|
||||
|
||||
// Per-reach motion timing keeps the schematic front ahead of the fixed 4.2s
|
||||
// camera flights without pretending short urban reaches and long valley runs
|
||||
// should advance at one global speed.
|
||||
const PATH_DURATION_BY_BEAT = Object.freeze({
|
||||
'debris-dammed-lake': 4.8,
|
||||
'second-landslide': 6.5,
|
||||
'timure-cluster': 2.1,
|
||||
'syabru-besi': 2.8,
|
||||
dhunche: 2.5,
|
||||
'mailung-upper-trishuli': 2.9,
|
||||
'mailung-bazzar': 2.1,
|
||||
dandagaun: 1.2,
|
||||
'dandagaun-viewpoint': 2.5,
|
||||
'betrawati-bazaar': 2.9,
|
||||
bhainse: 1.5,
|
||||
'bidur-trishuli-bridge': 2.6,
|
||||
});
|
||||
|
||||
/** Extend the six-beat Nepal pack without replacing existing authored shots. */
|
||||
export function expandNepalEvidencePack(base) {
|
||||
const layerId = base.requiredSourcePackLayerId;
|
||||
const extraShots = ADDITIONAL_SHOTS.map(
|
||||
({ id, hold, duration = 4.2, locatorPresentation, ...camera }) => ({
|
||||
...camera,
|
||||
roll: Number.isFinite(Number(camera.roll)) ? Number(camera.roll) : 0,
|
||||
duration,
|
||||
hold,
|
||||
mapStack: 'photoreal',
|
||||
layers: {
|
||||
[layerId]: {
|
||||
enabled: true,
|
||||
params: {
|
||||
presentation: 'scene-beat',
|
||||
beatId: id,
|
||||
beatReveal: 0.36,
|
||||
split: 0.5,
|
||||
},
|
||||
},
|
||||
...(locatorPresentation
|
||||
? {
|
||||
'bhote-koshi-locator': {
|
||||
enabled: true,
|
||||
params: { presentation: locatorPresentation },
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
const byBeat = new Map(
|
||||
[...base.cameraPath, ...extraShots].map((shot) => [
|
||||
shot.layers[layerId].params.beatId,
|
||||
shot,
|
||||
]),
|
||||
);
|
||||
const cameraPath = EVIDENCE_ORDER.map((id) => byBeat.get(id));
|
||||
const runtimeControlsByBeat = { ...base.runtimeControlsByBeat };
|
||||
const cameraLedPathBeats = new Set([
|
||||
'timure-cluster',
|
||||
'syabru-besi',
|
||||
...SOURCE_PATH_BEAT_IDS.slice(2),
|
||||
]);
|
||||
const mediaLedPathBeats = new Set(['debris-dammed-lake', 'second-landslide']);
|
||||
const finalSourcedPathBeatId = SOURCE_PATH_BEAT_IDS.at(-1);
|
||||
const postSourceHistoryBeats = new Set([
|
||||
'devighat-taadi-khola-bridge',
|
||||
'charaudi',
|
||||
'final-view',
|
||||
]);
|
||||
for (const shot of cameraPath) {
|
||||
const id = shot.layers[layerId].params.beatId;
|
||||
const isFinalView = id === 'final-view';
|
||||
const authoredPathDurationSec = PATH_DURATION_BY_BEAT[id];
|
||||
runtimeControlsByBeat[id] = {
|
||||
...runtimeControlsByBeat[id],
|
||||
evidenceMediaAutoplay: !isFinalView,
|
||||
...(isFinalView
|
||||
? {}
|
||||
: {
|
||||
mediaPlaybackHoldSec: id === 'mailung-bazzar' ? 7 : 6,
|
||||
mediaExitDurationSec: 0.65,
|
||||
}),
|
||||
deferEvidenceUntilCameraSettled: !isFinalView,
|
||||
evidenceRevealDurationSec: 0.9,
|
||||
evidencePath: SOURCE_PATH_BEAT_IDS.includes(id)
|
||||
? 'source'
|
||||
: ['gyirong-border-gate', 'timure-cluster', 'syabru-besi'].includes(id)
|
||||
? 'comparison'
|
||||
: 'none',
|
||||
...(cameraLedPathBeats.has(id)
|
||||
? {
|
||||
evidencePathDuringCamera: true,
|
||||
evidencePathPersistent: true,
|
||||
...(authoredPathDurationSec
|
||||
? { evidencePathTravelDurationSec: authoredPathDurationSec }
|
||||
: {}),
|
||||
}
|
||||
: {}),
|
||||
...(mediaLedPathBeats.has(id)
|
||||
? {
|
||||
evidencePathDuringMedia: true,
|
||||
evidencePathPersistent: true,
|
||||
evidencePathStartDelaySec: 0,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
if (SOURCE_PATH_BEAT_IDS.includes(id)) {
|
||||
runtimeControlsByBeat[id] = {
|
||||
...runtimeControlsByBeat[id],
|
||||
evidenceHoldCard: true,
|
||||
evidencePathHistory: true,
|
||||
evidencePathDurationSec:
|
||||
authoredPathDurationSec || Math.max(4, shot.hold),
|
||||
minimumHoldSec: Math.max(4, shot.hold),
|
||||
};
|
||||
}
|
||||
if (SOURCE_PATH_BEAT_IDS.slice(2).includes(id)) {
|
||||
runtimeControlsByBeat[id] = {
|
||||
...runtimeControlsByBeat[id],
|
||||
evidencePathHistory: true,
|
||||
evidencePathElevation: 'source',
|
||||
};
|
||||
}
|
||||
if (postSourceHistoryBeats.has(id)) {
|
||||
runtimeControlsByBeat[id] = {
|
||||
...runtimeControlsByBeat[id],
|
||||
evidencePath: 'history',
|
||||
evidencePathHistory: true,
|
||||
evidencePathHistoryBeatId: finalSourcedPathBeatId,
|
||||
evidencePathPersistent: true,
|
||||
evidencePathElevation: 'source',
|
||||
};
|
||||
}
|
||||
const pathDurationSec =
|
||||
Number(runtimeControlsByBeat[id].evidencePathDurationSec) ||
|
||||
(!cameraLedPathBeats.has(id) &&
|
||||
runtimeControlsByBeat[id].evidenceSequence === true
|
||||
? Number(runtimeControlsByBeat[id].evidenceSequenceDurationSec) || 0
|
||||
: 0);
|
||||
const mediaPresentationSec = isFinalView
|
||||
? 0
|
||||
: (id === 'mailung-bazzar' ? 7 : 6) + 0.65;
|
||||
const mediaAndPathHoldSec =
|
||||
pathDurationSec > 0 && !cameraLedPathBeats.has(id)
|
||||
? mediaLedPathBeats.has(id)
|
||||
? Math.max(mediaPresentationSec, pathDurationSec)
|
||||
: mediaPresentationSec + pathDurationSec
|
||||
: mediaPresentationSec;
|
||||
runtimeControlsByBeat[id] = {
|
||||
...runtimeControlsByBeat[id],
|
||||
minimumHoldSec:
|
||||
id === 'mailung-bazzar'
|
||||
? mediaAndPathHoldSec
|
||||
: Math.max(
|
||||
Number(runtimeControlsByBeat[id].minimumHoldSec) || 0,
|
||||
mediaAndPathHoldSec,
|
||||
),
|
||||
};
|
||||
}
|
||||
// Keep the downstream sequence on its sourced river profile. Coarse Google
|
||||
// mesh samples can contain cliff-height spikes that reverse apparent motion.
|
||||
// Preserve each authored readable hold after the media-first timing floor.
|
||||
for (const [id, seconds, sequenceSeconds = seconds] of [
|
||||
['immediate-collapse-viewpoint', 10],
|
||||
['gyirong-border-gate', 8, 2.5],
|
||||
['timure-cluster', 12],
|
||||
['syabru-besi', 11],
|
||||
]) {
|
||||
runtimeControlsByBeat[id] = {
|
||||
...runtimeControlsByBeat[id],
|
||||
evidenceHoldCard: true,
|
||||
evidenceSequenceDurationSec: sequenceSeconds,
|
||||
minimumHoldSec: Math.max(
|
||||
Number(runtimeControlsByBeat[id].minimumHoldSec) || 0,
|
||||
seconds,
|
||||
),
|
||||
};
|
||||
}
|
||||
const finalView = byBeat.get('final-view');
|
||||
return Object.freeze({
|
||||
...base,
|
||||
version: 18,
|
||||
cameraPath,
|
||||
runtimeControlsByBeat,
|
||||
// Clip trims must also replace older saved holds, not just lower a floor.
|
||||
runtimeHoldSecByBeat: { 'mailung-bazzar': 7 + 0.65 },
|
||||
expansionFromVersion: 17,
|
||||
previousRequiredSourcePackBeatIds: EVIDENCE_ORDER.slice(0, -1),
|
||||
previousRequiredSourcePackBeatIdVariants: [
|
||||
base.requiredSourcePackBeatIds,
|
||||
EVIDENCE_ORDER.slice(0, -1),
|
||||
],
|
||||
requiredSourcePackBeatIds: EVIDENCE_ORDER,
|
||||
requiredShotTitles: [
|
||||
...base.requiredShotTitles.slice(0, -base.cameraPath.length),
|
||||
...cameraPath.map(({ title }) => title),
|
||||
],
|
||||
adoptExistingShotTitles: ['Final view'],
|
||||
shotPatches: [
|
||||
...(base.shotPatches || []),
|
||||
{
|
||||
title: 'Final view',
|
||||
layers: finalView.layers,
|
||||
},
|
||||
],
|
||||
photorealSurfaceBeatIds: EVIDENCE_ORDER,
|
||||
});
|
||||
}
|
||||
+603
-1
@@ -3,7 +3,594 @@
|
||||
* Each recipe is deterministic so repeated runs produce similar footage.
|
||||
*/
|
||||
|
||||
export const SCENE_RECIPES = [
|
||||
import { expandNepalEvidencePack } from './nepalEvidencePack.js';
|
||||
|
||||
const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
|
||||
id: 'bhote-koshi-nepal-evidence-pack',
|
||||
version: 12,
|
||||
title: 'Bhote Koshi Evidence Sequence',
|
||||
style: 'normal',
|
||||
ui: { hidePanels: false, hudMode: 'full', safeFrame: '16:9' },
|
||||
layers: {
|
||||
'bhote-koshi-locator': false,
|
||||
},
|
||||
releaseLayerIds: ['bhote-koshi-2026', 'bhote-koshi-locator'],
|
||||
post: {
|
||||
bloom: 0,
|
||||
sharpen: true,
|
||||
detectionMode: 'OFF',
|
||||
mapStack: 'photoreal',
|
||||
},
|
||||
legacySceneBootstrap: {
|
||||
targetSceneTitle: 'Nepal Flood Incident',
|
||||
fromShotTitles: ['Shot 1', 'Shot 2', 'Shot 3'],
|
||||
cameraPath: [
|
||||
{
|
||||
title: 'Global Incident Context',
|
||||
lat: 27.5962,
|
||||
lon: 78.5718,
|
||||
alt: 15628002,
|
||||
heading: 0,
|
||||
pitch: -90,
|
||||
roll: 0,
|
||||
duration: 4,
|
||||
hold: 0.9,
|
||||
layers: {
|
||||
'bhote-koshi-locator': {
|
||||
enabled: true,
|
||||
params: { presentation: 'nepal-context' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Nepal-Focused Globe Rotation',
|
||||
lat: 28.7248,
|
||||
lon: 81.3226,
|
||||
alt: 2068966,
|
||||
heading: 0,
|
||||
pitch: -90,
|
||||
roll: 0,
|
||||
duration: 4,
|
||||
hold: 0.9,
|
||||
layers: {
|
||||
'bhote-koshi-locator': {
|
||||
enabled: true,
|
||||
params: { presentation: 'nepal-context' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Bhote Koshi Regional Approach',
|
||||
lat: 27.0354,
|
||||
lon: 84.6287,
|
||||
alt: 809543,
|
||||
heading: 0,
|
||||
pitch: -90,
|
||||
roll: 0,
|
||||
duration: 4,
|
||||
hold: 0.9,
|
||||
layers: {
|
||||
'bhote-koshi-locator': {
|
||||
enabled: true,
|
||||
params: { presentation: 'bhote-koshi-regional' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Bhote Koshi Nearby Cities',
|
||||
lat: 27.891,
|
||||
lon: 85.428,
|
||||
alt: 225734,
|
||||
heading: 0,
|
||||
pitch: -90,
|
||||
roll: 0,
|
||||
duration: 4,
|
||||
hold: 0.9,
|
||||
layers: {
|
||||
'bhote-koshi-locator': {
|
||||
enabled: true,
|
||||
params: { presentation: 'bhote-koshi-city-context' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Bhote Koshi Incident Corridor',
|
||||
lat: 28.0529,
|
||||
lon: 85.2189,
|
||||
alt: 126931,
|
||||
heading: 0,
|
||||
pitch: -90,
|
||||
roll: 0,
|
||||
duration: 4,
|
||||
hold: 11,
|
||||
layers: {
|
||||
'bhote-koshi-locator': {
|
||||
enabled: true,
|
||||
params: { presentation: 'bhote-koshi-incident-places' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Bhote Koshi Flood Path',
|
||||
lat: 28.1691,
|
||||
lon: 85.352,
|
||||
alt: 60328,
|
||||
heading: 0,
|
||||
pitch: -90,
|
||||
roll: 0,
|
||||
duration: 4,
|
||||
hold: 0.9,
|
||||
layers: {
|
||||
'bhote-koshi-locator': {
|
||||
enabled: true,
|
||||
params: { presentation: 'bhote-koshi-flood-path' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Bhote Koshi Corridor Overview',
|
||||
lat: 27.8986,
|
||||
lon: 84.8723,
|
||||
alt: 53247,
|
||||
heading: 60,
|
||||
pitch: -48,
|
||||
roll: 0,
|
||||
duration: 4,
|
||||
hold: 0.9,
|
||||
layers: {
|
||||
'bhote-koshi-locator': {
|
||||
enabled: true,
|
||||
params: { presentation: 'bhote-koshi-path-overview' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Bhote Koshi Upper Valley',
|
||||
lat: 28.4187,
|
||||
lon: 85.4341,
|
||||
alt: 7977,
|
||||
heading: 155,
|
||||
pitch: -28,
|
||||
roll: 0,
|
||||
duration: 4,
|
||||
hold: 8,
|
||||
layers: {
|
||||
'bhote-koshi-locator': {
|
||||
enabled: true,
|
||||
params: { presentation: 'bhote-koshi-trigger-record' },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
requiredShotTitles: [
|
||||
'Global Incident Context',
|
||||
'Nepal-Focused Globe Rotation',
|
||||
'Bhote Koshi Regional Approach',
|
||||
'Bhote Koshi Nearby Cities',
|
||||
'Bhote Koshi Incident Corridor',
|
||||
'Bhote Koshi Flood Path',
|
||||
'Bhote Koshi Corridor Overview',
|
||||
'Bhote Koshi Upper Valley',
|
||||
'Upper Valley Collapse',
|
||||
'Debris-Dammed Lake',
|
||||
'Nepal-China Border Gate',
|
||||
'Timure Evidence Cluster',
|
||||
'Syabru Besi Passage',
|
||||
'Bidur / Trishuli Consequence',
|
||||
],
|
||||
requiredSourcePackBeatIds: [
|
||||
'immediate-collapse-viewpoint',
|
||||
'debris-dammed-lake',
|
||||
'gyirong-border-gate',
|
||||
'timure-cluster',
|
||||
'syabru-besi',
|
||||
'bidur-trishuli-bridge',
|
||||
],
|
||||
requiredSourcePackLayerId: 'bhote-koshi-2026',
|
||||
photorealSurfaceBeatIds: [
|
||||
'gyirong-border-gate',
|
||||
'timure-cluster',
|
||||
'syabru-besi',
|
||||
'bidur-trishuli-bridge',
|
||||
],
|
||||
runtimeControlsByBeat: {
|
||||
'immediate-collapse-viewpoint': { evidenceMediaAutoplay: true },
|
||||
'debris-dammed-lake': { evidenceMediaAutoplay: true },
|
||||
'gyirong-border-gate': {
|
||||
imageryComparison: true,
|
||||
evidenceSequence: true,
|
||||
evidenceSequenceDurationSec: 2.5,
|
||||
deferEvidenceUntilCameraSettled: true,
|
||||
evidenceRevealDurationSec: 2.5,
|
||||
},
|
||||
'timure-cluster': {
|
||||
imageryComparison: true,
|
||||
evidenceSequence: true,
|
||||
evidenceSequenceDurationSec: 4,
|
||||
},
|
||||
'syabru-besi': {
|
||||
imageryComparison: true,
|
||||
evidenceSequence: true,
|
||||
evidenceSequenceDurationSec: 4,
|
||||
},
|
||||
'bidur-trishuli-bridge': {
|
||||
evidenceSequence: true,
|
||||
evidenceSequenceDurationSec: 4,
|
||||
},
|
||||
},
|
||||
shotPatches: [
|
||||
{
|
||||
title: 'Global Incident Context',
|
||||
visual: { mapStack: 'photoreal' },
|
||||
layers: {
|
||||
'bhote-koshi-2026': {
|
||||
enabled: true,
|
||||
params: {
|
||||
presentation: 'scene-beat',
|
||||
beatId: 'immediate-collapse-viewpoint',
|
||||
beatReveal: 0.36,
|
||||
split: 0.5,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Nepal-Focused Globe Rotation',
|
||||
visual: { mapStack: 'photoreal' },
|
||||
layers: {
|
||||
'bhote-koshi-2026': {
|
||||
enabled: true,
|
||||
params: {
|
||||
presentation: 'scene-beat',
|
||||
beatId: 'debris-dammed-lake',
|
||||
beatReveal: 0.36,
|
||||
split: 0.5,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Bhote Koshi Regional Approach',
|
||||
visual: { mapStack: 'photoreal' },
|
||||
layers: {
|
||||
'bhote-koshi-2026': {
|
||||
enabled: true,
|
||||
params: {
|
||||
presentation: 'scene-beat',
|
||||
beatId: 'gyirong-border-gate',
|
||||
beatReveal: 0.36,
|
||||
split: 0.5,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Bhote Koshi Nearby Cities',
|
||||
visual: { mapStack: 'photoreal' },
|
||||
layers: {
|
||||
'bhote-koshi-2026': {
|
||||
enabled: true,
|
||||
params: {
|
||||
presentation: 'scene-beat',
|
||||
beatId: 'timure-cluster',
|
||||
beatReveal: 0.36,
|
||||
split: 0.5,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Bhote Koshi Incident Corridor',
|
||||
camera: {
|
||||
lat: 28.0529,
|
||||
lon: 85.2189,
|
||||
alt: 126931,
|
||||
heading: 0,
|
||||
pitch: -90,
|
||||
roll: 0,
|
||||
},
|
||||
holdSec: 11,
|
||||
visual: { mapStack: 'photoreal' },
|
||||
layers: {
|
||||
'bhote-koshi-2026': {
|
||||
enabled: true,
|
||||
params: {
|
||||
presentation: 'scene-beat',
|
||||
beatId: 'syabru-besi',
|
||||
beatReveal: 0.36,
|
||||
split: 0.5,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Bhote Koshi Flood Path',
|
||||
visual: { mapStack: 'photoreal' },
|
||||
layers: {
|
||||
'bhote-koshi-2026': {
|
||||
enabled: true,
|
||||
params: {
|
||||
presentation: 'scene-beat',
|
||||
beatId: 'bidur-trishuli-bridge',
|
||||
beatReveal: 0.36,
|
||||
split: 0.5,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Bhote Koshi Corridor Overview',
|
||||
visual: { mapStack: 'photoreal' },
|
||||
layers: {
|
||||
'bhote-koshi-2026': {
|
||||
enabled: true,
|
||||
params: {
|
||||
presentation: 'scene-beat',
|
||||
beatId: 'gyirong-border-gate',
|
||||
beatReveal: 0.36,
|
||||
split: 0.5,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Bhote Koshi Upper Valley',
|
||||
holdSec: 8,
|
||||
visual: { mapStack: 'photoreal' },
|
||||
layers: {
|
||||
'bhote-koshi-2026': {
|
||||
enabled: true,
|
||||
params: {
|
||||
presentation: 'scene-beat',
|
||||
beatId: 'immediate-collapse-viewpoint',
|
||||
beatReveal: 0.36,
|
||||
split: 0.5,
|
||||
},
|
||||
},
|
||||
'bhote-koshi-locator': {
|
||||
enabled: true,
|
||||
params: {
|
||||
presentation: 'bhote-koshi-trigger-record',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Upper Valley Collapse',
|
||||
camera: {
|
||||
lat: 28.417,
|
||||
lon: 85.3937,
|
||||
alt: 9850,
|
||||
heading: 146,
|
||||
pitch: -34,
|
||||
roll: 0,
|
||||
},
|
||||
layers: {
|
||||
'bhote-koshi-2026': {
|
||||
enabled: true,
|
||||
params: {
|
||||
presentation: 'scene-beat',
|
||||
beatId: 'immediate-collapse-viewpoint',
|
||||
beatReveal: 0.36,
|
||||
split: 0.5,
|
||||
sceneControls: { evidenceMediaAutoplay: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Nepal-China Border Gate',
|
||||
visual: { mapStack: 'photoreal' },
|
||||
},
|
||||
{
|
||||
title: 'Timure Evidence Cluster',
|
||||
visual: { mapStack: 'photoreal' },
|
||||
},
|
||||
{
|
||||
title: 'Syabru Besi Passage',
|
||||
visual: { mapStack: 'photoreal' },
|
||||
},
|
||||
{
|
||||
title: 'Bidur / Trishuli Consequence',
|
||||
visual: { mapStack: 'photoreal' },
|
||||
},
|
||||
],
|
||||
cameraPath: [
|
||||
{
|
||||
title: 'Upper Valley Collapse',
|
||||
lat: 28.417,
|
||||
lon: 85.3937,
|
||||
alt: 9850,
|
||||
heading: 146,
|
||||
pitch: -34,
|
||||
roll: 0,
|
||||
duration: 4.2,
|
||||
hold: 1.4,
|
||||
layers: {
|
||||
'bhote-koshi-2026': {
|
||||
enabled: true,
|
||||
params: {
|
||||
presentation: 'scene-beat',
|
||||
beatId: 'immediate-collapse-viewpoint',
|
||||
beatReveal: 0.36,
|
||||
split: 0.5,
|
||||
sceneControls: { evidenceMediaAutoplay: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Debris-Dammed Lake',
|
||||
lat: 28.392914,
|
||||
lon: 85.570921,
|
||||
alt: 10744,
|
||||
heading: 232,
|
||||
pitch: -37,
|
||||
roll: 0,
|
||||
duration: 4.2,
|
||||
hold: 1.4,
|
||||
layers: {
|
||||
'bhote-koshi-2026': {
|
||||
enabled: true,
|
||||
params: {
|
||||
presentation: 'scene-beat',
|
||||
beatId: 'debris-dammed-lake',
|
||||
beatReveal: 0.36,
|
||||
split: 0.5,
|
||||
sceneControls: { evidenceMediaAutoplay: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Nepal-China Border Gate',
|
||||
mapStack: 'photoreal',
|
||||
lat: 28.316789,
|
||||
lon: 85.399734,
|
||||
alt: 5900,
|
||||
heading: 208,
|
||||
pitch: -42,
|
||||
roll: 0,
|
||||
duration: 4.2,
|
||||
hold: 2.5,
|
||||
layers: {
|
||||
'bhote-koshi-2026': {
|
||||
enabled: true,
|
||||
params: {
|
||||
presentation: 'scene-beat',
|
||||
beatId: 'gyirong-border-gate',
|
||||
beatReveal: 0.36,
|
||||
split: 0.5,
|
||||
sceneControls: {
|
||||
imageryComparison: true,
|
||||
evidenceSequence: true,
|
||||
evidenceSequenceDurationSec: 2.5,
|
||||
deferEvidenceUntilCameraSettled: true,
|
||||
evidenceRevealDurationSec: 2.5,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Timure Evidence Cluster',
|
||||
mapStack: 'photoreal',
|
||||
lat: 28.285968,
|
||||
lon: 85.379136,
|
||||
alt: 5032,
|
||||
heading: 198,
|
||||
pitch: -40,
|
||||
roll: 0,
|
||||
duration: 4.2,
|
||||
hold: 4,
|
||||
layers: {
|
||||
'bhote-koshi-2026': {
|
||||
enabled: true,
|
||||
params: {
|
||||
presentation: 'scene-beat',
|
||||
beatId: 'timure-cluster',
|
||||
beatReveal: 0.36,
|
||||
split: 0.5,
|
||||
sceneControls: {
|
||||
imageryComparison: true,
|
||||
evidenceSequence: true,
|
||||
evidenceSequenceDurationSec: 4,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Syabru Besi Passage',
|
||||
mapStack: 'photoreal',
|
||||
lat: 28.210306,
|
||||
lon: 85.361527,
|
||||
alt: 5893,
|
||||
heading: 205,
|
||||
pitch: -38,
|
||||
roll: 0,
|
||||
duration: 4.2,
|
||||
hold: 4,
|
||||
layers: {
|
||||
'bhote-koshi-2026': {
|
||||
enabled: true,
|
||||
params: {
|
||||
presentation: 'scene-beat',
|
||||
beatId: 'syabru-besi',
|
||||
beatReveal: 0.36,
|
||||
split: 0.5,
|
||||
sceneControls: {
|
||||
imageryComparison: true,
|
||||
evidenceSequence: true,
|
||||
evidenceSequenceDurationSec: 4,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Bidur / Trishuli Consequence',
|
||||
mapStack: 'photoreal',
|
||||
lat: 27.981737,
|
||||
lon: 85.196273,
|
||||
alt: 7422,
|
||||
heading: 215,
|
||||
pitch: -40,
|
||||
roll: 0,
|
||||
duration: 4.2,
|
||||
hold: 4,
|
||||
layers: {
|
||||
'bhote-koshi-2026': {
|
||||
enabled: true,
|
||||
params: {
|
||||
presentation: 'scene-beat',
|
||||
beatId: 'bidur-trishuli-bridge',
|
||||
beatReveal: 0.36,
|
||||
split: 0.5,
|
||||
sceneControls: {
|
||||
evidenceSequence: true,
|
||||
evidenceSequenceDurationSec: 4,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const BHOTE_KOSHI_NEPAL_APPEND_RECIPE = BHOTE_KOSHI_NEPAL_BASE_RECIPE
|
||||
? expandNepalEvidencePack(BHOTE_KOSHI_NEPAL_BASE_RECIPE)
|
||||
: null;
|
||||
|
||||
// Fresh browser origins have no legacy three-shot Nepal project for the
|
||||
// append recipe to upgrade. Seed that exact legacy shape so SceneDirector can
|
||||
// run the same idempotent bootstrap used by existing browser-saved projects.
|
||||
const BHOTE_KOSHI_NEPAL_BOOTSTRAP_RECIPE = BHOTE_KOSHI_NEPAL_APPEND_RECIPE
|
||||
? Object.freeze({
|
||||
id: 'bhote-koshi-nepal-scene',
|
||||
title: 'Nepal Flood Incident',
|
||||
durationSec: 15,
|
||||
style: BHOTE_KOSHI_NEPAL_APPEND_RECIPE.style,
|
||||
ui: BHOTE_KOSHI_NEPAL_APPEND_RECIPE.ui,
|
||||
layers: BHOTE_KOSHI_NEPAL_APPEND_RECIPE.layers,
|
||||
releaseLayerIds: BHOTE_KOSHI_NEPAL_APPEND_RECIPE.releaseLayerIds,
|
||||
post: BHOTE_KOSHI_NEPAL_APPEND_RECIPE.post,
|
||||
installAlongsideSceneId: 'bhote-koshi-flood',
|
||||
installAlongsideFallbackSceneId: 'flights-radar',
|
||||
cameraPath:
|
||||
BHOTE_KOSHI_NEPAL_APPEND_RECIPE.legacySceneBootstrap.cameraPath
|
||||
.slice(0, 3)
|
||||
.map((keyframe, index) => ({
|
||||
...keyframe,
|
||||
title: `Shot ${index + 1}`,
|
||||
})),
|
||||
})
|
||||
: null;
|
||||
|
||||
const EVENT_RECIPES = [BHOTE_KOSHI_NEPAL_BOOTSTRAP_RECIPE].filter(Boolean);
|
||||
|
||||
const PUBLIC_SCENE_RECIPES = [
|
||||
{
|
||||
id: 'flights-radar',
|
||||
title: 'Global Flights Radar',
|
||||
@@ -377,6 +964,21 @@ export const SCENE_RECIPES = [
|
||||
},
|
||||
];
|
||||
|
||||
/** Build the recipe list without mutating stored user-authored projects. */
|
||||
export function createSceneRecipes({ localDemoRecipes = EVENT_RECIPES } = {}) {
|
||||
return [...localDemoRecipes, ...PUBLIC_SCENE_RECIPES];
|
||||
}
|
||||
|
||||
export const SCENE_RECIPES = createSceneRecipes();
|
||||
|
||||
export const SCENE_APPEND_RECIPES = BHOTE_KOSHI_NEPAL_APPEND_RECIPE
|
||||
? [BHOTE_KOSHI_NEPAL_APPEND_RECIPE]
|
||||
: [];
|
||||
|
||||
export function getSceneRecipeById(id) {
|
||||
return SCENE_RECIPES.find((recipe) => recipe.id === id) || null;
|
||||
}
|
||||
|
||||
export function getSceneAppendRecipeById(id) {
|
||||
return SCENE_APPEND_RECIPES.find((recipe) => recipe.id === id) || null;
|
||||
}
|
||||
@@ -41,6 +41,7 @@ function sweepLayerParamKeys() {
|
||||
|
||||
/** The layer registry as main.js builds it (src/main.js dataManager.register calls). */
|
||||
const REGISTERED = new Set([
|
||||
'bhote-koshi-2026', 'bhote-koshi-locator',
|
||||
'flights', 'military', 'earthquakes', 'satellites', 'rocket-launches', 'traffic',
|
||||
'cctv', 'radio', 'bikeshare', 'ais-live-vessels', 'military-installations',
|
||||
'military-awareness', 'local-datacenters', 'local-dams',
|
||||
|
||||
@@ -66,7 +66,7 @@ test('share links parse explicit celestial on and off states', () => {
|
||||
});
|
||||
|
||||
test('unknown-only v2 layer tokens are invalid, while historical l fields stay inert', () => {
|
||||
const invalid = makeManager('#v=2&lat=10&lon=20&l=z').parseInitialHash();
|
||||
const invalid = makeManager('#v=2&lat=10&lon=20&l=unknown').parseInitialHash();
|
||||
assert.equal(invalid.layerState, null);
|
||||
assert.equal(invalid.layerStateInvalid, true);
|
||||
for (const hash of ['#lat=10&lon=20&l=z', '#v=1&lat=10&lon=20&l=z']) {
|
||||
@@ -76,6 +76,12 @@ test('unknown-only v2 layer tokens are invalid, while historical l fields stay i
|
||||
}
|
||||
});
|
||||
|
||||
test('Nepal locator token is valid in v2 share links', () => {
|
||||
const parsed = makeManager('#v=2&lat=10&lon=20&l=z').parseInitialHash();
|
||||
assert.deepEqual(parsed.layerState.enabledLayerIds, ['bhote-koshi-locator']);
|
||||
assert.equal(parsed.layerStateInvalid, false);
|
||||
});
|
||||
|
||||
test('share-link serialization emits the current celestial state', () => {
|
||||
const manager = makeManager();
|
||||
manager.onToggleChange(false, false, { celestialRingEnabled: false });
|
||||
|
||||
@@ -43,6 +43,12 @@ export function createStandaloneApplication({
|
||||
loaderStatus,
|
||||
});
|
||||
catalog = createStandaloneCatalog({
|
||||
nepalBoundaryResolver: (signal) =>
|
||||
scene.operations.annotationResolver.resolveRegionRingForQuery(
|
||||
'Nepal',
|
||||
signal,
|
||||
placeSearch,
|
||||
),
|
||||
signal: context.signal,
|
||||
surface: scene.operations.surface,
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ export { createStandaloneReferenceSources } from './layerSources.js';
|
||||
|
||||
/** Create fresh layer instances using the existing standalone source choices. */
|
||||
export function createStandaloneCatalog({
|
||||
nepalBoundaryResolver,
|
||||
signal = new AbortController().signal,
|
||||
surface = createSurfaceServices({
|
||||
terrainSource: createApplicationRequestServices().terrain,
|
||||
@@ -13,6 +14,7 @@ export function createStandaloneCatalog({
|
||||
}),
|
||||
} = {}) {
|
||||
return createApplicationCatalog({
|
||||
nepalBoundaryResolver,
|
||||
surface,
|
||||
sources: createStandaloneLayerSources(),
|
||||
signal,
|
||||
|
||||
@@ -25,6 +25,11 @@ function fixture() {
|
||||
appendChild(element) {
|
||||
this.children.push(element);
|
||||
}
|
||||
setAttribute(name, value) {
|
||||
this[name] = value;
|
||||
}
|
||||
focus() {}
|
||||
select() {}
|
||||
set textContent(value) {
|
||||
this._text = value;
|
||||
this.children = [];
|
||||
@@ -164,6 +169,8 @@ test('Scene controls dispatch explicit actions and revoke replaced shot-row list
|
||||
f.elements.start.click();
|
||||
label.click();
|
||||
label.dispatchEvent(new Event('dblclick'));
|
||||
label.children[0].value = 'Renamed';
|
||||
label.children[0].dispatchEvent(new Event('blur'));
|
||||
load.click();
|
||||
assert.deepEqual(f.calls, [
|
||||
['capture'],
|
||||
@@ -343,6 +350,8 @@ test('the real director preserves a selected shot label for the following double
|
||||
label.click();
|
||||
assert.equal(f.elements.shots.children[0].children[0].children[0], label);
|
||||
label.dispatchEvent(new Event('dblclick'));
|
||||
label.children[0].value = 'Renamed';
|
||||
label.children[0].dispatchEvent(new Event('blur'));
|
||||
assert.equal(director._getSelectedScene().shots[0].title, 'Renamed');
|
||||
} finally {
|
||||
await director?.destroy();
|
||||
@@ -351,6 +360,35 @@ test('the real director preserves a selected shot label for the following double
|
||||
}
|
||||
});
|
||||
|
||||
test('inline shot names save on Enter, cancel on Escape, and reject blank names', () => {
|
||||
const f = fixture();
|
||||
const key = (name) => Object.assign(new Event('keydown'), { key: name });
|
||||
try {
|
||||
const label = f.elements.shots.children[0].children[0].children[0];
|
||||
for (const [value, commitKey] of [
|
||||
['Cancelled', 'Escape'],
|
||||
[' ', 'Enter'],
|
||||
[' New name ', 'Enter'],
|
||||
]) {
|
||||
label.dispatchEvent(new Event('dblclick'));
|
||||
const input = label.children[0];
|
||||
input.value = value;
|
||||
input.dispatchEvent(key(commitKey));
|
||||
input.dispatchEvent(new Event('blur'));
|
||||
}
|
||||
assert.deepEqual(f.calls, [
|
||||
['renameShot', 'scene-a', 'shot-a', 'New name'],
|
||||
]);
|
||||
assert.equal(
|
||||
label.textContent,
|
||||
'<Shot A>',
|
||||
'the model owns the next render',
|
||||
);
|
||||
} finally {
|
||||
f.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('Scene controls consume current state, preserve rows on progress, and unsubscribe on destruction', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
|
||||
@@ -61,10 +61,36 @@ export function renderSceneShots(
|
||||
const label = document.createElement('div');
|
||||
label.className = 'scene-shot-label';
|
||||
label.textContent = shot.title;
|
||||
label.title = 'Double-click to rename';
|
||||
listen(label, 'click', () => select(shot.id));
|
||||
listen(label, 'dblclick', () => {
|
||||
const title = window.prompt('Shot title', shot.title);
|
||||
if (title) rename(scene.id, shot.id, title);
|
||||
if (label.children.length) return;
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.className = 'scene-shot-rename';
|
||||
input.setAttribute('aria-label', 'Shot name');
|
||||
input.value = shot.title;
|
||||
let finished = false;
|
||||
const finish = (save) => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
const title = input.value.trim();
|
||||
label.textContent = shot.title;
|
||||
if (save && title && title !== shot.title)
|
||||
rename(scene.id, shot.id, title);
|
||||
};
|
||||
listen(input, 'keydown', (event) => {
|
||||
event.stopPropagation();
|
||||
if (event.key === 'Enter' || event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
finish(event.key === 'Enter');
|
||||
}
|
||||
});
|
||||
listen(input, 'blur', () => finish(true));
|
||||
label.textContent = '';
|
||||
label.appendChild(input);
|
||||
input.focus();
|
||||
input.select();
|
||||
});
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'scene-shot-actions';
|
||||
|
||||
@@ -0,0 +1,613 @@
|
||||
/* Bhote Koshi 2026 event reconstruction */
|
||||
#bhote-koshi-event-panel {
|
||||
position: fixed;
|
||||
top: var(--right-stack-safe-top, 26vh);
|
||||
right: var(--right-rail-x);
|
||||
bottom: auto;
|
||||
z-index: 142;
|
||||
width: min(330px, calc(100vw - 40px));
|
||||
max-height: calc(100vh - var(--right-stack-safe-top, 26vh) - 88px);
|
||||
overflow-y: auto;
|
||||
color: var(--text-primary);
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 18px 52px rgba(0, 0, 0, 0.48), inset 0 0 28px rgba(14, 78, 92, 0.12);
|
||||
backdrop-filter: blur(24px) saturate(1.4);
|
||||
font-family: var(--font-mono);
|
||||
letter-spacing: 0;
|
||||
pointer-events: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
#bhote-koshi-event-panel::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#bhote-koshi-event-panel [hidden],
|
||||
#bhote-koshi-split-line[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
#bhote-koshi-embedded-media-root {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 7;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.bhote-embedded-media-warmup {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
clip-path: inset(50%);
|
||||
}
|
||||
|
||||
.bhote-embedded-callout {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
width: min(356px, calc(100% - 36px));
|
||||
max-height: min(270px, calc(100% - 36px));
|
||||
overflow: visible;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.bhote-embedded-callout[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.bhote-embedded-callout-leader {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 0;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: visible;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.bhote-embedded-callout-leader circle {
|
||||
opacity: 0;
|
||||
transform: scale(0.2);
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.bhote-embedded-callout-leader path {
|
||||
opacity: 0;
|
||||
stroke-dasharray: 1;
|
||||
stroke-dashoffset: 1;
|
||||
}
|
||||
|
||||
.bhote-embedded-callout-body {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
overflow: hidden;
|
||||
background: rgba(5, 13, 19, 0.96);
|
||||
border: 1px solid rgba(107, 232, 255, 0.32);
|
||||
box-shadow: 0 18px 46px rgba(0, 0, 0, 0.56);
|
||||
opacity: 0;
|
||||
transform: translateY(8px) scale(0.98);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.bhote-embedded-callout-player {
|
||||
width: 100%;
|
||||
height: min(200px, 34vh);
|
||||
aspect-ratio: 16 / 9;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: #03080d;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(107, 232, 255, 0.36) transparent;
|
||||
}
|
||||
|
||||
.bhote-embedded-callout.is-youtube {
|
||||
width: min(280px, calc(100% - 36px));
|
||||
max-height: min(228px, calc(100% - 36px));
|
||||
}
|
||||
|
||||
.bhote-embedded-callout.is-youtube .bhote-embedded-callout-player {
|
||||
height: min(158px, 27vh);
|
||||
}
|
||||
|
||||
.bhote-embedded-callout.is-portrait {
|
||||
width: min(214px, calc(100% - 36px));
|
||||
max-height: min(390px, calc(100% - 36px));
|
||||
}
|
||||
|
||||
.bhote-embedded-callout.is-portrait .bhote-embedded-callout-player {
|
||||
width: 100%;
|
||||
height: min(320px, 48vh);
|
||||
aspect-ratio: 9 / 16;
|
||||
}
|
||||
|
||||
.bhote-embedded-callout.is-entering .bhote-embedded-callout-leader circle {
|
||||
animation: bhote-embedded-anchor-enter 240ms ease-out both;
|
||||
}
|
||||
|
||||
.bhote-embedded-callout.is-entering .bhote-embedded-callout-leader path {
|
||||
animation: bhote-embedded-leader-enter 420ms 150ms ease-out both;
|
||||
}
|
||||
|
||||
.bhote-embedded-callout.is-entering .bhote-embedded-callout-body {
|
||||
animation: bhote-embedded-body-enter 420ms 510ms ease-out both;
|
||||
}
|
||||
|
||||
.bhote-embedded-callout.is-leaving .bhote-embedded-callout-body {
|
||||
animation: bhote-embedded-body-exit 240ms ease-in both;
|
||||
}
|
||||
|
||||
.bhote-embedded-callout.is-leaving .bhote-embedded-callout-leader path {
|
||||
animation: bhote-embedded-leader-exit 300ms 190ms ease-in both;
|
||||
}
|
||||
|
||||
.bhote-embedded-callout.is-leaving .bhote-embedded-callout-leader circle {
|
||||
animation: bhote-embedded-anchor-exit 160ms 460ms ease-in both;
|
||||
}
|
||||
|
||||
@keyframes bhote-embedded-anchor-enter {
|
||||
from { opacity: 0; transform: scale(0.2); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
@keyframes bhote-embedded-leader-enter {
|
||||
from { opacity: 0; stroke-dashoffset: 1; }
|
||||
to { opacity: 1; stroke-dashoffset: 0; }
|
||||
}
|
||||
|
||||
@keyframes bhote-embedded-body-enter {
|
||||
from { opacity: 0; transform: translateY(8px) scale(0.98); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
@keyframes bhote-embedded-body-exit {
|
||||
from { opacity: 1; transform: translateY(0) scale(1); }
|
||||
to { opacity: 0; transform: translateY(6px) scale(0.985); }
|
||||
}
|
||||
|
||||
@keyframes bhote-embedded-leader-exit {
|
||||
from { opacity: 1; stroke-dashoffset: 0; }
|
||||
to { opacity: 0; stroke-dashoffset: 1; }
|
||||
}
|
||||
|
||||
@keyframes bhote-embedded-anchor-exit {
|
||||
from { opacity: 1; transform: scale(1); }
|
||||
to { opacity: 0; transform: scale(0.2); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.bhote-embedded-callout.is-entering .bhote-embedded-callout-leader circle,
|
||||
.bhote-embedded-callout.is-entering .bhote-embedded-callout-leader path,
|
||||
.bhote-embedded-callout.is-entering .bhote-embedded-callout-body {
|
||||
animation: none;
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.bhote-embedded-callout.is-x .bhote-embedded-callout-player {
|
||||
max-height: min(200px, 34vh);
|
||||
aspect-ratio: auto;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.bhote-embedded-callout:not(.is-x) .bhote-embedded-callout-player iframe {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.bhote-facebook-player-host {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.bhote-embedded-callout.is-x .bhote-embedded-callout-player iframe {
|
||||
display: block;
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
min-height: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.bhote-embedded-callout.is-x .bhote-embedded-callout-player > * {
|
||||
margin: 0 auto !important;
|
||||
}
|
||||
|
||||
.bhote-embedded-callout.is-unavailable .bhote-embedded-callout-player {
|
||||
min-height: 92px;
|
||||
background: repeating-linear-gradient(
|
||||
135deg,
|
||||
rgba(107, 232, 255, 0.06),
|
||||
rgba(107, 232, 255, 0.06) 12px,
|
||||
rgba(3, 8, 13, 0.92) 12px,
|
||||
rgba(3, 8, 13, 0.92) 24px
|
||||
);
|
||||
}
|
||||
|
||||
.bhote-embedded-callout footer {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 2px 8px;
|
||||
padding: 7px 9px 8px;
|
||||
border-top: 3px solid #6be8ff;
|
||||
}
|
||||
|
||||
.bhote-embedded-callout footer strong {
|
||||
overflow: hidden;
|
||||
font-size: 11px;
|
||||
line-height: 1.25;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bhote-embedded-callout footer small {
|
||||
grid-column: 1;
|
||||
color: rgba(151, 211, 224, 0.7);
|
||||
font-size: 8px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.bhote-embedded-callout footer a {
|
||||
grid-column: 2;
|
||||
grid-row: 1 / span 2;
|
||||
align-self: center;
|
||||
color: #6be8ff;
|
||||
font-size: 8px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.bhote-embedded-callout footer a:hover,
|
||||
.bhote-embedded-callout footer a:focus-visible {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
#right-context-rail.bhote-event-active > :not(#bhote-koshi-event-panel) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.bhote-event-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding: 15px 16px 12px;
|
||||
border-bottom: 1px solid rgba(95, 213, 236, 0.18);
|
||||
}
|
||||
|
||||
.bhote-event-kicker,
|
||||
.bhote-event-section-head,
|
||||
.bhote-event-status,
|
||||
.bhote-event-evidence summary,
|
||||
.bhote-event-report small {
|
||||
font-size: 9px;
|
||||
line-height: 1.35;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.bhote-event-title {
|
||||
margin-top: 5px;
|
||||
font-size: 17px;
|
||||
line-height: 1.2;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.bhote-event-icon-btn {
|
||||
flex: 0 0 30px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(151, 211, 224, 0.26);
|
||||
border-radius: 4px;
|
||||
color: var(--text-primary);
|
||||
background: var(--accent-dim);
|
||||
font: inherit;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bhote-event-status-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 11px 16px 0;
|
||||
}
|
||||
|
||||
.bhote-event-status {
|
||||
padding: 4px 7px;
|
||||
border: 1px solid rgba(80, 206, 231, 0.24);
|
||||
border-radius: 3px;
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
|
||||
.bhote-event-status.reconstructed {
|
||||
color: var(--accent);
|
||||
border-color: rgba(80, 206, 231, 0.24);
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
|
||||
.bhote-event-section {
|
||||
padding: 13px 16px 0;
|
||||
}
|
||||
|
||||
.bhote-event-section-head,
|
||||
.bhote-event-dates,
|
||||
.bhote-event-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.bhote-event-section-head span:last-child {
|
||||
color: rgba(222, 240, 245, 0.88);
|
||||
}
|
||||
|
||||
.bhote-event-range {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
margin: 11px 0 8px;
|
||||
accent-color: var(--accent);
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
.bhote-event-range.flood {
|
||||
accent-color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bhote-event-dates {
|
||||
font-size: 8px;
|
||||
color: rgba(174, 208, 216, 0.62);
|
||||
}
|
||||
|
||||
.bhote-event-story-nav {
|
||||
display: grid;
|
||||
grid-template-columns: 34px minmax(0, 1fr) 34px;
|
||||
align-items: stretch;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.bhote-event-story-nav > button {
|
||||
width: 34px;
|
||||
min-height: 54px;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(88, 196, 218, 0.3);
|
||||
border-radius: 4px;
|
||||
color: var(--accent);
|
||||
background: rgba(7, 25, 35, 0.9);
|
||||
font: 22px/1 'JetBrains Mono', 'SFMono-Regular', Consolas, monospace;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bhote-event-story-current {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-content: center;
|
||||
column-gap: 9px;
|
||||
min-width: 0;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid rgba(88, 196, 218, 0.18);
|
||||
background: rgba(6, 19, 27, 0.72);
|
||||
}
|
||||
|
||||
.bhote-event-story-current span,
|
||||
.bhote-event-story-current small {
|
||||
color: rgba(116, 216, 235, 0.7);
|
||||
font-size: 8px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.bhote-event-story-current strong {
|
||||
overflow: hidden;
|
||||
color: var(--text-primary);
|
||||
font-size: 11px;
|
||||
line-height: 1.25;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bhote-event-story-current small {
|
||||
grid-column: 1 / -1;
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
color: rgba(116, 216, 235, 0.7);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bhote-event-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
justify-content: flex-start;
|
||||
margin-top: 9px;
|
||||
}
|
||||
|
||||
.bhote-event-actions button,
|
||||
.bhote-event-report {
|
||||
border: 1px solid rgba(88, 196, 218, 0.24);
|
||||
border-radius: 4px;
|
||||
color: var(--text-primary);
|
||||
background: rgba(7, 25, 35, 0.9);
|
||||
font: inherit;
|
||||
font-size: 9px;
|
||||
letter-spacing: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bhote-event-actions button {
|
||||
min-width: 0;
|
||||
min-height: 30px;
|
||||
padding: 0 5px;
|
||||
overflow: hidden;
|
||||
font-size: 8px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bhote-event-actions button:disabled,
|
||||
.bhote-event-story-nav > button:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.bhote-event-actions button:first-child {
|
||||
color: var(--accent);
|
||||
border-color: rgba(88, 196, 218, 0.36);
|
||||
}
|
||||
|
||||
.bhote-event-actions button[data-action="cinematic"][aria-pressed="true"] {
|
||||
color: #071017;
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.bhote-event-evidence {
|
||||
margin: 13px 16px 0;
|
||||
border-top: 1px solid rgba(95, 213, 236, 0.16);
|
||||
border-bottom: 1px solid rgba(95, 213, 236, 0.16);
|
||||
}
|
||||
|
||||
.bhote-event-evidence summary {
|
||||
padding: 10px 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bhote-event-report-list {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.bhote-event-report {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
width: 100%;
|
||||
padding: 8px 9px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.bhote-event-report span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bhote-event-report small {
|
||||
color: rgba(115, 210, 228, 0.58);
|
||||
}
|
||||
|
||||
.bhote-event-credit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin: 10px 16px 0;
|
||||
padding: 8px 0 0;
|
||||
border-top: 1px solid rgba(95, 213, 236, 0.16);
|
||||
color: rgba(116, 216, 235, 0.62);
|
||||
font-size: 8px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.bhote-event-credit button {
|
||||
flex: 0 0 auto;
|
||||
min-height: 26px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid rgba(88, 196, 218, 0.24);
|
||||
border-radius: 4px;
|
||||
color: var(--text-primary);
|
||||
background: rgba(7, 25, 35, 0.9);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bhote-event-caveat {
|
||||
margin: 11px 16px 14px;
|
||||
font-size: 8px;
|
||||
line-height: 1.5;
|
||||
color: rgba(179, 204, 210, 0.62);
|
||||
}
|
||||
|
||||
#bhote-koshi-split-line {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: var(--bhote-koshi-split, 50%);
|
||||
z-index: 8;
|
||||
width: 1px;
|
||||
pointer-events: none;
|
||||
background: rgba(218, 241, 247, 0.76);
|
||||
box-shadow: 0 0 12px rgba(94, 216, 239, 0.72);
|
||||
}
|
||||
|
||||
#bhote-koshi-split-line span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#bhote-koshi-split-line .bhote-koshi-split-handle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(180, 215, 222, 0.72);
|
||||
border-radius: 50%;
|
||||
outline: 0;
|
||||
color: rgba(239, 247, 249, 0.96);
|
||||
background: rgba(4, 17, 27, 0.96);
|
||||
box-shadow:
|
||||
0 0 0 5px rgba(62, 112, 125, 0.3),
|
||||
0 0 13px rgba(94, 216, 239, 0.3);
|
||||
font: 700 14px/1 var(--font-mono);
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: auto;
|
||||
touch-action: none;
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
#bhote-koshi-split-line .bhote-koshi-split-handle:focus-visible {
|
||||
outline: 2px solid #fff;
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
#bhote-koshi-event-panel {
|
||||
right: 12px;
|
||||
bottom: 88px;
|
||||
width: calc(100vw - 24px);
|
||||
max-height: 48vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--bg-dark);
|
||||
font-family: var(--font-sans);
|
||||
color: var(--text-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
@@ -244,12 +244,24 @@
|
||||
}
|
||||
|
||||
.scene-shot-label {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 1px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.scene-shot-rename {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
font: inherit;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-panel, #101820);
|
||||
border: 1px solid var(--accent, #00d4ff);
|
||||
}
|
||||
|
||||
.scene-shot-meta {
|
||||
margin-top: 4px;
|
||||
font-family: var(--font-mono);
|
||||
|
||||
Reference in new issue
Block a user