diff --git a/CHANGELOG.md b/CHANGELOG.md
index b0f90f590..640740864 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,7 @@
# Changelog
+- Cancel hidden Nepal provider preloads on Stop, event disable and replacement, and respect drawing-tool pointer ownership for fallback evidence cards. Preserve @manjunath22466’s Nepal scene contribution and source attribution.
+
- Cancel the Nepal Upper Valley locator's pending approach and orbit on scene Stop, replacement, seek, and teardown; late camera callbacks cannot take over a newer shot.
- Keep completed flood history visible when later Nepal media-only shots arrive and reveal their source cards.
diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md
index 8fce6d8b1..aa561b15c 100644
--- a/docs/CURRENT-STATE.md
+++ b/docs/CURRENT-STATE.md
@@ -1,5 +1,7 @@
# God's Eye View Current State
+Nepal media preloads survive repeated camera-flight updates, but Stop, event disable and replacement remove abandoned frames and revoke pending Facebook sessions. Fallback evidence-card clicks respect the shared drawing-tool pointer lease.
+
Nepal flood lines classify the active terrain or photoreal surface, and the surge marker follows that surface. Geographic route coordinates are unchanged; dynamic lines do not rely on unsupported depth-failure materials. Later media-only shots keep their completed source-path history throughout arrival and card reveal.
Scene Stop, replacement, seek, and teardown revoke the Upper Valley locator's delayed camera approach and orbit while preserving its visible callout. A revoked timer or camera callback cannot start another flight or interfere with a newer shot.
diff --git a/src/data/bhoteKoshiEmbeddedMedia.js b/src/data/bhoteKoshiEmbeddedMedia.js
index 9395db48e..b00f265a8 100644
--- a/src/data/bhoteKoshiEmbeddedMedia.js
+++ b/src/data/bhoteKoshiEmbeddedMedia.js
@@ -26,7 +26,8 @@ const X_HOSTS = new Set([
]);
const X_WIDGET_URL = 'https://platform.twitter.com/widgets.js';
const FACEBOOK_SDK_ID = 'facebook-jssdk';
-const FACEBOOK_SDK_URL = 'https://connect.facebook.net/en_US/sdk.js#xfbml=0&version=v24.0';
+const FACEBOOK_SDK_URL =
+ 'https://connect.facebook.net/en_US/sdk.js#xfbml=0&version=v24.0';
const EMBED_TIMEOUT_MS = 15000;
const CARD_EXIT_DURATION_MS = 620;
const MEDIA_PRECONNECT_ORIGINS = Object.freeze({
@@ -44,7 +45,8 @@ let _facebookEmbedSequence = 0;
const youtubeApis = new WeakMap();
function loadYouTubeApi(documentRef, globalRef) {
- if (typeof globalRef.YT?.Player === 'function') return Promise.resolve(globalRef.YT);
+ if (typeof globalRef.YT?.Player === 'function')
+ return Promise.resolve(globalRef.YT);
if (youtubeApis.has(documentRef)) return youtubeApis.get(documentRef);
const pending = new Promise((resolve, reject) => {
const previous = globalRef.onYouTubeIframeAPIReady;
@@ -55,30 +57,54 @@ function loadYouTubeApi(documentRef, globalRef) {
if (settled) return;
settled = true;
globalRef.clearTimeout(timer);
- if (globalRef.onYouTubeIframeAPIReady === ready) globalRef.onYouTubeIframeAPIReady = previous;
- if (error) { script.remove(); reject(error); }
- else resolve(globalRef.YT);
+ if (globalRef.onYouTubeIframeAPIReady === ready)
+ globalRef.onYouTubeIframeAPIReady = previous;
+ if (error) {
+ script.remove();
+ reject(error);
+ } else resolve(globalRef.YT);
};
const ready = () => {
- try { previous?.(); } finally {
- finish(typeof globalRef.YT?.Player === 'function' ? null : new Error('YouTube API unavailable'));
+ try {
+ previous?.();
+ } finally {
+ finish(
+ typeof globalRef.YT?.Player === 'function'
+ ? null
+ : new Error('YouTube API unavailable'),
+ );
}
};
globalRef.onYouTubeIframeAPIReady = ready;
script.src = 'https://www.youtube.com/iframe_api';
script.async = true;
- script.addEventListener('error', () => finish(new Error('YouTube API unavailable')), { once: true });
- timer = globalRef.setTimeout(() => finish(new Error('YouTube API timed out')), 5000);
+ script.addEventListener(
+ 'error',
+ () => finish(new Error('YouTube API unavailable')),
+ { once: true },
+ );
+ timer = globalRef.setTimeout(
+ () => finish(new Error('YouTube API timed out')),
+ 5000,
+ );
documentRef.head.append(script);
- }).catch((error) => { youtubeApis.delete(documentRef); throw error; });
+ }).catch((error) => {
+ youtubeApis.delete(documentRef);
+ throw error;
+ });
youtubeApis.set(documentRef, pending);
return pending;
}
/** A trimmed clip follows provider time, never time spent loading its iframe. */
export function createTrimmedYouTubePlayback({
- iframe, endAtSec, startAtSec = 0, loadApi, globalRef = globalThis,
- isCurrent = () => true, onStatus = () => {},
+ iframe,
+ endAtSec,
+ startAtSec = 0,
+ loadApi,
+ globalRef = globalThis,
+ isCurrent = () => true,
+ onStatus = () => {},
}) {
let player = null;
let phase = 'starting';
@@ -87,7 +113,8 @@ export function createTrimmedYouTubePlayback({
let currentTime = startAtSec;
let poll = null;
let deadline = null;
- const live = () => active && isCurrent() && ['starting', 'playing'].includes(phase);
+ const live = () =>
+ active && isCurrent() && ['starting', 'playing'].includes(phase);
const clear = () => {
globalRef.clearTimeout(deadline);
globalRef.clearTimeout(poll);
@@ -97,7 +124,11 @@ export function createTrimmedYouTubePlayback({
if (!live()) return;
phase = outcome;
clear();
- try { player?.pauseVideo?.(); } catch { /* Removed provider frame. */ }
+ try {
+ player?.pauseVideo?.();
+ } catch {
+ /* Removed provider frame. */
+ }
onStatus(outcome);
};
const sample = (stateHint) => {
@@ -109,55 +140,84 @@ export function createTrimmedYouTubePlayback({
const time = player?.getCurrentTime?.();
if (Number.isFinite(time)) currentTime = time;
const state = stateHint ?? player?.getPlayerState?.();
- if (currentTime >= endAtSec || (state === 0 && currentTime >= endAtSec - 0.1)) {
- finish('completed'); return;
+ if (
+ currentTime >= endAtSec ||
+ (state === 0 && currentTime >= endAtSec - 0.1)
+ ) {
+ finish('completed');
+ return;
+ }
+ if (state === 0) {
+ finish('unavailable');
+ return;
}
- if (state === 0) { finish('unavailable'); return; }
// Autoplay can begin before the API attaches, without a new PLAYING edge.
if (!started && (state === 1 || currentTime > previousTime)) {
started = true;
phase = 'playing';
globalRef.clearTimeout(deadline);
- deadline = globalRef.setTimeout(() => finish('timeout'), (endAtSec - startAtSec) * 1000 + 5000);
+ deadline = globalRef.setTimeout(
+ () => finish('timeout'),
+ (endAtSec - startAtSec) * 1000 + 5000,
+ );
}
- } catch { finish('unavailable'); return; }
+ } catch {
+ finish('unavailable');
+ return;
+ }
poll = globalRef.setTimeout(() => sample(), 100);
};
deadline = globalRef.setTimeout(() => finish('timeout'), 5000);
- Promise.resolve().then(loadApi).then((api) => {
- if (!live()) return;
- player = new api.Player(iframe, { events: {
- onReady(event) {
- if (!live()) return;
- player = event.target;
- sample();
- if (!live()) return;
- try { event.target.mute(); event.target.playVideo(); } catch { finish('unavailable'); }
- },
- onStateChange(event) {
- if (!live()) return;
- if (event.target) player = event.target;
- sample(event.data);
- },
- onError: () => finish('unavailable'),
- onAutoplayBlocked: () => finish('blocked'),
- } });
- }).catch(() => finish('unavailable'));
+ Promise.resolve()
+ .then(loadApi)
+ .then((api) => {
+ if (!live()) return;
+ player = new api.Player(iframe, {
+ events: {
+ onReady(event) {
+ if (!live()) return;
+ player = event.target;
+ sample();
+ if (!live()) return;
+ try {
+ event.target.mute();
+ event.target.playVideo();
+ } catch {
+ finish('unavailable');
+ }
+ },
+ onStateChange(event) {
+ if (!live()) return;
+ if (event.target) player = event.target;
+ sample(event.data);
+ },
+ onError: () => finish('unavailable'),
+ onAutoplayBlocked: () => finish('blocked'),
+ },
+ });
+ })
+ .catch(() => finish('unavailable'));
return {
getState: () => ({ phase, currentTime }),
destroy() {
active = false;
phase = 'cancelled';
clear();
- try { player?.destroy?.(); } catch { /* Detached frame already released. */ }
+ try {
+ player?.destroy?.();
+ } catch {
+ /* Detached frame already released. */
+ }
player = null;
},
};
}
function facebookSdkReady(api) {
- return typeof api?.XFBML?.parse === 'function'
- && typeof api?.Event?.subscribe === 'function';
+ return (
+ typeof api?.XFBML?.parse === 'function' &&
+ typeof api?.Event?.subscribe === 'function'
+ );
}
function loadFacebookSdk(documentRef, globalRef = globalThis) {
@@ -199,10 +259,11 @@ function loadFacebookSdk(documentRef, globalRef = globalThis) {
() => finish(new Error('Facebook player API could not load.')),
{ once: true },
);
- timer = globalRef.setTimeout?.(
- () => finish(new Error('Facebook player API timed out.')),
- EMBED_TIMEOUT_MS,
- ) ?? null;
+ timer =
+ globalRef.setTimeout?.(
+ () => finish(new Error('Facebook player API timed out.')),
+ EMBED_TIMEOUT_MS,
+ ) ?? null;
}).catch((error) => {
_facebookSdkPromise = null;
throw error;
@@ -221,22 +282,24 @@ export function publicEmbeddedMediaUrl(value) {
}
const host = url.hostname.toLowerCase();
if (
- url.protocol !== 'https:'
- || url.username
- || url.password
- || (url.port && url.port !== '443')
- || !host.includes('.')
- || host.includes(':')
- || /^\d+(\.\d+){3}$/.test(host)
- || /(^|\.)(localhost|local|internal|test|invalid)$/.test(host)
- ) return null;
+ url.protocol !== 'https:' ||
+ url.username ||
+ url.password ||
+ (url.port && url.port !== '443') ||
+ !host.includes('.') ||
+ host.includes(':') ||
+ /^\d+(\.\d+){3}$/.test(host) ||
+ /(^|\.)(localhost|local|internal|test|invalid)$/.test(host)
+ )
+ return null;
return url;
}
function youtubeVideoId(url) {
const host = url.hostname.toLowerCase();
if (!YOUTUBE_HOSTS.has(host)) return null;
- if (host === 'youtu.be') return /^\/([\w-]{11})\/?$/.exec(url.pathname)?.[1] || null;
+ if (host === 'youtu.be')
+ return /^\/([\w-]{11})\/?$/.exec(url.pathname)?.[1] || null;
if (url.pathname === '/watch') return url.searchParams.get('v');
return /^\/(?:embed|shorts)\/([\w-]{11})\/?$/.exec(url.pathname)?.[1] || null;
}
@@ -244,16 +307,21 @@ function youtubeVideoId(url) {
function isFacebookVideoUrl(url) {
const host = url.hostname.toLowerCase();
if (!FACEBOOK_HOSTS.has(host)) return false;
- if (host === 'fb.watch' || host === 'www.fb.watch') return /^\/[\w.-]+\/?$/.test(url.pathname);
- return /^\/reel\/[\w.-]+\/?$/.test(url.pathname)
- || (url.pathname === '/watch/' && Boolean(url.searchParams.get('v')))
- || /\/videos\/[\w.-]+\/?$/.test(url.pathname)
- || /^\/share\/(?:v|r)\/[\w.-]+\/?$/.test(url.pathname);
+ if (host === 'fb.watch' || host === 'www.fb.watch')
+ return /^\/[\w.-]+\/?$/.test(url.pathname);
+ return (
+ /^\/reel\/[\w.-]+\/?$/.test(url.pathname) ||
+ (url.pathname === '/watch/' && Boolean(url.searchParams.get('v'))) ||
+ /\/videos\/[\w.-]+\/?$/.test(url.pathname) ||
+ /^\/share\/(?:v|r)\/[\w.-]+\/?$/.test(url.pathname)
+ );
}
function xPostId(url) {
if (!X_HOSTS.has(url.hostname.toLowerCase())) return null;
- return /^\/[A-Za-z0-9_]{1,15}\/status\/(\d+)\/?$/.exec(url.pathname)?.[1] || null;
+ return (
+ /^\/[A-Za-z0-9_]{1,15}\/status\/(\d+)\/?$/.exec(url.pathname)?.[1] || null
+ );
}
/** Resolve only providers that can render inside a Bhote Koshi callout. */
@@ -279,11 +347,10 @@ export function resolveEmbeddedMediaSource(value) {
}
/** Provider-owned iframe URL for YouTube or Facebook callouts. */
-export function embeddedMediaFrameUrl(source, {
- autoplay = false,
- startAtSec = 0,
- endAtSec = null,
-} = {}) {
+export function embeddedMediaFrameUrl(
+ source,
+ { autoplay = false, startAtSec = 0, endAtSec = null } = {},
+) {
if (source?.provider === 'youtube') {
const url = new URL(`https://www.youtube-nocookie.com/embed/${source.id}`);
url.searchParams.set('playsinline', '1');
@@ -342,24 +409,40 @@ function loadXWidgets(documentRef, globalRef = globalThis) {
script.dataset.gevXWidgets = 'true';
documentRef.head?.append(script);
}
- script.addEventListener?.('error', () => finish(new Error('X embed could not load.')), {
- once: true,
- });
+ script.addEventListener?.(
+ 'error',
+ () => finish(new Error('X embed could not load.')),
+ {
+ once: true,
+ },
+ );
const waitForApi = () => {
if (xWidgetsReady(globalRef.twttr)) {
finish();
return;
}
- if (typeof globalRef.twttr?.ready === 'function') globalRef.twttr.ready(ready);
+ if (typeof globalRef.twttr?.ready === 'function')
+ globalRef.twttr.ready(ready);
else ready();
};
script.addEventListener?.('load', waitForApi, { once: true });
- if (typeof globalRef.twttr?.ready === 'function') globalRef.twttr.ready(ready);
+ if (typeof globalRef.twttr?.ready === 'function')
+ globalRef.twttr.ready(ready);
if (script.dataset?.gevXLoaded === 'true') waitForApi();
- else script.addEventListener?.('load', () => { script.dataset.gevXLoaded = 'true'; }, {
- once: true,
- });
- timer = setTimeout(() => finish(new Error('X embed timed out.')), EMBED_TIMEOUT_MS);
+ else
+ script.addEventListener?.(
+ 'load',
+ () => {
+ script.dataset.gevXLoaded = 'true';
+ },
+ {
+ once: true,
+ },
+ );
+ timer = setTimeout(
+ () => finish(new Error('X embed timed out.')),
+ EMBED_TIMEOUT_MS,
+ );
}).catch((error) => {
_xWidgetsPromise = null;
throw error;
@@ -368,19 +451,26 @@ function loadXWidgets(documentRef, globalRef = globalThis) {
}
function providerLabel(provider) {
- return ({ youtube: 'YOUTUBE', facebook: 'FACEBOOK', x: 'X' })[provider] || 'SOURCE';
+ return (
+ { youtube: 'YOUTUBE', facebook: 'FACEBOOK', x: 'X' }[provider] || 'SOURCE'
+ );
}
function isPortraitMedia(media = {}) {
const width = Number(media.width);
const height = Number(media.height);
- return String(media.orientation || '').toLowerCase() === 'portrait'
- || (Number.isFinite(width) && Number.isFinite(height) && height > width);
+ return (
+ String(media.orientation || '').toLowerCase() === 'portrait' ||
+ (Number.isFinite(width) && Number.isFinite(height) && height > width)
+ );
}
function ensureProviderConnections(documentRef, provider) {
for (const origin of MEDIA_PRECONNECT_ORIGINS[provider] || []) {
- if (documentRef.querySelector?.(`link[data-gev-media-preconnect="${origin}"]`)) continue;
+ if (
+ documentRef.querySelector?.(`link[data-gev-media-preconnect="${origin}"]`)
+ )
+ continue;
const link = documentRef.createElement('link');
link.rel = 'preconnect';
link.href = origin;
@@ -394,17 +484,30 @@ function anchorOnScreen(viewer, anchor, root) {
if (!viewer?.scene || !anchor || !root) return null;
const camera = viewer.camera;
if (camera?.positionWC && camera?.directionWC) {
- const delta = Cesium.Cartesian3.subtract(anchor, camera.positionWC, new Cesium.Cartesian3());
+ const delta = Cesium.Cartesian3.subtract(
+ anchor,
+ camera.positionWC,
+ new Cesium.Cartesian3(),
+ );
if (Cesium.Cartesian3.dot(delta, camera.directionWC) <= 0) return null;
}
- const pixel = Cesium.SceneTransforms.worldToWindowCoordinates(viewer.scene, anchor);
+ const pixel = Cesium.SceneTransforms.worldToWindowCoordinates(
+ viewer.scene,
+ anchor,
+ );
if (!pixel) return null;
const canvasBounds = viewer.canvas?.getBoundingClientRect?.();
const rootBounds = root.getBoundingClientRect?.();
if (!canvasBounds || !rootBounds) return null;
const x = pixel.x + canvasBounds.left - rootBounds.left;
const y = pixel.y + canvasBounds.top - rootBounds.top;
- if (x < -40 || y < -40 || x > rootBounds.width + 40 || y > rootBounds.height + 40) return null;
+ if (
+ x < -40 ||
+ y < -40 ||
+ x > rootBounds.width + 40 ||
+ y > rootBounds.height + 40
+ )
+ return null;
return { x, y, width: rootBounds.width, height: rootBounds.height };
}
@@ -419,15 +522,27 @@ function placeCard(card, leader, anchor) {
let laneRight = anchor.width - margin;
let laneBottom = anchor.height - margin;
if (rootBounds && documentRef?.querySelector) {
- const leftPanel = documentRef.querySelector('#scene-panel')?.getBoundingClientRect?.();
- const rightRail = documentRef.querySelector('#right-context-rail')?.getBoundingClientRect?.();
- const commandDock = documentRef.querySelector('#command-dock')?.getBoundingClientRect?.();
- if (leftPanel?.width > 0) laneLeft = Math.max(laneLeft, leftPanel.right - rootBounds.left + margin);
- if (rightRail?.width > 0) laneRight = Math.min(laneRight, rightRail.left - rootBounds.left - margin);
- if (commandDock?.height > 0) laneBottom = Math.min(
- laneBottom,
- commandDock.top - rootBounds.top - margin,
- );
+ const leftPanel = documentRef
+ .querySelector('#scene-panel')
+ ?.getBoundingClientRect?.();
+ const rightRail = documentRef
+ .querySelector('#right-context-rail')
+ ?.getBoundingClientRect?.();
+ const commandDock = documentRef
+ .querySelector('#command-dock')
+ ?.getBoundingClientRect?.();
+ if (leftPanel?.width > 0)
+ laneLeft = Math.max(laneLeft, leftPanel.right - rootBounds.left + margin);
+ if (rightRail?.width > 0)
+ laneRight = Math.min(
+ laneRight,
+ rightRail.left - rootBounds.left - margin,
+ );
+ if (commandDock?.height > 0)
+ laneBottom = Math.min(
+ laneBottom,
+ commandDock.top - rootBounds.top - margin,
+ );
if (laneRight - laneLeft < width) {
laneLeft = margin;
laneRight = anchor.width - margin;
@@ -437,11 +552,13 @@ function placeCard(card, leader, anchor) {
if (left + width > laneRight) left = anchor.x - width - gap;
left = Math.max(laneLeft, Math.min(laneRight - width, left));
const belowAnchor = anchor.y + gap;
- const prefersBelow = card.classList.contains('is-youtube')
- || card.classList.contains('is-portrait');
- const desiredTop = prefersBelow && belowAnchor + height <= laneBottom
- ? belowAnchor
- : anchor.y - height * 0.42;
+ const prefersBelow =
+ card.classList.contains('is-youtube') ||
+ card.classList.contains('is-portrait');
+ const desiredTop =
+ prefersBelow && belowAnchor + height <= laneBottom
+ ? belowAnchor
+ : anchor.y - height * 0.42;
const top = Math.max(margin, Math.min(laneBottom - height, desiredTop));
card.style.left = `${left}px`;
card.style.top = `${top}px`;
@@ -449,7 +566,9 @@ function placeCard(card, leader, anchor) {
const localY = anchor.y - top;
const edgeX = localX < 0 ? 0 : width;
const edgeY = Math.max(16, Math.min(height - 16, localY));
- leader.querySelector('path')?.setAttribute('d', `M ${localX} ${localY} L ${edgeX} ${edgeY}`);
+ leader
+ .querySelector('path')
+ ?.setAttribute('d', `M ${localX} ${localY} L ${edgeX} ${edgeY}`);
const dot = leader.querySelector('circle');
dot?.setAttribute('cx', String(localX));
dot?.setAttribute('cy', String(localY));
@@ -492,9 +611,9 @@ export function createBhoteKoshiEmbeddedMedia({
let generation = 0;
const leavingRecords = new Set();
- const prefersReducedMotion = () => (
- globalRef.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches === true
- );
+ const prefersReducedMotion = () =>
+ globalRef.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ===
+ true;
const scheduleFrame = (callback) => {
if (typeof globalRef.requestAnimationFrame === 'function') {
return { kind: 'frame', id: globalRef.requestAnimationFrame(callback) };
@@ -507,7 +626,8 @@ export function createBhoteKoshiEmbeddedMedia({
};
const cancelScheduledFrame = (scheduled) => {
if (!scheduled) return;
- if (scheduled.kind === 'frame') globalRef.cancelAnimationFrame?.(scheduled.id);
+ if (scheduled.kind === 'frame')
+ globalRef.cancelAnimationFrame?.(scheduled.id);
else globalRef.clearTimeout?.(scheduled.id);
};
@@ -585,34 +705,41 @@ export function createBhoteKoshiEmbeddedMedia({
embed.dataset.autoplay = 'false';
session.embed = embed;
host.append(embed);
- void facebookLoader(documentRef, globalRef).then((api) => {
- if (!session.active) return;
- session.api = api;
- session.readyListener = (message) => {
- if (!session.active || message?.type !== 'video'
- || (message.id && message.id !== embed.id)) return;
- globalRef.clearTimeout?.(session.timer);
- session.timer = null;
- session.player = message.instance;
- session.ready = true;
- session.error = null;
- session.onReady?.();
- setFacebookSessionPlayback(session, session.shouldPlay);
- };
- api.Event.subscribe('xfbml.ready', session.readyListener);
- api.XFBML.parse(session.host);
- session.timer = globalRef.setTimeout?.(() => {
- if (!session.active || session.ready) return;
- session.error = new Error(
- 'Facebook embed unavailable. The post may be private, removed, region-restricted, or blocked.',
- );
- session.onError?.(session.error);
- }, EMBED_TIMEOUT_MS) ?? null;
- }).catch((error) => {
- if (!session.active) return;
- session.error = error;
- session.onError?.(error);
- });
+ void facebookLoader(documentRef, globalRef)
+ .then((api) => {
+ if (!session.active) return;
+ session.api = api;
+ session.readyListener = (message) => {
+ if (
+ !session.active ||
+ message?.type !== 'video' ||
+ (message.id && message.id !== embed.id)
+ )
+ return;
+ globalRef.clearTimeout?.(session.timer);
+ session.timer = null;
+ session.player = message.instance;
+ session.ready = true;
+ session.error = null;
+ session.onReady?.();
+ setFacebookSessionPlayback(session, session.shouldPlay);
+ };
+ api.Event.subscribe('xfbml.ready', session.readyListener);
+ api.XFBML.parse(session.host);
+ session.timer =
+ globalRef.setTimeout?.(() => {
+ if (!session.active || session.ready) return;
+ session.error = new Error(
+ 'Facebook embed unavailable. The post may be private, removed, region-restricted, or blocked.',
+ );
+ session.onError?.(session.error);
+ }, EMBED_TIMEOUT_MS) ?? null;
+ })
+ .catch((error) => {
+ if (!session.active) return;
+ session.error = error;
+ session.onError?.(error);
+ });
return session;
}
@@ -644,9 +771,11 @@ export function createBhoteKoshiEmbeddedMedia({
}
}
};
- const removePostRender = viewer.scene?.postRender?.addEventListener?.(position);
+ const removePostRender =
+ viewer.scene?.postRender?.addEventListener?.(position);
- function hide({ immediate = false } = {}) {
+ function hide({ immediate = false, preserveWarm = false } = {}) {
+ if (!preserveWarm) clearWarm();
generation += 1;
const current = record;
record = null;
@@ -662,15 +791,17 @@ export function createBhoteKoshiEmbeddedMedia({
current.card.classList.remove('is-entering');
current.card.classList.add('is-leaving');
leavingRecords.add(current);
- current.exitTimer = globalRef.setTimeout?.(
- () => removeRecord(current),
- CARD_EXIT_DURATION_MS,
- ) ?? null;
+ current.exitTimer =
+ globalRef.setTimeout?.(
+ () => removeRecord(current),
+ CARD_EXIT_DURATION_MS,
+ ) ?? null;
}
function clearWarm(key = null) {
if (!warmRecord || (key && warmRecord.key !== key)) return;
- if (warmRecord.facebookSession) destroyFacebookSession(warmRecord.facebookSession);
+ if (warmRecord.facebookSession)
+ destroyFacebookSession(warmRecord.facebookSession);
else warmRecord.iframe?.remove?.();
warmRecord = null;
}
@@ -694,9 +825,10 @@ export function createBhoteKoshiEmbeddedMedia({
clearWarm();
return false;
}
- const startAtSec = source.provider === 'youtube'
- ? Math.max(0, Number(media.embedStartAtSec ?? media.clipInSec) || 0)
- : 0;
+ const startAtSec =
+ source.provider === 'youtube'
+ ? Math.max(0, Number(media.embedStartAtSec ?? media.clipInSec) || 0)
+ : 0;
ensureProviderConnections(documentRef, source.provider);
const endAtSec = media.embedEndAtSec;
const key = `${source.provider}:${source.id || source.url}:${startAtSec}:${endAtSec ?? ''}`;
@@ -713,7 +845,11 @@ export function createBhoteKoshiEmbeddedMedia({
}
const iframe = documentRef.createElement('iframe');
iframe.className = 'bhote-embedded-media-warmup';
- iframe.src = embeddedMediaFrameUrl(source, { autoplay: false, startAtSec, endAtSec });
+ iframe.src = embeddedMediaFrameUrl(source, {
+ autoplay: false,
+ startAtSec,
+ endAtSec,
+ });
iframe.title = `Preloading ${providerLabel(source.provider)} media`;
iframe.tabIndex = -1;
iframe.setAttribute('aria-hidden', 'true');
@@ -729,15 +865,20 @@ export function createBhoteKoshiEmbeddedMedia({
try {
const api = await xLoader(documentRef, globalRef);
if (token !== generation || record !== current) return;
- const widget = await api.widgets.createTweet(current.source.id, current.player, {
- align: 'center',
- conversation: 'none',
- dnt: true,
- theme: 'dark',
- width: 390,
- });
+ const widget = await api.widgets.createTweet(
+ current.source.id,
+ current.player,
+ {
+ align: 'center',
+ conversation: 'none',
+ dnt: true,
+ theme: 'dark',
+ width: 390,
+ },
+ );
if (token !== generation || record !== current) return;
- if (!widget) throw new Error('The X post is unavailable or cannot be embedded.');
+ if (!widget)
+ throw new Error('The X post is unavailable or cannot be embedded.');
current.status.textContent = 'X POST · PROVIDER CONTROLS';
position();
} catch (error) {
@@ -749,8 +890,9 @@ export function createBhoteKoshiEmbeddedMedia({
}
function bindFacebook(current, token, warmKey, autoplay) {
- const session = takeWarmFacebook(warmKey, current.player)
- || createFacebookSession(current.source, current.player);
+ const session =
+ takeWarmFacebook(warmKey, current.player) ||
+ createFacebookSession(current.source, current.player);
current.facebookSession = session;
session.shouldPlay = autoplay === true;
session.onReady = () => {
@@ -775,6 +917,7 @@ export function createBhoteKoshiEmbeddedMedia({
}
function setPlayback(playing) {
+ if (!playing) clearWarm();
// Removing an uncontrolled provider frame also cancels delayed autoplay.
// Provider commands alone cannot stop a frame that has not loaded yet.
if (!playing && record && !record.facebookSession) {
@@ -792,37 +935,49 @@ export function createBhoteKoshiEmbeddedMedia({
hide();
return false;
}
- const startAtSec = source.provider === 'youtube'
- ? Math.max(0, Number(media.embedStartAtSec ?? media.clipInSec) || 0)
- : 0;
+ const startAtSec =
+ source.provider === 'youtube'
+ ? Math.max(0, Number(media.embedStartAtSec ?? media.clipInSec) || 0)
+ : 0;
ensureProviderConnections(documentRef, source.provider);
const endAtSec = media.embedEndAtSec;
const warmKey = `${source.provider}:${source.id || source.url}:${startAtSec}:${endAtSec ?? ''}`;
- const key = source.provider === 'facebook'
- ? warmKey
- : `${warmKey}:${autoplay ? 1 : 0}`;
+ const key =
+ source.provider === 'facebook'
+ ? warmKey
+ : `${warmKey}:${autoplay ? 1 : 0}`;
if (record?.key === key) {
record.anchor = anchor;
if (source.provider === 'facebook') setPlayback(autoplay);
position();
return true;
}
- hide();
+ if (warmRecord && warmRecord.key !== warmKey) clearWarm();
+ hide({ preserveWarm: true });
const token = ++generation;
const card = documentRef.createElement('section');
const portraitClass = isPortraitMedia(media) ? ' is-portrait' : '';
card.className = `bhote-embedded-callout is-${source.provider}${portraitClass}`;
card.dataset.provider = source.provider;
- card.setAttribute('aria-label', `Embedded ${providerLabel(source.provider)} media for ${observation?.title || 'witness source'}`);
- const leader = documentRef.createElementNS('http://www.w3.org/2000/svg', 'svg');
+ card.setAttribute(
+ 'aria-label',
+ `Embedded ${providerLabel(source.provider)} media for ${observation?.title || 'witness source'}`,
+ );
+ const leader = documentRef.createElementNS(
+ 'http://www.w3.org/2000/svg',
+ 'svg',
+ );
leader.classList.add('bhote-embedded-callout-leader');
leader.setAttribute('aria-hidden', 'true');
- leader.innerHTML = '';
+ leader.innerHTML =
+ '';
const player = documentRef.createElement('div');
player.className = 'bhote-embedded-callout-player';
const footer = documentRef.createElement('footer');
const title = documentRef.createElement('strong');
- title.textContent = String(observation?.shortTitle || observation?.title || 'Witness source').toUpperCase();
+ title.textContent = String(
+ observation?.shortTitle || observation?.title || 'Witness source',
+ ).toUpperCase();
const status = documentRef.createElement('small');
status.setAttribute('role', 'status');
status.textContent = `${providerLabel(source.provider)} · EMBEDDED SOURCE`;
@@ -854,36 +1009,58 @@ export function createBhoteKoshiEmbeddedMedia({
bindFacebook(record, token, warmKey, autoplay);
} else {
const iframe = documentRef.createElement('iframe');
- iframe.src = embeddedMediaFrameUrl(source, { autoplay, startAtSec, endAtSec });
- const controlled = autoplay && Number.isFinite(Number(endAtSec)) && Number(endAtSec) > startAtSec;
+ iframe.src = embeddedMediaFrameUrl(source, {
+ autoplay,
+ startAtSec,
+ endAtSec,
+ });
+ const controlled =
+ autoplay &&
+ Number.isFinite(Number(endAtSec)) &&
+ Number(endAtSec) > startAtSec;
if (controlled) {
const url = new URL(iframe.src);
url.searchParams.set('enablejsapi', '1');
- if (globalRef.location?.origin) url.searchParams.set('origin', globalRef.location.origin);
+ if (globalRef.location?.origin)
+ url.searchParams.set('origin', globalRef.location.origin);
iframe.src = url.href;
}
iframe.title = `${providerLabel(source.provider)} media: ${observation?.title || 'witness source'}`;
- iframe.allow = 'autoplay; encrypted-media; picture-in-picture; fullscreen';
+ iframe.allow =
+ 'autoplay; encrypted-media; picture-in-picture; fullscreen';
iframe.allowFullscreen = true;
iframe.loading = 'eager';
iframe.referrerPolicy = 'strict-origin-when-cross-origin';
- iframe.addEventListener?.('load', () => {
- if (token !== generation || record?.key !== key) return;
- clearWarm(warmKey);
- if (!record.youtubeSession || ['starting', 'playing'].includes(record.youtubeSession.getState().phase)) {
- status.textContent = `${providerLabel(source.provider)} · PROVIDER CONTROLS`;
- }
- position();
- }, { once: true });
+ iframe.addEventListener?.(
+ 'load',
+ () => {
+ if (token !== generation || record?.key !== key) return;
+ clearWarm(warmKey);
+ if (
+ !record.youtubeSession ||
+ ['starting', 'playing'].includes(
+ record.youtubeSession.getState().phase,
+ )
+ ) {
+ status.textContent = `${providerLabel(source.provider)} · PROVIDER CONTROLS`;
+ }
+ position();
+ },
+ { once: true },
+ );
player.append(iframe);
if (controlled) {
const current = record;
current.youtubeSession = createTrimmedYouTubePlayback({
- iframe, endAtSec: Number(endAtSec), startAtSec, globalRef,
+ iframe,
+ endAtSec: Number(endAtSec),
+ startAtSec,
+ globalRef,
loadApi: () => youtubeLoader(documentRef, globalRef),
isCurrent: () => token === generation && record === current,
onStatus: (phase) => {
- if (phase !== 'completed') status.textContent = `YOUTUBE ${phase.toUpperCase()} · OPEN ORIGINAL`;
+ if (phase !== 'completed')
+ status.textContent = `YOUTUBE ${phase.toUpperCase()} · OPEN ORIGINAL`;
},
});
}
diff --git a/src/data/bhoteKoshiEmbeddedMedia.test.mjs b/src/data/bhoteKoshiEmbeddedMedia.test.mjs
index 239d51b69..5223fff6c 100644
--- a/src/data/bhoteKoshiEmbeddedMedia.test.mjs
+++ b/src/data/bhoteKoshiEmbeddedMedia.test.mjs
@@ -248,3 +248,98 @@ test('Mailung playback stops at source timestamp 0:07 without changing other cli
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();
+});
diff --git a/src/data/bhoteKoshiEvent.js b/src/data/bhoteKoshiEvent.js
index 6b8254215..d53999f21 100644
--- a/src/data/bhoteKoshiEvent.js
+++ b/src/data/bhoteKoshiEvent.js
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
+import { isPointerFree } from './inputOwnership.js';
import {
clearOverlaySource,
hitTestWorldOverlay,
@@ -13,7 +14,10 @@ import {
} from '../renderGovernor.js';
import { announceNavigationAuthority } from '../navigationPolicy.js';
import { BHOTE_KOSHI_CREDIT, registerDynamicCredit } from './dataCredits.js';
-import { buildSourceShotPaths, SOURCE_PATH_BEAT_IDS } from './bhoteKoshiShotPaths.js';
+import {
+ buildSourceShotPaths,
+ SOURCE_PATH_BEAT_IDS,
+} from './bhoteKoshiShotPaths.js';
import {
createBhoteKoshiEmbeddedMedia,
resolveEmbeddedMediaSource,
@@ -40,23 +44,49 @@ const FLOOD_COLOR = Cesium.Color.fromCssColorString('#d8954e').withAlpha(0.68);
const SURGE_COLOR = Cesium.Color.fromCssColorString('#e8b47c');
const CINEMATIC_INPUT_EVENTS = Object.freeze(['pointerdown', 'wheel']);
const CINEMATIC_VIEW_SPECS = Object.freeze([
- { id: 'immediate-collapse-viewpoint', headingDeg: 224, pitchDeg: -38, rangeM: 15000 },
+ {
+ id: 'immediate-collapse-viewpoint',
+ headingDeg: 224,
+ pitchDeg: -38,
+ rangeM: 15000,
+ },
{ id: 'debris-dammed-lake', headingDeg: 232, pitchDeg: -37, rangeM: 13200 },
{ id: 'second-landslide', headingDeg: 224, pitchDeg: -38, rangeM: 12000 },
{ id: 'gyirong-border-gate', headingDeg: 208, pitchDeg: -42, rangeM: 6100 },
{ id: 'timure-cluster', headingDeg: 198, pitchDeg: -40, rangeM: 5200 },
{ id: 'syabru-besi', headingDeg: 205, pitchDeg: -38, rangeM: 7200 },
{ id: 'dhunche', headingDeg: 208, pitchDeg: -39, rangeM: 7800 },
- { id: 'mailung-upper-trishuli', headingDeg: 211, pitchDeg: -40, rangeM: 8400 },
+ {
+ id: 'mailung-upper-trishuli',
+ headingDeg: 211,
+ pitchDeg: -40,
+ rangeM: 8400,
+ },
{ id: 'mailung-bazzar', headingDeg: 212, pitchDeg: -40, rangeM: 8600 },
{ id: 'dandagaun', headingDeg: 214, pitchDeg: -40, rangeM: 8600 },
{ id: 'dandagaun-viewpoint', headingDeg: 215, pitchDeg: -41, rangeM: 9200 },
{ id: 'betrawati-bazaar', headingDeg: 216, pitchDeg: -41, rangeM: 9800 },
{ id: 'bhainse', headingDeg: 219, pitchDeg: -41, rangeM: 11500 },
- { id: 'bidur-trishuli-bridge', headingDeg: 248, pitchDeg: -46, rangeM: 15500 },
- { id: 'devighat-taadi-khola-bridge', headingDeg: 224, pitchDeg: -42, rangeM: 13000 },
+ {
+ id: 'bidur-trishuli-bridge',
+ headingDeg: 248,
+ pitchDeg: -46,
+ rangeM: 15500,
+ },
+ {
+ id: 'devighat-taadi-khola-bridge',
+ headingDeg: 224,
+ pitchDeg: -42,
+ rangeM: 13000,
+ },
{ id: 'charaudi', headingDeg: 232, pitchDeg: -42, rangeM: 15000 },
- { id: 'charaudi', progress: 1, headingDeg: 232, pitchDeg: -42, rangeM: 15000 },
+ {
+ id: 'charaudi',
+ progress: 1,
+ headingDeg: 232,
+ pitchDeg: -42,
+ rangeM: 15000,
+ },
]);
const PASSIVE_ENABLE_ORIGINS = new Set([
'scene',
@@ -79,24 +109,27 @@ const DEFAULT_RENDER_HOST = Object.freeze({
});
const DEFAULT_EVENT_LOADER = async () => {
const response = await fetch(eventAsset('event.json'), { cache: 'no-store' });
- if (!response.ok) throw new Error(`Bhote Koshi event pack unavailable (${response.status})`);
+ if (!response.ok)
+ throw new Error(`Bhote Koshi event pack unavailable (${response.status})`);
return response.json();
};
-const DEFAULT_IMAGERY_PROVIDER_FACTORY = (url, options) => (
- Cesium.SingleTileImageryProvider.fromUrl(url, options)
-);
+const DEFAULT_IMAGERY_PROVIDER_FACTORY = (url, options) =>
+ Cesium.SingleTileImageryProvider.fromUrl(url, options);
const DEFAULT_TERRAIN_SAMPLER = async (viewer, observations) => {
if (
- !viewer?.terrainProvider
- || !viewer.terrainProvider.availability
- || typeof Cesium.sampleTerrainMostDetailed !== 'function'
+ !viewer?.terrainProvider ||
+ !viewer.terrainProvider.availability ||
+ typeof Cesium.sampleTerrainMostDetailed !== 'function'
) {
return null;
}
- const cartographics = observations.map((observation) => (
- Cesium.Cartographic.fromDegrees(observation.lon, observation.lat)
- ));
- return Cesium.sampleTerrainMostDetailed(viewer.terrainProvider, cartographics);
+ const cartographics = observations.map((observation) =>
+ Cesium.Cartographic.fromDegrees(observation.lon, observation.lat),
+ );
+ return Cesium.sampleTerrainMostDetailed(
+ viewer.terrainProvider,
+ cartographics,
+ );
};
const DEFAULT_MEDIA_LOADER = async (url, { signal } = {}) => {
if (typeof createImageBitmap !== 'function') {
@@ -108,12 +141,18 @@ const DEFAULT_MEDIA_LOADER = async (url, { signal } = {}) => {
mode: 'cors',
signal,
});
- if (!response.ok) throw new Error(`Evidence poster unavailable (${response.status})`);
+ if (!response.ok)
+ throw new Error(`Evidence poster unavailable (${response.status})`);
return createImageBitmap(await response.blob());
};
const DEFAULT_VIDEO_FACTORY = () => {
const video = globalThis.document?.createElement?.('video');
- if (!video || typeof video.play !== 'function' || typeof video.pause !== 'function') return null;
+ if (
+ !video ||
+ typeof video.play !== 'function' ||
+ typeof video.pause !== 'function'
+ )
+ return null;
video.muted = true;
video.playsInline = true;
video.preload = 'auto';
@@ -125,7 +164,9 @@ export function clampUnit(value) {
}
export function elapsedLabel(progress, totalSeconds) {
- const elapsed = Math.round(clampUnit(progress) * Math.max(0, Number(totalSeconds) || 0));
+ const elapsed = Math.round(
+ clampUnit(progress) * Math.max(0, Number(totalSeconds) || 0),
+ );
const minutes = Math.floor(elapsed / 60);
const seconds = elapsed % 60;
return `${minutes}:${String(seconds).padStart(2, '0')}`;
@@ -147,7 +188,8 @@ export function updateCorridorPositionCache(progress, terrainPositions, cache) {
const normalizedProgress = clampUnit(progress);
const segmentCount = Math.max(0, positions.length - 1);
const visibleCount = visibleSegmentCount(normalizedProgress, segmentCount);
- if (cache.progress === normalizedProgress && cache.source === positions) return false;
+ if (cache.progress === normalizedProgress && cache.source === positions)
+ return false;
cache.progress = normalizedProgress;
cache.source = positions;
@@ -160,9 +202,8 @@ export function updateCorridorPositionCache(progress, terrainPositions, cache) {
const scaledProgress = normalizedProgress * segmentCount;
const startIndex = Math.min(segmentCount - 1, Math.floor(scaledProgress));
- const segmentProgress = normalizedProgress >= 1
- ? 1
- : scaledProgress - startIndex;
+ const segmentProgress =
+ normalizedProgress >= 1 ? 1 : scaledProgress - startIndex;
cache.headPosition = Cesium.Cartesian3.lerp(
positions[startIndex],
positions[startIndex + 1],
@@ -183,9 +224,7 @@ export function floodProgressForStoryProgress(progress, window = null) {
const end = window?.end ?? BHOTE_KOSHI_CORRIDOR_END;
if (storyProgress <= start) return 0;
if (storyProgress >= end) return 1;
- return (
- (storyProgress - start) / (end - start)
- );
+ return (storyProgress - start) / (end - start);
}
/**
@@ -198,21 +237,35 @@ export function buildEvidenceTimeline(observations, corridor) {
const firstChainage = Number(points[0]?.chainageM);
const lastChainage = Number(points[points.length - 1]?.chainageM);
const span = Math.max(1, lastChainage - firstChainage);
- const timeline = beats.map((observation, sourceIndex) => {
- const trigger = observation?.trigger || {};
- let activationProgress;
- if (trigger.mode === 'corridor' && Number.isFinite(Number(trigger.chainageM))) {
- const corridorProgress = clampUnit((Number(trigger.chainageM) - firstChainage) / span);
- activationProgress = BHOTE_KOSHI_PROLOGUE_END
- + corridorProgress * (BHOTE_KOSHI_CORRIDOR_END - BHOTE_KOSHI_PROLOGUE_END);
- } else {
- activationProgress = clampUnit(trigger.storyProgress);
- }
- return { observation, sourceIndex, activationProgress };
- }).sort((a, b) => a.activationProgress - b.activationProgress || a.sourceIndex - b.sourceIndex);
+ const timeline = beats
+ .map((observation, sourceIndex) => {
+ const trigger = observation?.trigger || {};
+ let activationProgress;
+ if (
+ trigger.mode === 'corridor' &&
+ Number.isFinite(Number(trigger.chainageM))
+ ) {
+ const corridorProgress = clampUnit(
+ (Number(trigger.chainageM) - firstChainage) / span,
+ );
+ activationProgress =
+ BHOTE_KOSHI_PROLOGUE_END +
+ corridorProgress *
+ (BHOTE_KOSHI_CORRIDOR_END - BHOTE_KOSHI_PROLOGUE_END);
+ } else {
+ activationProgress = clampUnit(trigger.storyProgress);
+ }
+ return { observation, sourceIndex, activationProgress };
+ })
+ .sort(
+ (a, b) =>
+ a.activationProgress - b.activationProgress ||
+ a.sourceIndex - b.sourceIndex,
+ );
for (let index = 0; index < timeline.length; index += 1) {
- timeline[index].deactivationProgress = timeline[index + 1]?.activationProgress ?? 1.000001;
+ timeline[index].deactivationProgress =
+ timeline[index + 1]?.activationProgress ?? 1.000001;
}
return timeline;
}
@@ -223,7 +276,10 @@ export function activeEvidenceIndex(progress, timeline) {
let activeIndex = -1;
for (let index = 0; index < timeline.length; index += 1) {
if (normalized < timeline[index].activationProgress) break;
- if (normalized < timeline[index].deactivationProgress || index === timeline.length - 1) {
+ if (
+ normalized < timeline[index].deactivationProgress ||
+ index === timeline.length - 1
+ ) {
activeIndex = index;
}
}
@@ -232,9 +288,9 @@ export function activeEvidenceIndex(progress, timeline) {
/** Resolve a stable evidence id to a visible point inside that beat's window. */
export function evidenceBeatProgress(beatId, timeline, reveal = 0.36) {
- const beat = (Array.isArray(timeline) ? timeline : []).find(({ observation }) => (
- observation?.id === beatId
- ));
+ const beat = (Array.isArray(timeline) ? timeline : []).find(
+ ({ observation }) => observation?.id === beatId,
+ );
if (!beat) return null;
const start = clampUnit(beat.activationProgress);
const end = Math.max(start, Math.min(1, beat.deactivationProgress));
@@ -244,7 +300,9 @@ export function evidenceBeatProgress(beatId, timeline, reveal = 0.36) {
/** Resolve the exact standalone story-clock span owned by one embedded shot. */
export function evidenceSequenceWindow(beatId, timeline) {
const beats = Array.isArray(timeline) ? timeline : [];
- const beatIndex = beats.findIndex(({ observation }) => observation?.id === beatId);
+ const beatIndex = beats.findIndex(
+ ({ observation }) => observation?.id === beatId,
+ );
if (beatIndex < 0) return null;
const beat = beats[beatIndex];
const startProgress = clampUnit(beat.activationProgress);
@@ -276,18 +334,30 @@ export function evidenceBeatPresentation(progress, beat) {
};
}
const start = clampUnit(beat.activationProgress);
- const end = Math.max(start + 0.000001, Math.min(1.000001, beat.deactivationProgress));
- const localProgress = clampUnit((clampUnit(progress) - start) / (end - start));
- const beatDurationSeconds = Math.max(0.25, (end - start) * BHOTE_KOSHI_PLAYBACK_SECONDS);
+ const end = Math.max(
+ start + 0.000001,
+ Math.min(1.000001, beat.deactivationProgress),
+ );
+ const localProgress = clampUnit(
+ (clampUnit(progress) - start) / (end - start),
+ );
+ const beatDurationSeconds = Math.max(
+ 0.25,
+ (end - start) * BHOTE_KOSHI_PLAYBACK_SECONDS,
+ );
const dotEnd = Math.min(0.12, 0.25 / beatDurationSeconds);
const leaderStart = dotEnd * 0.35;
const leaderEnd = Math.min(0.24, leaderStart + 0.35 / beatDurationSeconds);
- const contentStart = Math.min(leaderEnd, leaderStart + 0.12 / beatDurationSeconds);
+ const contentStart = Math.min(
+ leaderEnd,
+ leaderStart + 0.12 / beatDurationSeconds,
+ );
const contentEnd = Math.min(0.34, contentStart + 0.6 / beatDurationSeconds);
const dotReveal = smoothstep(0, dotEnd, localProgress);
const leaderReveal = smoothstep(leaderStart, leaderEnd, localProgress);
const contentReveal = smoothstep(contentStart, contentEnd, localProgress);
- const contentHold = 1 - smoothstep(BHOTE_KOSHI_CAMERA_HOLD_RATIO, 0.78, localProgress);
+ const contentHold =
+ 1 - smoothstep(BHOTE_KOSHI_CAMERA_HOLD_RATIO, 0.78, localProgress);
const leaderHold = 1 - smoothstep(0.74, 0.9, localProgress);
const dotHold = 1 - smoothstep(0.86, 1, localProgress);
const alpha = Math.min(dotReveal, dotHold);
@@ -314,7 +384,10 @@ export function adjacentEvidenceIndex(progress, timeline, direction) {
/** Compact, source-bounded disclosure for multi-source geolocation beats. */
export function evidenceCorroborationLabel(observation) {
- const count = Math.max(0, Math.floor(Number(observation?.corroboration?.sourceCount) || 0));
+ const count = Math.max(
+ 0,
+ Math.floor(Number(observation?.corroboration?.sourceCount) || 0),
+ );
return count > 1 ? `${count} GEOLOCATED SOURCE-MAP PLACEMENTS` : '';
}
@@ -323,7 +396,7 @@ export function interpolateHeadingDegrees(start, end, amount) {
const from = Number(start) || 0;
const to = Number(end) || 0;
const delta = ((to - from + 540) % 360) - 180;
- return ((from + delta * clampUnit(amount)) % 360 + 360) % 360;
+ return (((from + delta * clampUnit(amount)) % 360) + 360) % 360;
}
/** Resolve the editorial camera beats against the terrain-sampled evidence anchors. */
@@ -333,16 +406,21 @@ export function buildCinematicKeyframes(timeline, anchors) {
const resolved = new Map();
for (let index = 0; index < beats.length; index += 1) {
const id = beats[index]?.observation?.id;
- if (id && positions[index]) resolved.set(id, { beat: beats[index], target: positions[index] });
+ if (id && positions[index])
+ resolved.set(id, { beat: beats[index], target: positions[index] });
}
return CINEMATIC_VIEW_SPECS.flatMap((spec) => {
const entry = resolved.get(spec.id);
if (!entry) return [];
- return [{
- ...spec,
- progress: Number.isFinite(spec.progress) ? spec.progress : entry.beat.activationProgress,
- target: entry.target,
- }];
+ return [
+ {
+ ...spec,
+ progress: Number.isFinite(spec.progress)
+ ? spec.progress
+ : entry.beat.activationProgress,
+ target: entry.target,
+ },
+ ];
}).sort((a, b) => a.progress - b.progress);
}
@@ -361,13 +439,16 @@ export function sampleCinematicPose(progress, keyframes, result = {}) {
}
}
if (normalized <= frames[0].progress) from = to = frames[0];
- if (normalized >= frames[frames.length - 1].progress) from = to = frames[frames.length - 1];
+ if (normalized >= frames[frames.length - 1].progress)
+ from = to = frames[frames.length - 1];
const span = Math.max(0.000001, to.progress - from.progress);
- const linearAmount = from === to ? 0 : clampUnit((normalized - from.progress) / span);
- const travelAmount = linearAmount <= BHOTE_KOSHI_CAMERA_HOLD_RATIO
- ? 0
- : (linearAmount - BHOTE_KOSHI_CAMERA_HOLD_RATIO)
- / (1 - BHOTE_KOSHI_CAMERA_HOLD_RATIO);
+ const linearAmount =
+ from === to ? 0 : clampUnit((normalized - from.progress) / span);
+ const travelAmount =
+ linearAmount <= BHOTE_KOSHI_CAMERA_HOLD_RATIO
+ ? 0
+ : (linearAmount - BHOTE_KOSHI_CAMERA_HOLD_RATIO) /
+ (1 - BHOTE_KOSHI_CAMERA_HOLD_RATIO);
const amount = travelAmount * travelAmount * (3 - 2 * travelAmount);
result.target = Cesium.Cartesian3.lerp(
from.target,
@@ -375,7 +456,11 @@ export function sampleCinematicPose(progress, keyframes, result = {}) {
amount,
result.target || new Cesium.Cartesian3(),
);
- result.headingDeg = interpolateHeadingDegrees(from.headingDeg, to.headingDeg, amount);
+ result.headingDeg = interpolateHeadingDegrees(
+ from.headingDeg,
+ to.headingDeg,
+ amount,
+ );
result.pitchDeg = Cesium.Math.lerp(from.pitchDeg, to.pitchDeg, amount);
result.rangeM = Cesium.Math.lerp(from.rangeM, to.rangeM, amount);
result.fromId = from.id;
@@ -385,7 +470,10 @@ export function sampleCinematicPose(progress, keyframes, result = {}) {
/** Wrap a compact card summary without clipping a word at the canvas edge. */
export function evidenceSummaryLines(value, maxChars = 52, maxLines = 2) {
- const words = String(value || '').trim().split(/\s+/).filter(Boolean);
+ const words = String(value || '')
+ .trim()
+ .split(/\s+/)
+ .filter(Boolean);
const limit = Math.max(8, Math.floor(Number(maxChars) || 52));
const lineLimit = Math.max(1, Math.floor(Number(maxLines) || 2));
const lines = [];
@@ -457,7 +545,8 @@ const EVIDENCE_CARD_LAYOUTS = Object.freeze({
/** Resolve one stable card footprint before the entry enters the shared host. */
export function evidenceCardLayout(observation) {
const media = observation?.media || {};
- if (!media.videoPath && !media.posterPath && !media.posterUrl) return EVIDENCE_CARD_LAYOUTS.link;
+ if (!media.videoPath && !media.posterPath && !media.posterUrl)
+ return EVIDENCE_CARD_LAYOUTS.link;
const width = Number(media.width);
const height = Number(media.height);
const orientation = String(media.orientation || '').toLowerCase();
@@ -471,9 +560,11 @@ export function evidenceCardLayout(observation) {
export function usesProviderEmbeddedEvidence(observation) {
const source = resolveEmbeddedMediaSource(observation?.media?.sourceUrl);
if (!source) return false;
- return source.provider === 'youtube'
- || source.provider === 'facebook'
- || !observation?.media?.videoPath;
+ return (
+ source.provider === 'youtube' ||
+ source.provider === 'facebook' ||
+ !observation?.media?.videoPath
+ );
}
function evidenceFrameCanvas(observation) {
@@ -487,19 +578,35 @@ function evidenceFrameCanvas(observation) {
}
function drawCover(ctx, image, x, y, width, height) {
- const sourceWidth = Number(image?.videoWidth || image?.width || image?.naturalWidth) || width;
- const sourceHeight = Number(image?.videoHeight || image?.height || image?.naturalHeight) || height;
+ const sourceWidth =
+ Number(image?.videoWidth || image?.width || image?.naturalWidth) || width;
+ const sourceHeight =
+ Number(image?.videoHeight || image?.height || image?.naturalHeight) ||
+ height;
const scale = Math.max(width / sourceWidth, height / sourceHeight);
const cropWidth = width / scale;
const cropHeight = height / scale;
const sourceX = Math.max(0, (sourceWidth - cropWidth) / 2);
const sourceY = Math.max(0, (sourceHeight - cropHeight) / 2);
- ctx.drawImage(image, sourceX, sourceY, cropWidth, cropHeight, x, y, width, height);
+ ctx.drawImage(
+ image,
+ sourceX,
+ sourceY,
+ cropWidth,
+ cropHeight,
+ x,
+ y,
+ width,
+ height,
+ );
}
function drawContain(ctx, image, x, y, width, height) {
- const sourceWidth = Number(image?.videoWidth || image?.width || image?.naturalWidth) || width;
- const sourceHeight = Number(image?.videoHeight || image?.height || image?.naturalHeight) || height;
+ const sourceWidth =
+ Number(image?.videoWidth || image?.width || image?.naturalWidth) || width;
+ const sourceHeight =
+ Number(image?.videoHeight || image?.height || image?.naturalHeight) ||
+ height;
const scale = Math.min(width / sourceWidth, height / sourceHeight);
const drawWidth = sourceWidth * scale;
const drawHeight = sourceHeight * scale;
@@ -513,7 +620,10 @@ function drawContain(ctx, image, x, y, width, height) {
}
function scaledOverlayFont(font, scale = 2) {
- return String(font).replace(/(\d+(?:\.\d+)?)px/, (_, size) => `${Number(size) * scale}px`);
+ return String(font).replace(
+ /(\d+(?:\.\d+)?)px/,
+ (_, size) => `${Number(size) * scale}px`,
+ );
}
function drawEvidenceFallback(ctx, observation, width, height) {
@@ -539,7 +649,12 @@ function ellipsizeEvidenceText(value, limit) {
}
/** Repaint one stable frame with a poster or the current source-video frame. */
-export function updateEvidenceFrame(canvas, observation, media = null, options = {}) {
+export function updateEvidenceFrame(
+ canvas,
+ observation,
+ media = null,
+ options = {},
+) {
if (!canvas || typeof canvas.getContext !== 'function') return false;
const ctx = canvas.getContext('2d');
if (!ctx) return false;
@@ -557,16 +672,19 @@ export function updateEvidenceFrame(canvas, observation, media = null, options =
if (media) {
try {
const fit = String(
- observation.media?.fit || (layout.kind === 'portrait' ? 'contain' : 'cover'),
+ observation.media?.fit ||
+ (layout.kind === 'portrait' ? 'contain' : 'cover'),
);
- if (fit === 'contain') drawContain(ctx, media, 0, 0, canvas.width, mediaHeight);
+ if (fit === 'contain')
+ drawContain(ctx, media, 0, 0, canvas.width, mediaHeight);
else drawCover(ctx, media, 0, 0, canvas.width, mediaHeight);
mediaRendered = true;
} catch {
// The deterministic local fallback below remains the visual source.
}
}
- if (!mediaRendered) drawEvidenceFallback(ctx, observation, canvas.width, mediaHeight);
+ if (!mediaRendered)
+ drawEvidenceFallback(ctx, observation, canvas.width, mediaHeight);
if (options.videoActive && mediaRendered) {
const mediaProgress = clampUnit(options.mediaProgress);
@@ -592,8 +710,14 @@ export function updateEvidenceFrame(canvas, observation, media = null, options =
);
ctx.fillStyle = WORLD_OVERLAY_STYLE.detail;
ctx.font = scaledOverlayFont(WORLD_OVERLAY_STYLE.fontDetail);
- const publisher = String(observation.media?.publisher || 'SOURCE LINK').toUpperCase();
- ctx.fillText(`${ellipsizeEvidenceText(publisher, 24)} / OPEN`, 16, infoY + 53);
+ const publisher = String(
+ observation.media?.publisher || 'SOURCE LINK',
+ ).toUpperCase();
+ ctx.fillText(
+ `${ellipsizeEvidenceText(publisher, 24)} / OPEN`,
+ 16,
+ infoY + 53,
+ );
return true;
}
@@ -752,9 +876,7 @@ function createPanel(event, handlers) {
const refs = collectPanelReferences(panel);
refs.beforeDate.textContent = event.imagery.before.label;
refs.afterDate.textContent = event.imagery.after.label;
- refs.time.textContent = (
- `~0:00 / ${elapsedLabel(1, event.reconstruction.elapsedSeconds)}`
- );
+ refs.time.textContent = `~0:00 / ${elapsedLabel(1, event.reconstruction.elapsedSeconds)}`;
refs.caveat.textContent = event.reconstruction.caveat;
for (const report of event.fieldReports) {
@@ -770,25 +892,53 @@ function createPanel(event, handlers) {
refs.reportList.appendChild(button);
}
- panel.querySelector('[data-action="close"]').addEventListener('click', handlers.close);
- panel.querySelector('[data-action="play"]').addEventListener('click', handlers.play);
- panel.querySelector('[data-action="play-scene"]').addEventListener('click', handlers.playScene);
- panel.querySelector('[data-action="cinematic"]').addEventListener('click', handlers.toggleCinematic);
- panel.querySelector('[data-action="previous-beat"]').addEventListener('click', handlers.previousBeat);
- panel.querySelector('[data-action="next-beat"]').addEventListener('click', handlers.nextBeat);
- panel.querySelector('[data-action="story-replay"]').addEventListener('click', handlers.replayCinematic);
- panel.querySelector('[data-action="open-source"]').addEventListener('click', handlers.openActiveSource);
- panel.querySelector('[data-action="geolocation-map"]').addEventListener('click', handlers.openGeolocationMap);
- panel.querySelector('[data-action="corridor"]').addEventListener('click', handlers.focusCorridor);
- panel.querySelector('[data-role="split"]').addEventListener('input', (inputEvent) => {
- handlers.setSplit(Number(inputEvent.target.value) / 100);
- });
- panel.querySelector('[data-role="progress"]').addEventListener('input', (inputEvent) => {
- handlers.previewProgress(Number(inputEvent.target.value) / 1000);
- });
- panel.querySelector('[data-role="progress"]').addEventListener('change', (inputEvent) => {
- handlers.commitProgress(Number(inputEvent.target.value) / 1000, { final: true });
- });
+ panel
+ .querySelector('[data-action="close"]')
+ .addEventListener('click', handlers.close);
+ panel
+ .querySelector('[data-action="play"]')
+ .addEventListener('click', handlers.play);
+ panel
+ .querySelector('[data-action="play-scene"]')
+ .addEventListener('click', handlers.playScene);
+ panel
+ .querySelector('[data-action="cinematic"]')
+ .addEventListener('click', handlers.toggleCinematic);
+ panel
+ .querySelector('[data-action="previous-beat"]')
+ .addEventListener('click', handlers.previousBeat);
+ panel
+ .querySelector('[data-action="next-beat"]')
+ .addEventListener('click', handlers.nextBeat);
+ panel
+ .querySelector('[data-action="story-replay"]')
+ .addEventListener('click', handlers.replayCinematic);
+ panel
+ .querySelector('[data-action="open-source"]')
+ .addEventListener('click', handlers.openActiveSource);
+ panel
+ .querySelector('[data-action="geolocation-map"]')
+ .addEventListener('click', handlers.openGeolocationMap);
+ panel
+ .querySelector('[data-action="corridor"]')
+ .addEventListener('click', handlers.focusCorridor);
+ panel
+ .querySelector('[data-role="split"]')
+ .addEventListener('input', (inputEvent) => {
+ handlers.setSplit(Number(inputEvent.target.value) / 100);
+ });
+ panel
+ .querySelector('[data-role="progress"]')
+ .addEventListener('input', (inputEvent) => {
+ handlers.previewProgress(Number(inputEvent.target.value) / 1000);
+ });
+ panel
+ .querySelector('[data-role="progress"]')
+ .addEventListener('change', (inputEvent) => {
+ handlers.commitProgress(Number(inputEvent.target.value) / 1000, {
+ final: true,
+ });
+ });
const rightRail = document.getElementById?.('right-context-rail');
rightRail?.classList.add('bhote-event-active');
(rightRail || document.body).appendChild(panel);
@@ -893,13 +1043,17 @@ export function createBhoteKoshiEventLayer({
let _comparisonSurfaceGeneration = 0;
function wantsTerrainComparison() {
- return _presentation === BHOTE_KOSHI_SCENE_PRESENTATION
- && _sceneControls.imageryComparison === true;
+ return (
+ _presentation === BHOTE_KOSHI_SCENE_PRESENTATION &&
+ _sceneControls.imageryComparison === true
+ );
}
function usesPhotorealFloodSurface() {
- return _mapStackController?.getActiveId?.() === 'photoreal'
- && !wantsTerrainComparison();
+ return (
+ _mapStackController?.getActiveId?.() === 'photoreal' &&
+ !wantsTerrainComparison()
+ );
}
async function syncTerrainComparison() {
@@ -930,10 +1084,14 @@ export function createBhoteKoshiEventLayer({
_sceneController = controller;
if (typeof controller?.subscribeSceneClock === 'function') {
_sceneClockUnsubscribe = controller.subscribeSceneClock((snapshot) => {
- if (_presentation !== BHOTE_KOSHI_SCENE_PRESENTATION
- || !_sceneContext?.sceneId
- || snapshot?.sceneId !== _sceneContext.sceneId) return;
- const playbackStopped = _sceneContext.running === true && snapshot.running === false;
+ if (
+ _presentation !== BHOTE_KOSHI_SCENE_PRESENTATION ||
+ !_sceneContext?.sceneId ||
+ snapshot?.sceneId !== _sceneContext.sceneId
+ )
+ return;
+ const playbackStopped =
+ _sceneContext.running === true && snapshot.running === false;
_sceneContext = { ..._sceneContext, ...snapshot };
if (playbackStopped || snapshot.stopped === true) {
_sceneMediaPlaybackCompletedBeatId = _sceneBeatId;
@@ -982,13 +1140,18 @@ export function createBhoteKoshiEventLayer({
}
async function resolveEvidenceAnchors(event, signal) {
- const observations = Array.isArray(event.evidenceSpine) ? event.evidenceSpine : [];
+ const observations = Array.isArray(event.evidenceSpine)
+ ? event.evidenceSpine
+ : [];
let sampled = null;
try {
sampled = await terrainSampler(_viewer, observations, { signal });
} catch (error) {
if (error?.name === 'AbortError') throw error;
- console.warn('[Data:BhoteKoshi] Evidence terrain sampling fell back:', error);
+ console.warn(
+ '[Data:BhoteKoshi] Evidence terrain sampling fell back:',
+ error,
+ );
}
throwIfEnableCancelled(signal, _enabled);
return observations.map((observation, index) => {
@@ -1007,15 +1170,26 @@ export function createBhoteKoshiEventLayer({
if (!_enabled) return null;
if (_witnessEmbedActive && !breadcrumb) return null;
if (_presentation === BHOTE_KOSHI_SCENE_PRESENTATION) {
- if (_sceneSurface !== BHOTE_KOSHI_SCENE_EVIDENCE_BEAT || breadcrumb) return null;
- if (_evidenceTimeline[index]?.observation?.id !== _sceneBeatId) return null;
- if (sceneEvidenceDefersUntilCameraSettles() && !sceneEvidenceCameraSettled()) return null;
+ if (_sceneSurface !== BHOTE_KOSHI_SCENE_EVIDENCE_BEAT || breadcrumb)
+ return null;
+ if (_evidenceTimeline[index]?.observation?.id !== _sceneBeatId)
+ return null;
+ if (
+ sceneEvidenceDefersUntilCameraSettles() &&
+ !sceneEvidenceCameraSettled()
+ )
+ return null;
if (index === _embeddedEvidenceIndex) return null;
- if (_evidenceTimeline[index]?.observation?.id === _sceneMediaPlaybackCompletedBeatId) return null;
+ if (
+ _evidenceTimeline[index]?.observation?.id ===
+ _sceneMediaPlaybackCompletedBeatId
+ )
+ return null;
return _evidenceAnchors[index] || null;
}
const activeIndex = activeEvidenceIndex(_progress, _evidenceTimeline);
- if (breadcrumb ? index >= activeIndex : index !== activeIndex) return null;
+ if (breadcrumb ? index >= activeIndex : index !== activeIndex)
+ return null;
if (!breadcrumb && index === _embeddedEvidenceIndex) return null;
return _evidenceAnchors[index] || null;
};
@@ -1029,16 +1203,17 @@ export function createBhoteKoshiEventLayer({
witness.lat,
(Number(witness.elevationM) || 0) + 18,
);
- const shown = _embeddedMedia?.show?.({
- observation: {
- title: witness.title,
- shortTitle: 'Rasuwagadhi witness',
- media: { sourceUrl: witness.sourceUrl },
- },
- anchor,
- sourceUrl: witness.sourceUrl,
- autoplay: false,
- }) === true;
+ const shown =
+ _embeddedMedia?.show?.({
+ observation: {
+ title: witness.title,
+ shortTitle: 'Rasuwagadhi witness',
+ media: { sourceUrl: witness.sourceUrl },
+ },
+ anchor,
+ sourceUrl: witness.sourceUrl,
+ autoplay: false,
+ }) === true;
if (shown) _embeddedEvidenceIndex = -1;
return shown;
}
@@ -1060,11 +1235,12 @@ export function createBhoteKoshiEventLayer({
_embeddedMedia?.hide?.();
return false;
}
- const shown = _embeddedMedia?.show?.({
- observation,
- anchor: _evidenceAnchors[index],
- autoplay: sceneEvidenceMediaAutoplays() || _playing,
- }) === true;
+ const shown =
+ _embeddedMedia?.show?.({
+ observation,
+ anchor: _evidenceAnchors[index],
+ autoplay: sceneEvidenceMediaAutoplays() || _playing,
+ }) === true;
_embeddedEvidenceIndex = shown ? index : -1;
if (shown && sceneEvidenceMediaAutoplays()) startSceneMediaPlayback(index);
return shown;
@@ -1086,15 +1262,18 @@ export function createBhoteKoshiEventLayer({
}
function sceneEvidenceMediaAutoplays() {
- return _presentation === BHOTE_KOSHI_SCENE_PRESENTATION
- && _sceneSurface === BHOTE_KOSHI_SCENE_EVIDENCE_BEAT
- && _sceneControls.evidenceMediaAutoplay === true
- && !_sceneControls.timelineSeek;
+ return (
+ _presentation === BHOTE_KOSHI_SCENE_PRESENTATION &&
+ _sceneSurface === BHOTE_KOSHI_SCENE_EVIDENCE_BEAT &&
+ _sceneControls.evidenceMediaAutoplay === true &&
+ !_sceneControls.timelineSeek
+ );
}
function sceneTimelineSeek() {
- return _presentation === BHOTE_KOSHI_SCENE_PRESENTATION
- && _sceneControls.timelineSeek && typeof _sceneControls.timelineSeek === 'object'
+ return _presentation === BHOTE_KOSHI_SCENE_PRESENTATION &&
+ _sceneControls.timelineSeek &&
+ typeof _sceneControls.timelineSeek === 'object'
? _sceneControls.timelineSeek
: null;
}
@@ -1112,7 +1291,8 @@ export function createBhoteKoshiEventLayer({
}
function stopSceneMediaPlayback({ pauseMedia = true } = {}) {
- const hadPlayback = _sceneMediaPlaybackTimer != null || _sceneMediaPlaybackBeatId != null;
+ const hadPlayback =
+ _sceneMediaPlaybackTimer != null || _sceneMediaPlaybackBeatId != null;
if (_sceneMediaPlaybackTimer != null) {
globalThis.window?.clearTimeout?.(_sceneMediaPlaybackTimer);
_sceneMediaPlaybackTimer = null;
@@ -1132,34 +1312,53 @@ export function createBhoteKoshiEventLayer({
SCENE_MEDIA_EXIT_SECONDS,
Number(_sceneControls.mediaExitDurationSec) || 0,
);
- _sceneMediaPlaybackTimer = globalThis.window?.setTimeout?.(() => {
- if (_sceneMediaPlaybackBeatId !== beatId) return;
- _sceneMediaPlaybackTimer = null;
- _sceneMediaPlaybackBeatId = null;
- renderHost.request('bhote-koshi-scene-media-exit');
- }, exitDurationSec * 1000) ?? null;
+ _sceneMediaPlaybackTimer =
+ globalThis.window?.setTimeout?.(() => {
+ if (_sceneMediaPlaybackBeatId !== beatId) return;
+ _sceneMediaPlaybackTimer = null;
+ _sceneMediaPlaybackBeatId = null;
+ renderHost.request('bhote-koshi-scene-media-exit');
+ }, exitDurationSec * 1000) ?? null;
}
function startSceneMediaPlayback(index) {
if (!sceneEvidenceMediaAutoplays()) return;
const beatId = _evidenceTimeline[index]?.observation?.id;
- if (!beatId || beatId === _sceneMediaPlaybackCompletedBeatId
- || beatId === _sceneMediaPlaybackBeatId) return;
+ if (
+ !beatId ||
+ beatId === _sceneMediaPlaybackCompletedBeatId ||
+ beatId === _sceneMediaPlaybackBeatId
+ )
+ return;
stopSceneMediaPlayback({ pauseMedia: false });
_sceneMediaPlaybackBeatId = beatId;
_embeddedMedia?.play?.();
if (sceneUsesTrimmedMedia(beatId)) {
const deadline = Date.now() + 18000;
const check = () => {
- if (!_enabled || _sceneBeatId !== beatId || _sceneMediaPlaybackBeatId !== beatId) return;
+ if (
+ !_enabled ||
+ _sceneBeatId !== beatId ||
+ _sceneMediaPlaybackBeatId !== beatId
+ )
+ return;
const state = _embeddedMedia?.getPlaybackState?.();
- if (['completed', 'blocked', 'unavailable', 'timeout', 'cancelled'].includes(state?.phase)
- || Date.now() >= deadline) {
+ if (
+ [
+ 'completed',
+ 'blocked',
+ 'unavailable',
+ 'timeout',
+ 'cancelled',
+ ].includes(state?.phase) ||
+ Date.now() >= deadline
+ ) {
_sceneMediaPlaybackTimer = null;
completeSceneMediaPlayback();
return;
}
- _sceneMediaPlaybackTimer = globalThis.window?.setTimeout?.(check, 100) ?? null;
+ _sceneMediaPlaybackTimer =
+ globalThis.window?.setTimeout?.(check, 100) ?? null;
};
check();
return;
@@ -1168,80 +1367,115 @@ export function createBhoteKoshiEventLayer({
SCENE_MEDIA_EXIT_SECONDS,
Number(_sceneControls.mediaExitDurationSec) || 0,
);
- const postMediaPathDurationSec = scenePathMovesDuringCamera() ? 0
- : sceneUsesSourcePath() ? Number(_sceneControls.evidencePathDurationSec) || 0
- : sceneEvidenceSequenceAnimates() && _sceneControls.evidencePath !== 'none'
+ const postMediaPathDurationSec = scenePathMovesDuringCamera()
+ ? 0
+ : sceneUsesSourcePath()
+ ? Number(_sceneControls.evidencePathDurationSec) || 0
+ : sceneEvidenceSequenceAnimates() &&
+ _sceneControls.evidencePath !== 'none'
? Number(_sceneControls.evidenceSequenceDurationSec) || 0
: 0;
- const availableMediaSec = (Number(_sceneContext?.holdSec) || 0)
- - exitDurationSec
- - postMediaPathDurationSec;
+ const availableMediaSec =
+ (Number(_sceneContext?.holdSec) || 0) -
+ exitDurationSec -
+ postMediaPathDurationSec;
const durationSec = Math.max(
SCENE_MEDIA_MIN_PLAYBACK_SECONDS,
Number(_sceneControls.mediaPlaybackHoldSec) || 0,
availableMediaSec,
);
- _sceneMediaPlaybackTimer = globalThis.window?.setTimeout?.(() => {
- if (!_enabled || _sceneBeatId !== beatId || _sceneMediaPlaybackBeatId !== beatId) return;
- _sceneMediaPlaybackTimer = null;
- completeSceneMediaPlayback();
- }, durationSec * 1000) ?? null;
+ _sceneMediaPlaybackTimer =
+ globalThis.window?.setTimeout?.(() => {
+ if (
+ !_enabled ||
+ _sceneBeatId !== beatId ||
+ _sceneMediaPlaybackBeatId !== beatId
+ )
+ return;
+ _sceneMediaPlaybackTimer = null;
+ completeSceneMediaPlayback();
+ }, durationSec * 1000) ?? null;
}
function sceneUsesTrimmedMedia(beatId) {
- const media = _evidenceTimeline.find((item) => item.observation?.id === beatId)?.observation?.media;
- return Number(media?.embedEndAtSec) > Number(media?.embedStartAtSec || 0)
- && resolveEmbeddedMediaSource(media?.sourceUrl)?.provider === 'youtube';
+ const media = _evidenceTimeline.find(
+ (item) => item.observation?.id === beatId,
+ )?.observation?.media;
+ return (
+ Number(media?.embedEndAtSec) > Number(media?.embedStartAtSec || 0) &&
+ resolveEmbeddedMediaSource(media?.sourceUrl)?.provider === 'youtube'
+ );
}
function getSceneShotMediaHold(beatId) {
- if (!_enabled || _sceneBeatId !== beatId || !sceneEvidenceMediaAutoplays()
- || !sceneUsesTrimmedMedia(beatId)) return null;
+ if (
+ !_enabled ||
+ _sceneBeatId !== beatId ||
+ !sceneEvidenceMediaAutoplays() ||
+ !sceneUsesTrimmedMedia(beatId)
+ )
+ return null;
return {
- pending: _sceneMediaPlaybackCompletedBeatId !== beatId || _sceneMediaPlaybackBeatId === beatId,
+ pending:
+ _sceneMediaPlaybackCompletedBeatId !== beatId ||
+ _sceneMediaPlaybackBeatId === beatId,
maxWaitMs: 20000,
};
}
function sceneMediaPlaybackBlocksPath() {
- return _sceneMediaPlaybackBeatId === _sceneBeatId
- && _sceneControls.evidencePathDuringMedia !== true;
+ return (
+ _sceneMediaPlaybackBeatId === _sceneBeatId &&
+ _sceneControls.evidencePathDuringMedia !== true
+ );
}
function sceneUsesSourcePath() {
- return _presentation === BHOTE_KOSHI_SCENE_PRESENTATION
- && _sceneControls.evidencePath === 'source';
+ return (
+ _presentation === BHOTE_KOSHI_SCENE_PRESENTATION &&
+ _sceneControls.evidencePath === 'source'
+ );
}
function sceneUsesPathHistory() {
- return _presentation === BHOTE_KOSHI_SCENE_PRESENTATION
- && _sceneControls.evidencePath === 'history';
+ return (
+ _presentation === BHOTE_KOSHI_SCENE_PRESENTATION &&
+ _sceneControls.evidencePath === 'history'
+ );
}
function sceneUsesCumulativePath() {
- return (sceneUsesSourcePath() || sceneUsesPathHistory())
- && _sceneControls.evidencePathHistory === true;
+ return (
+ (sceneUsesSourcePath() || sceneUsesPathHistory()) &&
+ _sceneControls.evidencePathHistory === true
+ );
}
function scenePathMovesDuringCamera() {
- return _presentation === BHOTE_KOSHI_SCENE_PRESENTATION
- && _sceneSurface === BHOTE_KOSHI_SCENE_EVIDENCE_BEAT
- && _sceneControls.evidencePathDuringCamera === true;
+ return (
+ _presentation === BHOTE_KOSHI_SCENE_PRESENTATION &&
+ _sceneSurface === BHOTE_KOSHI_SCENE_EVIDENCE_BEAT &&
+ _sceneControls.evidencePathDuringCamera === true
+ );
}
function sceneKeepsFloodVisible() {
- return _presentation === BHOTE_KOSHI_SCENE_PRESENTATION
- && _sceneControls.evidencePathPersistent === true;
+ return (
+ _presentation === BHOTE_KOSHI_SCENE_PRESENTATION &&
+ _sceneControls.evidencePathPersistent === true
+ );
}
function scenePathCanPrepare() {
- return !sceneEvidenceDefersUntilCameraSettles()
- || sceneEvidenceCameraSettled()
- || scenePathMovesDuringCamera()
+ return (
+ !sceneEvidenceDefersUntilCameraSettles() ||
+ sceneEvidenceCameraSettled() ||
+ scenePathMovesDuringCamera() ||
// Media-led shots defer the new segment, not the completed upstream
// prefix. Reconstruct that prefix for travel, direct loads and replay.
- || (sceneKeepsFloodVisible() && sceneUsesCumulativePath())
- || sceneUsesPathHistory();
+ (sceneKeepsFloodVisible() && sceneUsesCumulativePath()) ||
+ sceneUsesPathHistory()
+ );
}
function sourcePathPositions(beatId) {
@@ -1256,8 +1490,14 @@ export function createBhoteKoshiEventLayer({
// a single high sample can make the front dive backward in screen space.
if (_sceneControls.evidencePathElevation !== 'source') {
// Sample once while preparing the shot, never from an animation frame.
- try { height = _viewer?.scene?.sampleHeight?.(position, [_floodCore, _floodHalo, _surgePoint].filter(Boolean)); } catch {}
- if (!Number.isFinite(height)) height = _viewer?.scene?.globe?.getHeight?.(position);
+ try {
+ height = _viewer?.scene?.sampleHeight?.(
+ position,
+ [_floodCore, _floodHalo, _surgePoint].filter(Boolean),
+ );
+ } catch {}
+ if (!Number.isFinite(height))
+ height = _viewer?.scene?.globe?.getHeight?.(position);
}
if (!Number.isFinite(height)) height = point.fallbackElevationM;
return Cesium.Cartesian3.fromDegrees(point.lon, point.lat, height + 8);
@@ -1277,7 +1517,10 @@ export function createBhoteKoshiEventLayer({
}
function prepareScenePath() {
- if ((!sceneUsesSourcePath() && !sceneUsesPathHistory()) || !scenePathCanPrepare()) {
+ if (
+ (!sceneUsesSourcePath() && !sceneUsesPathHistory()) ||
+ !scenePathCanPrepare()
+ ) {
_scenePathBeatId = null;
_scenePathPositions = [];
_scenePathStartProgress = 0;
@@ -1323,66 +1566,89 @@ export function createBhoteKoshiEventLayer({
}
function activeFloodPositions() {
- return (sceneUsesSourcePath() || sceneUsesPathHistory())
+ return sceneUsesSourcePath() || sceneUsesPathHistory()
? _scenePathPositions
: _terrainPositions;
}
function sceneEvidenceDefersUntilCameraSettles() {
- return _presentation === BHOTE_KOSHI_SCENE_PRESENTATION
- && _sceneSurface === BHOTE_KOSHI_SCENE_EVIDENCE_BEAT
- && _sceneControls.deferEvidenceUntilCameraSettled === true;
+ return (
+ _presentation === BHOTE_KOSHI_SCENE_PRESENTATION &&
+ _sceneSurface === BHOTE_KOSHI_SCENE_EVIDENCE_BEAT &&
+ _sceneControls.deferEvidenceUntilCameraSettled === true
+ );
}
function sceneEvidenceSequenceAnimates() {
- return _presentation === BHOTE_KOSHI_SCENE_PRESENTATION
- && _sceneSurface === BHOTE_KOSHI_SCENE_EVIDENCE_BEAT
- && _sceneControls.evidenceSequence === true;
+ return (
+ _presentation === BHOTE_KOSHI_SCENE_PRESENTATION &&
+ _sceneSurface === BHOTE_KOSHI_SCENE_EVIDENCE_BEAT &&
+ _sceneControls.evidenceSequence === true
+ );
}
function sceneEvidenceCameraSettled() {
- return !sceneEvidenceDefersUntilCameraSettles()
- || _sceneControls.cameraSettled === true;
+ return (
+ !sceneEvidenceDefersUntilCameraSettles() ||
+ _sceneControls.cameraSettled === true
+ );
}
function sceneEvidencePresentation(index) {
const beat = _evidenceTimeline[index];
- if (beat && _presentation === BHOTE_KOSHI_SCENE_PRESENTATION
- && _sceneControls.evidenceHoldCard === true) {
- const span = Math.min(1, beat.deactivationProgress) - beat.activationProgress;
+ if (
+ beat &&
+ _presentation === BHOTE_KOSHI_SCENE_PRESENTATION &&
+ _sceneControls.evidenceHoldCard === true
+ ) {
+ const span =
+ Math.min(1, beat.deactivationProgress) - beat.activationProgress;
const reveal = Math.min(1, _sceneRevealProgress * 6);
- return evidenceBeatPresentation(beat.activationProgress + span * _sceneBeatReveal * reveal, beat);
+ return evidenceBeatPresentation(
+ beat.activationProgress + span * _sceneBeatReveal * reveal,
+ beat,
+ );
}
- if (!beat || sceneEvidenceSequenceAnimates() || !sceneEvidenceDefersUntilCameraSettles()) {
+ if (
+ !beat ||
+ sceneEvidenceSequenceAnimates() ||
+ !sceneEvidenceDefersUntilCameraSettles()
+ ) {
return evidenceBeatPresentation(_progress, beat);
}
const start = clampUnit(beat.activationProgress);
const end = Math.max(start, Math.min(1, beat.deactivationProgress));
- const revealProgress = start
- + (end - start) * _sceneBeatReveal * clampUnit(_sceneRevealProgress);
+ const revealProgress =
+ start +
+ (end - start) * _sceneBeatReveal * clampUnit(_sceneRevealProgress);
return evidenceBeatPresentation(revealProgress, beat);
}
function stopSceneEvidenceReveal() {
- if (_sceneRevealAnimationFrame != null) cancelAnimationFrame(_sceneRevealAnimationFrame);
+ if (_sceneRevealAnimationFrame != null)
+ cancelAnimationFrame(_sceneRevealAnimationFrame);
_sceneRevealAnimationFrame = null;
if (_sceneRevealHoldActive) renderHost.release(SCENE_REVEAL_RENDER_HOLD);
_sceneRevealHoldActive = false;
}
function stopScenePathReveal() {
- if (_scenePathAnimationFrame != null) cancelAnimationFrame(_scenePathAnimationFrame);
+ if (_scenePathAnimationFrame != null)
+ cancelAnimationFrame(_scenePathAnimationFrame);
_scenePathAnimationFrame = null;
- if (_scenePathDeadlineTimer != null) globalThis.window?.clearTimeout?.(_scenePathDeadlineTimer);
+ if (_scenePathDeadlineTimer != null)
+ globalThis.window?.clearTimeout?.(_scenePathDeadlineTimer);
_scenePathDeadlineTimer = null;
if (_scenePathHoldActive) renderHost.release(SCENE_PATH_RENDER_HOLD);
_scenePathHoldActive = false;
}
function finishScenePathReveal(targetProgress, requestReason) {
- if (_scenePathAnimationFrame != null) cancelAnimationFrame(_scenePathAnimationFrame);
+ if (_scenePathAnimationFrame != null)
+ cancelAnimationFrame(_scenePathAnimationFrame);
_scenePathAnimationFrame = null;
- if (_scenePathDeadlineTimer != null) globalThis.window?.clearTimeout?.(_scenePathDeadlineTimer);
+ if (_scenePathDeadlineTimer != null)
+ globalThis.window?.clearTimeout?.(_scenePathDeadlineTimer);
_scenePathDeadlineTimer = null;
_scenePathRevealProgress = targetProgress;
updateFloodTrace(_scenePathRevealProgress);
@@ -1394,8 +1660,12 @@ export function createBhoteKoshiEventLayer({
function scenePathProgressWindow() {
if (sceneUsesPathHistory()) return { start: 1, target: 1 };
- if (sceneUsesSourcePath()) return { start: _scenePathStartProgress, target: 1 };
- const sequenceWindow = evidenceSequenceWindow(_sceneBeatId, _evidenceTimeline);
+ if (sceneUsesSourcePath())
+ return { start: _scenePathStartProgress, target: 1 };
+ const sequenceWindow = evidenceSequenceWindow(
+ _sceneBeatId,
+ _evidenceTimeline,
+ );
if (!sequenceWindow) return null;
return {
start: floodProgressForStoryProgress(
@@ -1420,10 +1690,16 @@ export function createBhoteKoshiEventLayer({
const progressWindow = scenePathProgressWindow();
const startProgress = clampUnit(progressWindow?.start ?? 0);
- const targetProgress = Math.max(startProgress, clampUnit(progressWindow?.target ?? startProgress));
+ const targetProgress = Math.max(
+ startProgress,
+ clampUnit(progressWindow?.target ?? startProgress),
+ );
const cameraProgress = clampUnit(Number(seek.cameraProgress) || 0);
const holdElapsedSec = Math.max(0, Number(seek.holdElapsedSec) || 0);
- const flightDurationSec = Math.max(0.2, Number(seek.flightDurationSec) || 0.2);
+ const flightDurationSec = Math.max(
+ 0.2,
+ Number(seek.flightDurationSec) || 0.2,
+ );
if (sceneUsesPathHistory()) {
_scenePathRevealProgress = 1;
@@ -1432,34 +1708,57 @@ export function createBhoteKoshiEventLayer({
}
if (scenePathMovesDuringCamera()) {
- const authoredDurationSec = Number(_sceneControls.evidencePathTravelDurationSec);
- const pathDurationSec = Math.max(0.12,
+ const authoredDurationSec = Number(
+ _sceneControls.evidencePathTravelDurationSec,
+ );
+ const pathDurationSec = Math.max(
+ 0.12,
Number.isFinite(authoredDurationSec) && authoredDurationSec > 0
? Math.min(authoredDurationSec, flightDurationSec * 0.9)
- : Math.min(2.5, flightDurationSec * 0.6));
+ : Math.min(2.5, flightDurationSec * 0.6),
+ );
const cameraElapsedSec = cameraProgress * flightDurationSec;
const linear = clampUnit(cameraElapsedSec / pathDurationSec);
const eased = 1 - (1 - linear) ** 2;
- _scenePathRevealProgress = startProgress + (targetProgress - startProgress) * eased;
- _sceneRevealProgress = cameraProgress >= 1
- ? 1
- : clampUnit(cameraElapsedSec / Math.max(0.25,
- Number(_sceneControls.evidenceRevealDurationSec) || 0.9));
+ _scenePathRevealProgress =
+ startProgress + (targetProgress - startProgress) * eased;
+ _sceneRevealProgress =
+ cameraProgress >= 1
+ ? 1
+ : clampUnit(
+ cameraElapsedSec /
+ Math.max(
+ 0.25,
+ Number(_sceneControls.evidenceRevealDurationSec) || 0.9,
+ ),
+ );
return true;
}
- const mediaDurationSec = _sceneControls.evidenceMediaAutoplay === true
- ? Math.max(SCENE_MEDIA_MIN_PLAYBACK_SECONDS,
- Number(_sceneControls.mediaPlaybackHoldSec) || 0)
- : 0;
- const exitDurationSec = _sceneControls.evidenceMediaAutoplay === true
- ? Math.max(SCENE_MEDIA_EXIT_SECONDS, Number(_sceneControls.mediaExitDurationSec) || 0)
- : 0;
- const pathDurationSec = Math.max(0.25, Number(
- sceneUsesSourcePath() ? _sceneControls.evidencePathDurationSec
- : sceneEvidenceSequenceAnimates() ? _sceneControls.evidenceSequenceDurationSec
- : _sceneControls.evidenceRevealDurationSec,
- ) || 0.9);
+ const mediaDurationSec =
+ _sceneControls.evidenceMediaAutoplay === true
+ ? Math.max(
+ SCENE_MEDIA_MIN_PLAYBACK_SECONDS,
+ Number(_sceneControls.mediaPlaybackHoldSec) || 0,
+ )
+ : 0;
+ const exitDurationSec =
+ _sceneControls.evidenceMediaAutoplay === true
+ ? Math.max(
+ SCENE_MEDIA_EXIT_SECONDS,
+ Number(_sceneControls.mediaExitDurationSec) || 0,
+ )
+ : 0;
+ const pathDurationSec = Math.max(
+ 0.25,
+ Number(
+ sceneUsesSourcePath()
+ ? _sceneControls.evidencePathDurationSec
+ : sceneEvidenceSequenceAnimates()
+ ? _sceneControls.evidenceSequenceDurationSec
+ : _sceneControls.evidenceRevealDurationSec,
+ ) || 0.9,
+ );
const pathStartSec = Math.max(
0,
Number(_sceneControls.evidencePathStartDelaySec) || 0,
@@ -1469,14 +1768,16 @@ export function createBhoteKoshiEventLayer({
);
const linear = clampUnit((holdElapsedSec - pathStartSec) / pathDurationSec);
_sceneRevealProgress = smoothstep(0, 1, linear);
- _scenePathRevealProgress = startProgress
- + (targetProgress - startProgress) * _sceneRevealProgress;
+ _scenePathRevealProgress =
+ startProgress + (targetProgress - startProgress) * _sceneRevealProgress;
const sequenceWindow = sceneEvidenceSequenceAnimates()
? evidenceSequenceWindow(_sceneBeatId, _evidenceTimeline)
: null;
if (sequenceWindow) {
- _progress = sequenceWindow.startProgress
- + (sequenceWindow.targetProgress - sequenceWindow.startProgress) * _sceneRevealProgress;
+ _progress =
+ sequenceWindow.startProgress +
+ (sequenceWindow.targetProgress - sequenceWindow.startProgress) *
+ _sceneRevealProgress;
}
return true;
}
@@ -1489,7 +1790,10 @@ export function createBhoteKoshiEventLayer({
const progressWindow = scenePathProgressWindow();
if (!progressWindow) return;
const startProgress = clampUnit(progressWindow.start);
- const targetProgress = Math.max(startProgress, clampUnit(progressWindow.target));
+ const targetProgress = Math.max(
+ startProgress,
+ clampUnit(progressWindow.target),
+ );
_scenePathRevealProgress = startProgress;
updateFloodTrace(_scenePathRevealProgress);
updateSurgeFront(_scenePathRevealProgress);
@@ -1503,17 +1807,21 @@ export function createBhoteKoshiEventLayer({
// travel timing when supplied, bounded by the actual flight so the amber
// head always finishes ahead of the camera rather than sharing one global
// speed across every segment.
- const authoredDurationSec = Number(_sceneControls.evidencePathTravelDurationSec);
- const durationSec = Number.isFinite(authoredDurationSec) && authoredDurationSec > 0
- ? Math.min(authoredDurationSec, travelDurationSec * 0.9)
- : Math.min(2.5, travelDurationSec * 0.6);
+ const authoredDurationSec = Number(
+ _sceneControls.evidencePathTravelDurationSec,
+ );
+ const durationSec =
+ Number.isFinite(authoredDurationSec) && authoredDurationSec > 0
+ ? Math.min(authoredDurationSec, travelDurationSec * 0.9)
+ : Math.min(2.5, travelDurationSec * 0.6);
const durationMs = Math.max(120, durationSec * 1000);
const travelId = travel.id;
let lastTickAt = performance.now();
let activeElapsedMs = 0;
const accrueActiveTime = (now) => {
const tickAt = Math.max(lastTickAt, Number(now) || lastTickAt);
- if (!sceneMediaPlaybackBlocksPath()) activeElapsedMs += tickAt - lastTickAt;
+ if (!sceneMediaPlaybackBlocksPath())
+ activeElapsedMs += tickAt - lastTickAt;
lastTickAt = tickAt;
return activeElapsedMs;
};
@@ -1524,32 +1832,36 @@ export function createBhoteKoshiEventLayer({
// blocked render loop still establishes the complete tail before arrival.
const finishAtDeadline = () => {
const currentTravel = _sceneControls.cameraTravel;
- if (!_enabled
- || !scenePathMovesDuringCamera()
- || !currentTravel?.active
- || currentTravel.cancelled
- || currentTravel.id !== travelId) return;
+ if (
+ !_enabled ||
+ !scenePathMovesDuringCamera() ||
+ !currentTravel?.active ||
+ currentTravel.cancelled ||
+ currentTravel.id !== travelId
+ )
+ return;
const remainingMs = durationMs - accrueActiveTime(performance.now());
if (remainingMs > 0) {
- _scenePathDeadlineTimer = globalThis.window?.setTimeout?.(
- finishAtDeadline,
- Math.max(32, remainingMs),
- ) ?? null;
+ _scenePathDeadlineTimer =
+ globalThis.window?.setTimeout?.(
+ finishAtDeadline,
+ Math.max(32, remainingMs),
+ ) ?? null;
return;
}
finishScenePathReveal(targetProgress, 'bhote-koshi-scene-path-deadline');
};
- _scenePathDeadlineTimer = globalThis.window?.setTimeout?.(
- finishAtDeadline,
- durationMs,
- ) ?? null;
+ _scenePathDeadlineTimer =
+ globalThis.window?.setTimeout?.(finishAtDeadline, durationMs) ?? null;
const step = (now) => {
const currentTravel = _sceneControls.cameraTravel;
- if (!_enabled
- || !scenePathMovesDuringCamera()
- || !currentTravel?.active
- || currentTravel.cancelled
- || currentTravel.id !== travelId) {
+ if (
+ !_enabled ||
+ !scenePathMovesDuringCamera() ||
+ !currentTravel?.active ||
+ currentTravel.cancelled ||
+ currentTravel.id !== travelId
+ ) {
stopScenePathReveal();
return;
}
@@ -1558,15 +1870,18 @@ export function createBhoteKoshiEventLayer({
// the camera reaches it. Combined with the shorter travel window, this
// keeps the front alongside or ahead of a normally eased Cesium flight.
const eased = 1 - (1 - linear) ** 2;
- _scenePathRevealProgress = startProgress
- + (targetProgress - startProgress) * eased;
+ _scenePathRevealProgress =
+ startProgress + (targetProgress - startProgress) * eased;
updateFloodTrace(_scenePathRevealProgress);
updateSurgeFront(_scenePathRevealProgress);
renderHost.request('bhote-koshi-scene-path-travel');
if (linear < 1) {
_scenePathAnimationFrame = requestAnimationFrame(step);
} else {
- finishScenePathReveal(targetProgress, 'bhote-koshi-scene-path-complete');
+ finishScenePathReveal(
+ targetProgress,
+ 'bhote-koshi-scene-path-complete',
+ );
}
};
_scenePathAnimationFrame = requestAnimationFrame(step);
@@ -1575,16 +1890,20 @@ export function createBhoteKoshiEventLayer({
function startSceneEvidenceReveal() {
stopSceneEvidenceReveal();
const sequenceAnimates = sceneEvidenceSequenceAnimates();
- if ((!sequenceAnimates && !sceneEvidenceDefersUntilCameraSettles())
- || !sceneEvidenceCameraSettled()) return;
+ if (
+ (!sequenceAnimates && !sceneEvidenceDefersUntilCameraSettles()) ||
+ !sceneEvidenceCameraSettled()
+ )
+ return;
const sequenceWindow = sequenceAnimates
? evidenceSequenceWindow(_sceneBeatId, _evidenceTimeline)
: null;
if (sequenceAnimates && !sequenceWindow) return;
_sceneRevealProgress = 0;
- const sourceWindow = sceneUsesSourcePath() || sceneUsesPathHistory()
- ? scenePathProgressWindow()
- : null;
+ const sourceWindow =
+ sceneUsesSourcePath() || sceneUsesPathHistory()
+ ? scenePathProgressWindow()
+ : null;
// The independent travel clock owns persistent path motion. Older beats
// retain the arrival-gated reveal behavior for both card and path.
if (!scenePathMovesDuringCamera()) {
@@ -1595,40 +1914,51 @@ export function createBhoteKoshiEventLayer({
const durationMs = Math.max(
250,
(Number(
- sceneUsesSourcePath() ? _sceneControls.evidencePathDurationSec : sequenceAnimates
- ? _sceneControls.evidenceSequenceDurationSec
- : _sceneControls.evidenceRevealDurationSec,
+ sceneUsesSourcePath()
+ ? _sceneControls.evidencePathDurationSec
+ : sequenceAnimates
+ ? _sceneControls.evidenceSequenceDurationSec
+ : _sceneControls.evidenceRevealDurationSec,
) || 0.9) * 1000,
);
- const targetFloodProgress = sourceWindow ? sourceWindow.target
- : _sceneControls.evidencePath === 'none' ? 0
- : floodProgressForStoryProgress(_progress, _event?.reconstruction?.storyFloodWindow);
+ const targetFloodProgress = sourceWindow
+ ? sourceWindow.target
+ : _sceneControls.evidencePath === 'none'
+ ? 0
+ : floodProgressForStoryProgress(
+ _progress,
+ _event?.reconstruction?.storyFloodWindow,
+ );
if (sequenceWindow) updateProgress(sequenceWindow.startProgress);
let lastTickAt = performance.now();
let activeElapsedMs = 0;
renderHost.hold(SCENE_REVEAL_RENDER_HOLD);
_sceneRevealHoldActive = true;
const step = (now) => {
- if (!_enabled
- || !sceneEvidenceCameraSettled()
- || (sequenceAnimates && !sceneEvidenceSequenceAnimates())) {
+ if (
+ !_enabled ||
+ !sceneEvidenceCameraSettled() ||
+ (sequenceAnimates && !sceneEvidenceSequenceAnimates())
+ ) {
stopSceneEvidenceReveal();
return;
}
const tickAt = Math.max(lastTickAt, Number(now) || lastTickAt);
- if (!sceneMediaPlaybackBlocksPath()) activeElapsedMs += tickAt - lastTickAt;
+ if (!sceneMediaPlaybackBlocksPath())
+ activeElapsedMs += tickAt - lastTickAt;
lastTickAt = tickAt;
const linear = clampUnit(activeElapsedMs / durationMs);
_sceneRevealProgress = smoothstep(0, 1, linear);
if (sequenceWindow) {
updateProgress(
- sequenceWindow.startProgress
- + (sequenceWindow.targetProgress - sequenceWindow.startProgress) * _sceneRevealProgress,
+ sequenceWindow.startProgress +
+ (sequenceWindow.targetProgress - sequenceWindow.startProgress) *
+ _sceneRevealProgress,
);
} else if (!scenePathMovesDuringCamera()) {
const floodProgress = sourceWindow
- ? sourceWindow.start
- + (sourceWindow.target - sourceWindow.start) * _sceneRevealProgress
+ ? sourceWindow.start +
+ (sourceWindow.target - sourceWindow.start) * _sceneRevealProgress
: targetFloodProgress * _sceneRevealProgress;
updateFloodTrace(floodProgress);
updateSurgeFront(floodProgress);
@@ -1647,22 +1977,42 @@ export function createBhoteKoshiEventLayer({
function stopEvidenceVideoFrameLoop(video = _evidenceVideo) {
if (_evidenceVideoFrameHandle == null) return;
- try { video?.cancelVideoFrameCallback?.(_evidenceVideoFrameHandle); } catch {}
+ try {
+ video?.cancelVideoFrameCallback?.(_evidenceVideoFrameHandle);
+ } catch {}
_evidenceVideoFrameHandle = null;
}
- function startEvidenceVideoFrameLoop(video, index, generation, clipIn, clipDuration) {
- if (!sceneEvidenceMediaAutoplays()
- || typeof video?.requestVideoFrameCallback !== 'function'
- || _evidenceVideoFrameHandle != null) return;
+ function startEvidenceVideoFrameLoop(
+ video,
+ index,
+ generation,
+ clipIn,
+ clipDuration,
+ ) {
+ if (
+ !sceneEvidenceMediaAutoplays() ||
+ typeof video?.requestVideoFrameCallback !== 'function' ||
+ _evidenceVideoFrameHandle != null
+ )
+ return;
const drawFrame = () => {
_evidenceVideoFrameHandle = null;
- if (!_enabled || generation !== _evidenceVideoGeneration
- || video !== _evidenceVideo || !sceneEvidenceMediaAutoplays()) return;
- if (redrawEvidenceSlot(index, video, {
- videoActive: true,
- mediaProgress: clampUnit(((Number(video.currentTime) || 0) - clipIn) / clipDuration),
- })) {
+ if (
+ !_enabled ||
+ generation !== _evidenceVideoGeneration ||
+ video !== _evidenceVideo ||
+ !sceneEvidenceMediaAutoplays()
+ )
+ return;
+ if (
+ redrawEvidenceSlot(index, video, {
+ videoActive: true,
+ mediaProgress: clampUnit(
+ ((Number(video.currentTime) || 0) - clipIn) / clipDuration,
+ ),
+ })
+ ) {
renderHost.request('bhote-koshi-evidence-video-frame');
}
_evidenceVideoFrameHandle = video.requestVideoFrameCallback(drawFrame);
@@ -1679,13 +2029,19 @@ export function createBhoteKoshiEventLayer({
_evidenceVideoIndex = -1;
_evidenceVideoReady = false;
if (video) {
- try { video.pause(); } catch {}
+ try {
+ video.pause();
+ } catch {}
try {
video.removeAttribute?.('src');
video.load?.();
} catch {}
}
- if (restorePoster && previousIndex >= 0 && redrawEvidenceSlot(previousIndex)) {
+ if (
+ restorePoster &&
+ previousIndex >= 0 &&
+ redrawEvidenceSlot(previousIndex)
+ ) {
renderHost.request('bhote-koshi-evidence-poster-restore');
}
}
@@ -1707,14 +2063,24 @@ export function createBhoteKoshiEventLayer({
video.preload = 'auto';
video.loop = true;
const markReady = () => {
- if (!_enabled || generation !== _evidenceVideoGeneration || video !== _evidenceVideo) return;
+ if (
+ !_enabled ||
+ generation !== _evidenceVideoGeneration ||
+ video !== _evidenceVideo
+ )
+ return;
_evidenceVideoReady = true;
renderHost.request('bhote-koshi-evidence-video-ready');
syncEvidenceMedia();
};
video.addEventListener?.('loadeddata', markReady, { once: true });
video.addEventListener?.('seeked', () => {
- if (!_enabled || generation !== _evidenceVideoGeneration || video !== _evidenceVideo) return;
+ if (
+ !_enabled ||
+ generation !== _evidenceVideoGeneration ||
+ video !== _evidenceVideo
+ )
+ return;
renderHost.request('bhote-koshi-evidence-video-seek');
});
video.src = eventAsset(videoPath);
@@ -1722,8 +2088,12 @@ export function createBhoteKoshiEventLayer({
}
function syncEvidenceMedia() {
- if (_presentation === BHOTE_KOSHI_SCENE_PRESENTATION
- && !_evidenceTimeline.some(({ observation }) => observation.id === _sceneBeatId)) {
+ if (
+ _presentation === BHOTE_KOSHI_SCENE_PRESENTATION &&
+ !_evidenceTimeline.some(
+ ({ observation }) => observation.id === _sceneBeatId,
+ )
+ ) {
releaseEvidenceVideo();
_embeddedEvidenceIndex = -1;
_embeddedMedia?.hide?.({ immediate: true });
@@ -1732,9 +2102,12 @@ export function createBhoteKoshiEventLayer({
if (!sceneEvidenceCameraSettled()) {
releaseEvidenceVideo();
_embeddedEvidenceIndex = -1;
- _embeddedMedia?.hide?.();
- if (_presentation === BHOTE_KOSHI_SCENE_PRESENTATION
- && _sceneSurface === BHOTE_KOSHI_SCENE_EVIDENCE_BEAT) {
+ const warmingEvidence =
+ _presentation === BHOTE_KOSHI_SCENE_PRESENTATION &&
+ _sceneSurface === BHOTE_KOSHI_SCENE_EVIDENCE_BEAT;
+ // Keep the same preload across camera frames; stop/disable still cancels it.
+ _embeddedMedia?.hide?.({ preserveWarm: warmingEvidence });
+ if (warmingEvidence) {
const upcoming = _evidenceTimeline.find(
({ observation }) => observation.id === _sceneBeatId,
)?.observation;
@@ -1746,16 +2119,22 @@ export function createBhoteKoshiEventLayer({
}
return;
}
- if (_presentation === BHOTE_KOSHI_SCENE_PRESENTATION
- && _sceneSurface !== BHOTE_KOSHI_SCENE_EVIDENCE_BEAT) {
+ if (
+ _presentation === BHOTE_KOSHI_SCENE_PRESENTATION &&
+ _sceneSurface !== BHOTE_KOSHI_SCENE_EVIDENCE_BEAT
+ ) {
releaseEvidenceVideo();
_embeddedEvidenceIndex = -1;
_embeddedMedia?.hide?.();
return;
}
- const index = _presentation === BHOTE_KOSHI_SCENE_PRESENTATION && _sceneControls.evidenceHoldCard
- ? _evidenceTimeline.findIndex(({ observation }) => observation.id === _sceneBeatId)
- : activeEvidenceIndex(_progress, _evidenceTimeline);
+ const index =
+ _presentation === BHOTE_KOSHI_SCENE_PRESENTATION &&
+ _sceneControls.evidenceHoldCard
+ ? _evidenceTimeline.findIndex(
+ ({ observation }) => observation.id === _sceneBeatId,
+ )
+ : activeEvidenceIndex(_progress, _evidenceTimeline);
if (index < 0) {
releaseEvidenceVideo();
_embeddedEvidenceIndex = -1;
@@ -1768,7 +2147,10 @@ export function createBhoteKoshiEventLayer({
_embeddedMedia?.hide?.({ immediate: true });
return;
}
- if (_evidenceTimeline[index]?.observation?.id === _sceneMediaPlaybackCompletedBeatId) {
+ if (
+ _evidenceTimeline[index]?.observation?.id ===
+ _sceneMediaPlaybackCompletedBeatId
+ ) {
if (_evidenceVideo || _evidenceVideoIndex >= 0) {
releaseEvidenceVideo({ restorePoster: false });
}
@@ -1790,13 +2172,15 @@ export function createBhoteKoshiEventLayer({
const mediaDuration = Number(video.duration);
const clipOut = Number.isFinite(declaredOut)
? Math.max(clipIn + 0.1, declaredOut)
- : Number.isFinite(mediaDuration) ? Math.max(clipIn + 0.1, mediaDuration) : clipIn + 4;
+ : Number.isFinite(mediaDuration)
+ ? Math.max(clipIn + 0.1, mediaDuration)
+ : clipIn + 4;
const clipDuration = clipOut - clipIn;
const targetTime = clipIn + presentation.localProgress * clipDuration;
const beatDuration = Math.max(
0.1,
- (Math.min(1, beat.deactivationProgress) - beat.activationProgress)
- * BHOTE_KOSHI_PLAYBACK_SECONDS,
+ (Math.min(1, beat.deactivationProgress) - beat.activationProgress) *
+ BHOTE_KOSHI_PLAYBACK_SECONDS,
);
const sceneAutoplay = sceneEvidenceMediaAutoplays();
video.playbackRate = sceneAutoplay
@@ -1804,13 +2188,22 @@ export function createBhoteKoshiEventLayer({
: Math.max(0.5, Math.min(2, clipDuration / beatDuration));
if (!_playing && !sceneAutoplay) {
stopEvidenceVideoFrameLoop(video);
- try { video.pause(); } catch {}
+ try {
+ video.pause();
+ } catch {}
if (Math.abs((Number(video.currentTime) || 0) - targetTime) > 0.04) {
- try { video.currentTime = targetTime; } catch {}
+ try {
+ video.currentTime = targetTime;
+ } catch {}
}
} else {
- if (_playing && Math.abs((Number(video.currentTime) || 0) - targetTime) > 0.42) {
- try { video.currentTime = targetTime; } catch {}
+ if (
+ _playing &&
+ Math.abs((Number(video.currentTime) || 0) - targetTime) > 0.42
+ ) {
+ try {
+ video.currentTime = targetTime;
+ } catch {}
}
if (video.paused) {
try {
@@ -1820,13 +2213,21 @@ export function createBhoteKoshiEventLayer({
} catch {}
}
if (sceneAutoplay) {
- startEvidenceVideoFrameLoop(video, index, _evidenceVideoGeneration, clipIn, clipDuration);
+ startEvidenceVideoFrameLoop(
+ video,
+ index,
+ _evidenceVideoGeneration,
+ clipIn,
+ clipDuration,
+ );
}
}
- if (redrawEvidenceSlot(index, video, {
- videoActive: true,
- mediaProgress: presentation.localProgress,
- })) {
+ if (
+ redrawEvidenceSlot(index, video, {
+ videoActive: true,
+ mediaProgress: presentation.localProgress,
+ })
+ ) {
renderHost.request('bhote-koshi-evidence-video-frame');
}
}
@@ -1884,7 +2285,12 @@ export function createBhoteKoshiEventLayer({
minDistance: 0,
maxDistance: 220000,
distanceFadeStartRatio: 0.82,
- distanceScale: { near: 1200, nearValue: 1, far: 180000, farValue: 0.72 },
+ distanceScale: {
+ near: 1200,
+ nearValue: 1,
+ far: 180000,
+ farValue: 0.72,
+ },
edgeFade: 'keyhole',
horizonCull: true,
terrainOcclusion: true,
@@ -1893,8 +2299,11 @@ export function createBhoteKoshiEventLayer({
anchorGapPaddingPx: 26,
// This short upstream reach sits immediately above its witness pin.
// Keep the landscape card below it rather than hiding the whole path.
- placement: _presentation === BHOTE_KOSHI_SCENE_PRESENTATION
- && observation.id === 'debris-dammed-lake' ? 'below' : 'right',
+ placement:
+ _presentation === BHOTE_KOSHI_SCENE_PRESENTATION &&
+ observation.id === 'debris-dammed-lake'
+ ? 'below'
+ : 'right',
verticalOnly: false,
leaderStyle: 'elbow',
viewportMargin: 18,
@@ -1913,7 +2322,12 @@ export function createBhoteKoshiEventLayer({
minDistance: 0,
maxDistance: 260000,
distanceFadeStartRatio: 0.7,
- distanceScale: { near: 1800, nearValue: 1, far: 220000, farValue: 0.66 },
+ distanceScale: {
+ near: 1800,
+ nearValue: 1,
+ far: 220000,
+ farValue: 0.66,
+ },
edgeFade: 'keyhole',
horizonCull: true,
terrainOcclusion: true,
@@ -1928,59 +2342,78 @@ export function createBhoteKoshiEventLayer({
});
overlayHost.setVisible(
BHOTE_KOSHI_OVERLAY_SOURCE_ID,
- _presentation !== BHOTE_KOSHI_SCENE_PRESENTATION
- || _sceneSurface === BHOTE_KOSHI_SCENE_EVIDENCE_BEAT,
+ _presentation !== BHOTE_KOSHI_SCENE_PRESENTATION ||
+ _sceneSurface === BHOTE_KOSHI_SCENE_EVIDENCE_BEAT,
);
}
async function loadEvidencePosters(event, signal, generation) {
const observations = _evidenceTimeline.map((beat) => beat.observation);
- await Promise.allSettled(observations.map(async (observation, index) => {
- const posterUrl = observation.media?.posterPath
- ? eventAsset(observation.media.posterPath)
- : observation.media?.posterUrl;
- if (!posterUrl) return;
- let poster = null;
- try {
- poster = await mediaLoader(posterUrl, { signal });
- if (signal.aborted || !_enabled || generation !== _mediaGeneration) {
+ await Promise.allSettled(
+ observations.map(async (observation, index) => {
+ const posterUrl = observation.media?.posterPath
+ ? eventAsset(observation.media.posterPath)
+ : observation.media?.posterUrl;
+ if (!posterUrl) return;
+ let poster = null;
+ try {
+ poster = await mediaLoader(posterUrl, { signal });
+ if (signal.aborted || !_enabled || generation !== _mediaGeneration) {
+ closeFrame(poster);
+ return;
+ }
+ const slot = _evidenceFrameSlots[index];
+ if (!slot) {
+ closeFrame(poster);
+ return;
+ }
+ closeFrame(slot.poster);
+ slot.poster = poster;
+ poster = null;
+ if (!redrawEvidenceSlot(index)) return;
+ renderHost.request('bhote-koshi-evidence-poster');
+ } catch (error) {
closeFrame(poster);
- return;
+ if (
+ error?.name !== 'AbortError' &&
+ error?.message !== 'Evidence poster decoding is unavailable'
+ ) {
+ console.warn(
+ `[Data:BhoteKoshi] Evidence poster failed for ${observation.id}:`,
+ error,
+ );
+ }
}
- const slot = _evidenceFrameSlots[index];
- if (!slot) {
- closeFrame(poster);
- return;
- }
- closeFrame(slot.poster);
- slot.poster = poster;
- poster = null;
- if (!redrawEvidenceSlot(index)) return;
- renderHost.request('bhote-koshi-evidence-poster');
- } catch (error) {
- closeFrame(poster);
- if (
- error?.name !== 'AbortError'
- && error?.message !== 'Evidence poster decoding is unavailable'
- ) {
- console.warn(`[Data:BhoteKoshi] Evidence poster failed for ${observation.id}:`, error);
- }
- }
- }));
+ }),
+ );
}
async function prepareEventTerrain(event, signal) {
_sourceShotPaths = buildSourceShotPaths(event);
- const elevationOffsetM = _presentation === BHOTE_KOSHI_SCENE_PRESENTATION ? 55 : 8;
- _terrainPositions = event.reconstruction.corridor.map((point) => (
- Cesium.Cartesian3.fromDegrees(point.lon, point.lat, point.elevationM + elevationOffsetM)
- ));
+ const elevationOffsetM =
+ _presentation === BHOTE_KOSHI_SCENE_PRESENTATION ? 55 : 8;
+ _terrainPositions = event.reconstruction.corridor.map((point) =>
+ Cesium.Cartesian3.fromDegrees(
+ point.lon,
+ point.lat,
+ point.elevationM + elevationOffsetM,
+ ),
+ );
prepareScenePath();
- _evidenceTimeline = buildEvidenceTimeline(event.evidenceSpine, event.reconstruction.corridor);
- const timelineEvent = { ...event, evidenceSpine: _evidenceTimeline.map((beat) => beat.observation) };
+ _evidenceTimeline = buildEvidenceTimeline(
+ event.evidenceSpine,
+ event.reconstruction.corridor,
+ );
+ const timelineEvent = {
+ ...event,
+ evidenceSpine: _evidenceTimeline.map((beat) => beat.observation),
+ };
_evidenceAnchors = await resolveEvidenceAnchors(timelineEvent, signal);
throwIfEnableCancelled(signal, _enabled);
- _cinematicKeyframes = buildCinematicKeyframes(_evidenceTimeline, _evidenceAnchors);
+ _cinematicKeyframes = buildCinematicKeyframes(
+ _evidenceTimeline,
+ _evidenceAnchors,
+ );
const requestedBeatProgress = evidenceBeatProgress(
_sceneBeatId,
_evidenceTimeline,
@@ -1995,7 +2428,9 @@ export function createBhoteKoshiEventLayer({
_mediaAbortController = new AbortController();
const mediaController = _mediaAbortController;
const generation = ++_mediaGeneration;
- signal?.addEventListener('abort', () => mediaController.abort(), { once: true });
+ signal?.addEventListener('abort', () => mediaController.abort(), {
+ once: true,
+ });
void loadEvidencePosters(timelineEvent, mediaController.signal, generation);
updateProgress(_progress);
}
@@ -2092,7 +2527,10 @@ export function createBhoteKoshiEventLayer({
splitHandle.type = 'button';
splitHandle.className = 'bhote-koshi-split-handle';
splitHandle.setAttribute('role', 'slider');
- splitHandle.setAttribute('aria-label', 'Historical reference and post-event image divider');
+ splitHandle.setAttribute(
+ 'aria-label',
+ 'Historical reference and post-event image divider',
+ );
splitHandle.setAttribute('aria-valuemin', '0');
splitHandle.setAttribute('aria-valuemax', '100');
splitHandle.setAttribute('aria-orientation', 'horizontal');
@@ -2108,9 +2546,10 @@ export function createBhoteKoshiEventLayer({
}
function setSplitFromClientX(clientX) {
- const viewportWidth = Number(document.documentElement?.clientWidth)
- || Number(window.innerWidth)
- || 0;
+ const viewportWidth =
+ Number(document.documentElement?.clientWidth) ||
+ Number(window.innerWidth) ||
+ 0;
if (viewportWidth <= 0 || !Number.isFinite(Number(clientX))) return;
setSplit(Number(clientX) / viewportWidth);
}
@@ -2149,9 +2588,18 @@ export function createBhoteKoshiEventLayer({
}
function applyCinematicCamera() {
- if (!_cinematicActive || !_viewer?.camera || _cinematicKeyframes.length === 0) return;
+ if (
+ !_cinematicActive ||
+ !_viewer?.camera ||
+ _cinematicKeyframes.length === 0
+ )
+ return;
if (_lastCinematicProgress === _progress) return;
- const pose = sampleCinematicPose(_progress, _cinematicKeyframes, _cinematicPose);
+ const pose = sampleCinematicPose(
+ _progress,
+ _cinematicKeyframes,
+ _cinematicPose,
+ );
if (!pose) return;
_cinematicOffset.heading = Cesium.Math.toRadians(pose.headingDeg);
_cinematicOffset.pitch = Cesium.Math.toRadians(pose.pitchDeg);
@@ -2163,7 +2611,11 @@ export function createBhoteKoshiEventLayer({
function syncCinematicButton() {
const button = _panelRefs?.cinematicButton;
if (!button) return;
- setElementProperty(button, 'textContent', _cinematicActive ? '■ RELEASE CAMERA' : '◉ CINEMATIC');
+ setElementProperty(
+ button,
+ 'textContent',
+ _cinematicActive ? '■ RELEASE CAMERA' : '◉ CINEMATIC',
+ );
setElementAttribute(button, 'aria-pressed', String(_cinematicActive));
}
@@ -2180,7 +2632,10 @@ export function createBhoteKoshiEventLayer({
return true;
}
- function stopCinematic({ releaseCamera = true, preserveReplay = false } = {}) {
+ function stopCinematic({
+ releaseCamera = true,
+ preserveReplay = false,
+ } = {}) {
if (!preserveReplay) _cinematicReplayArmed = false;
if (!_cinematicActive) {
syncPanel();
@@ -2188,7 +2643,8 @@ export function createBhoteKoshiEventLayer({
}
_cinematicActive = false;
_lastCinematicProgress = null;
- if (releaseCamera) _viewer?.camera?.lookAtTransform?.(Cesium.Matrix4.IDENTITY);
+ if (releaseCamera)
+ _viewer?.camera?.lookAtTransform?.(Cesium.Matrix4.IDENTITY);
syncCinematicButton();
renderHost.request('bhote-koshi-cinematic-stop');
}
@@ -2236,9 +2692,13 @@ export function createBhoteKoshiEventLayer({
}
function handleEvidenceCardClick(event) {
- if (!_enabled
- || _presentation !== BHOTE_KOSHI_SCENE_PRESENTATION
- || _sceneSurface !== BHOTE_KOSHI_SCENE_EVIDENCE_BEAT) return;
+ if (
+ !isPointerFree() ||
+ !_enabled ||
+ _presentation !== BHOTE_KOSHI_SCENE_PRESENTATION ||
+ _sceneSurface !== BHOTE_KOSHI_SCENE_EVIDENCE_BEAT
+ )
+ return;
const target = _evidenceClickTarget;
const bounds = target?.getBoundingClientRect?.();
const offsetX = Number(event?.offsetX);
@@ -2247,10 +2707,14 @@ export function createBhoteKoshiEventLayer({
const clientY = Number(event?.clientY);
const x = Number.isFinite(offsetX)
? offsetX
- : Number.isFinite(clientX) && bounds ? clientX - bounds.left : Number.NaN;
+ : Number.isFinite(clientX) && bounds
+ ? clientX - bounds.left
+ : Number.NaN;
const y = Number.isFinite(offsetY)
? offsetY
- : Number.isFinite(clientY) && bounds ? clientY - bounds.top : Number.NaN;
+ : Number.isFinite(clientY) && bounds
+ ? clientY - bounds.top
+ : Number.NaN;
const hit = overlayHost.hitTest?.(x, y, {
sourceId: BHOTE_KOSHI_OVERLAY_SOURCE_ID,
filter: (entry) => String(entry?.id || '').startsWith('evidence-card-'),
@@ -2265,7 +2729,10 @@ export function createBhoteKoshiEventLayer({
}
function detachEvidenceCardActivation() {
- _evidenceClickTarget?.removeEventListener?.('click', handleEvidenceCardClick);
+ _evidenceClickTarget?.removeEventListener?.(
+ 'click',
+ handleEvidenceCardClick,
+ );
_evidenceClickTarget = null;
}
@@ -2277,15 +2744,28 @@ export function createBhoteKoshiEventLayer({
if (!_enabled || _evidenceTimeline.length === 0) return false;
invalidateIntroAutostart();
stopPlayback();
- const targetIndex = adjacentEvidenceIndex(_progress, _evidenceTimeline, direction);
+ const targetIndex = adjacentEvidenceIndex(
+ _progress,
+ _evidenceTimeline,
+ direction,
+ );
if (targetIndex < 0) return false;
- if (_presentation !== BHOTE_KOSHI_SCENE_PRESENTATION && !claimCinematicCamera()) return false;
+ if (
+ _presentation !== BHOTE_KOSHI_SCENE_PRESENTATION &&
+ !claimCinematicCamera()
+ )
+ return false;
updateProgress(_evidenceTimeline[targetIndex].activationProgress);
return true;
}
async function navigateSceneShot(direction) {
- if (!_sceneController?.loadAdjacentShot || !_sceneContext || _sceneActionPending) return false;
+ if (
+ !_sceneController?.loadAdjacentShot ||
+ !_sceneContext ||
+ _sceneActionPending
+ )
+ return false;
_sceneActionPending = true;
syncPanel();
try {
@@ -2301,11 +2781,15 @@ export function createBhoteKoshiEventLayer({
}
async function playSceneShot() {
- if (!_sceneController?.replayShot || !_sceneContext || _sceneActionPending) return false;
+ if (!_sceneController?.replayShot || !_sceneContext || _sceneActionPending)
+ return false;
_sceneActionPending = true;
syncPanel();
try {
- return await _sceneController.replayShot(_sceneContext.sceneId, _sceneContext.shotId);
+ return await _sceneController.replayShot(
+ _sceneContext.sceneId,
+ _sceneContext.shotId,
+ );
} finally {
_sceneActionPending = false;
syncPanel();
@@ -2313,14 +2797,23 @@ export function createBhoteKoshiEventLayer({
}
async function playWholeScene() {
- if (!_enabled || _presentation !== BHOTE_KOSHI_SCENE_PRESENTATION
- || !_sceneController?.continueScene || !_sceneContext?.sceneId || !_sceneContext?.shotId
- || _sceneActionPending) return false;
+ if (
+ !_enabled ||
+ _presentation !== BHOTE_KOSHI_SCENE_PRESENTATION ||
+ !_sceneController?.continueScene ||
+ !_sceneContext?.sceneId ||
+ !_sceneContext?.shotId ||
+ _sceneActionPending
+ )
+ return false;
const action = { type: 'scene' };
_sceneActionPending = action;
syncPanel();
try {
- return await _sceneController.continueScene(_sceneContext.sceneId, _sceneContext.shotId);
+ return await _sceneController.continueScene(
+ _sceneContext.sceneId,
+ _sceneContext.shotId,
+ );
} catch (error) {
console.warn('[Bhote Koshi] Scene playback failed:', error);
return false;
@@ -2378,13 +2871,20 @@ export function createBhoteKoshiEventLayer({
}
_sceneScrubProgress = clampUnit(progress);
const sceneDurationSec = Number(_sceneContext?.sceneDurationSec) || 0;
- setElementProperty(_panelRefs?.progressInput, 'value', String(Math.round(_sceneScrubProgress * 1000)));
- setElementProperty(_panelRefs?.time, 'textContent', (
- `~${elapsedLabel(_sceneScrubProgress, sceneDurationSec)} / ${elapsedLabel(1, sceneDurationSec)}`
- ));
+ setElementProperty(
+ _panelRefs?.progressInput,
+ 'value',
+ String(Math.round(_sceneScrubProgress * 1000)),
+ );
+ setElementProperty(
+ _panelRefs?.time,
+ 'textContent',
+ `~${elapsedLabel(_sceneScrubProgress, sceneDurationSec)} / ${elapsedLabel(1, sceneDurationSec)}`,
+ );
if (_sceneSeekFrame != null) return;
- const schedule = globalThis.requestAnimationFrame
- || ((callback) => globalThis.setTimeout(callback, 16));
+ const schedule =
+ globalThis.requestAnimationFrame ||
+ ((callback) => globalThis.setTimeout(callback, 16));
_sceneSeekFrame = schedule(() => {
_sceneSeekFrame = null;
void commitProgress(_sceneScrubProgress, { final: false });
@@ -2393,8 +2893,12 @@ export function createBhoteKoshiEventLayer({
async function commitProgress(progress, { final = false } = {}) {
if (_presentation !== BHOTE_KOSHI_SCENE_PRESENTATION) return;
- if (!_sceneController?.seekScene || !_sceneContext?.sceneId
- || (_sceneActionPending && !['scene', 'scene-seek'].includes(_sceneActionPending.type))) {
+ if (
+ !_sceneController?.seekScene ||
+ !_sceneContext?.sceneId ||
+ (_sceneActionPending &&
+ !['scene', 'scene-seek'].includes(_sceneActionPending.type))
+ ) {
syncPanel();
return;
}
@@ -2410,12 +2914,19 @@ export function createBhoteKoshiEventLayer({
_sceneActionPending = action;
syncPanel();
try {
- await _sceneController.seekScene(_sceneContext.sceneId, requestedProgress);
+ await _sceneController.seekScene(
+ _sceneContext.sceneId,
+ requestedProgress,
+ );
} catch (error) {
console.warn('[Bhote Koshi] Scene clock seek failed:', error);
} finally {
- if (generation === _sceneSeekGeneration && final) _sceneScrubProgress = null;
- if (_sceneActionPending === action && generation === _sceneSeekGeneration) {
+ if (generation === _sceneSeekGeneration && final)
+ _sceneScrubProgress = null;
+ if (
+ _sceneActionPending === action &&
+ generation === _sceneSeekGeneration
+ ) {
_sceneActionPending = false;
syncPanel();
}
@@ -2424,7 +2935,8 @@ export function createBhoteKoshiEventLayer({
function openActiveEvidenceSource() {
const beatIndex = currentEvidenceIndex();
- const sourceUrl = _evidenceTimeline[beatIndex]?.observation?.media?.sourceUrl;
+ const sourceUrl =
+ _evidenceTimeline[beatIndex]?.observation?.media?.sourceUrl;
if (!sourceUrl) return false;
openExternal(sourceUrl);
return true;
@@ -2440,51 +2952,104 @@ export function createBhoteKoshiEventLayer({
function syncPanel() {
if (!_panelRefs || !_event) return;
const sceneDirected = _presentation === BHOTE_KOSHI_SCENE_PRESENTATION;
- const sceneClockProgress = clampUnit(Number(_sceneContext?.sceneProgress) || 0);
- const visibleSceneProgress = _sceneScrubProgress == null
- ? sceneClockProgress
- : clampUnit(_sceneScrubProgress);
+ const sceneClockProgress = clampUnit(
+ Number(_sceneContext?.sceneProgress) || 0,
+ );
+ const visibleSceneProgress =
+ _sceneScrubProgress == null
+ ? sceneClockProgress
+ : clampUnit(_sceneScrubProgress);
const displayProgress = sceneDirected ? visibleSceneProgress : _progress;
- setElementProperty(_panelRefs.progressInput, 'value', String(Math.round(displayProgress * 1000)));
- setElementProperty(_panelRefs.progressInput, 'disabled', sceneDirected && (
- !_sceneController?.seekScene || !_sceneContext?.sceneId
- || Boolean(_sceneActionPending && !['scene', 'scene-seek'].includes(_sceneActionPending.type))
- ));
- setElementAttribute(_panelRefs.progressInput, 'aria-label', sceneDirected
- ? 'Seek Nepal scene clock'
- : 'Schematic downstream progression');
- setElementProperty(_panelRefs.timelineLabel, 'textContent', (
- sceneDirected ? 'NEPAL SCENE CLOCK' : 'RECONSTRUCTION CLOCK'
- ));
+ setElementProperty(
+ _panelRefs.progressInput,
+ 'value',
+ String(Math.round(displayProgress * 1000)),
+ );
+ setElementProperty(
+ _panelRefs.progressInput,
+ 'disabled',
+ sceneDirected &&
+ (!_sceneController?.seekScene ||
+ !_sceneContext?.sceneId ||
+ Boolean(
+ _sceneActionPending &&
+ !['scene', 'scene-seek'].includes(_sceneActionPending.type),
+ )),
+ );
+ setElementAttribute(
+ _panelRefs.progressInput,
+ 'aria-label',
+ sceneDirected
+ ? 'Seek Nepal scene clock'
+ : 'Schematic downstream progression',
+ );
+ setElementProperty(
+ _panelRefs.timelineLabel,
+ 'textContent',
+ sceneDirected ? 'NEPAL SCENE CLOCK' : 'RECONSTRUCTION CLOCK',
+ );
const sceneDuration = Number(_sceneContext?.sceneDurationSec) || 0;
- setElementProperty(_panelRefs.time, 'textContent', sceneDirected
- ? (`~${elapsedLabel(visibleSceneProgress, sceneDuration)} / ${elapsedLabel(1, sceneDuration)}`)
- : (`~${elapsedLabel(_progress, _event.reconstruction.elapsedSeconds)} / `
- + elapsedLabel(1, _event.reconstruction.elapsedSeconds)));
+ setElementProperty(
+ _panelRefs.time,
+ 'textContent',
+ sceneDirected
+ ? `~${elapsedLabel(visibleSceneProgress, sceneDuration)} / ${elapsedLabel(1, sceneDuration)}`
+ : `~${elapsedLabel(_progress, _event.reconstruction.elapsedSeconds)} / ` +
+ elapsedLabel(1, _event.reconstruction.elapsedSeconds),
+ );
const playLabel = sceneDirected
- ? (_sceneActionPending && _sceneActionPending.type !== 'scene' ? '… PLAYING SHOT' : '▶ PLAY SHOT')
- : (_playing
+ ? _sceneActionPending && _sceneActionPending.type !== 'scene'
+ ? '… PLAYING SHOT'
+ : '▶ PLAY SHOT'
+ : _playing
? 'Ⅱ PAUSE'
- : (_progress >= 1
- ? (_cinematicReplayArmed ? '↺ REPLAY CINEMATIC' : '↺ REPLAY')
- : '▶ PLAY'));
+ : _progress >= 1
+ ? _cinematicReplayArmed
+ ? '↺ REPLAY CINEMATIC'
+ : '↺ REPLAY'
+ : '▶ PLAY';
setElementProperty(_panelRefs.playButton, 'textContent', playLabel);
- setElementProperty(_panelRefs.playSceneButton, 'textContent',
- _sceneActionPending?.type === 'scene' ? '… PLAYING SCENE' : '▶ PLAY SCENE');
- setElementProperty(_panelRefs.playSceneButton, 'disabled', !sceneDirected
- || !_sceneController?.continueScene || !_sceneContext?.sceneId || !_sceneContext?.shotId
- || Number(_sceneContext.shotIndex) >= Number(_sceneContext.shotCount) - 1
- || Boolean(_sceneActionPending));
- setElementAttribute(_panelRefs.playSceneButton, 'aria-label',
- `Continue ${_sceneContext?.sceneTitle || 'current scene'} from next shot`);
- setElementProperty(_panelRefs.storyReplayButton, 'textContent', '↺ FULL STORY');
+ setElementProperty(
+ _panelRefs.playSceneButton,
+ 'textContent',
+ _sceneActionPending?.type === 'scene'
+ ? '… PLAYING SCENE'
+ : '▶ PLAY SCENE',
+ );
+ setElementProperty(
+ _panelRefs.playSceneButton,
+ 'disabled',
+ !sceneDirected ||
+ !_sceneController?.continueScene ||
+ !_sceneContext?.sceneId ||
+ !_sceneContext?.shotId ||
+ Number(_sceneContext.shotIndex) >=
+ Number(_sceneContext.shotCount) - 1 ||
+ Boolean(_sceneActionPending),
+ );
+ setElementAttribute(
+ _panelRefs.playSceneButton,
+ 'aria-label',
+ `Continue ${_sceneContext?.sceneTitle || 'current scene'} from next shot`,
+ );
+ setElementProperty(
+ _panelRefs.storyReplayButton,
+ 'textContent',
+ '↺ FULL STORY',
+ );
if (!sceneDirected) {
setElementProperty(_panelRefs.storyReplayButton, 'disabled', false);
- setElementAttribute(_panelRefs.storyReplayButton, 'aria-label', 'Replay the full reconstruction');
+ setElementAttribute(
+ _panelRefs.storyReplayButton,
+ 'aria-label',
+ 'Replay the full reconstruction',
+ );
}
const beatIndex = currentEvidenceIndex();
const sceneBeatIndex = sceneDirected
- ? _evidenceTimeline.findIndex(({ observation: item }) => item.id === _sceneBeatId)
+ ? _evidenceTimeline.findIndex(
+ ({ observation: item }) => item.id === _sceneBeatId,
+ )
: beatIndex;
const observation = _evidenceTimeline[sceneBeatIndex]?.observation || null;
const sceneShotIndex = Math.max(0, Number(_sceneContext?.shotIndex) || 0);
@@ -2492,14 +3057,18 @@ export function createBhoteKoshiEventLayer({
? Math.max(0, Number(_sceneContext?.shotCount) || 0)
: _evidenceTimeline.length;
const displayIndex = sceneDirected ? sceneShotIndex : beatIndex;
- setElementProperty(_panelRefs.beatIndex, 'textContent', (
- `${String(displayIndex + 1).padStart(2, '0')} / ${String(beatCount).padStart(2, '0')}`
- ));
- setElementProperty(_panelRefs.beatTitle, 'textContent', (
+ setElementProperty(
+ _panelRefs.beatIndex,
+ 'textContent',
+ `${String(displayIndex + 1).padStart(2, '0')} / ${String(beatCount).padStart(2, '0')}`,
+ );
+ setElementProperty(
+ _panelRefs.beatTitle,
+ 'textContent',
sceneDirected
- ? (_sceneContext?.shotTitle || 'SCENE SHOT')
- : (observation?.shortTitle || observation?.title || 'EVIDENCE')
- ));
+ ? _sceneContext?.shotTitle || 'SCENE SHOT'
+ : observation?.shortTitle || observation?.title || 'EVIDENCE',
+ );
const sourceCount = Math.max(
0,
Math.floor(Number(observation?.corroboration?.sourceCount) || 0),
@@ -2509,25 +3078,42 @@ export function createBhoteKoshiEventLayer({
'TIME UNVERIFIED',
sourceCount > 1 ? `${sourceCount} SOURCES` : '',
observation?.imageryCoverage === 'outside' ? 'OUTSIDE IMAGERY SWIPE' : '',
- ].filter(Boolean).join(' / ');
- setElementProperty(_panelRefs.beatMeta, 'textContent', sceneDirected
- ? `${String(_sceneContext?.sceneTitle || 'NEPAL FLOOD INCIDENT').toUpperCase()} / AUTHORED SHOT`
- : beatMeta);
- setElementProperty(_panelRefs.previousButton, 'disabled', (
- Boolean(_sceneActionPending) || displayIndex <= 0
- ));
+ ]
+ .filter(Boolean)
+ .join(' / ');
+ setElementProperty(
+ _panelRefs.beatMeta,
+ 'textContent',
+ sceneDirected
+ ? `${String(_sceneContext?.sceneTitle || 'NEPAL FLOOD INCIDENT').toUpperCase()} / AUTHORED SHOT`
+ : beatMeta,
+ );
+ setElementProperty(
+ _panelRefs.previousButton,
+ 'disabled',
+ Boolean(_sceneActionPending) || displayIndex <= 0,
+ );
setElementProperty(
_panelRefs.nextButton,
'disabled',
- Boolean(_sceneActionPending) || beatCount === 0 || displayIndex >= beatCount - 1,
+ Boolean(_sceneActionPending) ||
+ beatCount === 0 ||
+ displayIndex >= beatCount - 1,
+ );
+ setElementProperty(
+ _panelRefs.playButton,
+ 'disabled',
+ sceneDirected &&
+ (!_sceneController?.replayShot ||
+ !_sceneContext ||
+ Boolean(_sceneActionPending)),
);
- setElementProperty(_panelRefs.playButton, 'disabled', (
- sceneDirected && (!_sceneController?.replayShot || !_sceneContext || Boolean(_sceneActionPending))
- ));
setElementAttribute(
_panelRefs.playButton,
'aria-label',
- sceneDirected ? `Play ${_sceneContext?.shotTitle || 'current scene shot'}` : 'Play reconstruction',
+ sceneDirected
+ ? `Play ${_sceneContext?.shotTitle || 'current scene shot'}`
+ : 'Play reconstruction',
);
setElementAttribute(
_panelRefs.previousButton,
@@ -2539,9 +3125,17 @@ export function createBhoteKoshiEventLayer({
'aria-label',
sceneDirected ? 'Load next scene shot' : 'Next story beat',
);
- setElementProperty(_panelRefs.openSourceButton, 'disabled', !observation?.media?.sourceUrl);
+ setElementProperty(
+ _panelRefs.openSourceButton,
+ 'disabled',
+ !observation?.media?.sourceUrl,
+ );
syncCinematicButton();
- setElementProperty(_panelRefs.splitInput, 'value', String(Math.round(_split * 100)));
+ setElementProperty(
+ _panelRefs.splitInput,
+ 'value',
+ String(Math.round(_split * 100)),
+ );
const beforePercent = Math.round(_split * 100);
setElementProperty(
_panelRefs.splitReadout,
@@ -2552,50 +3146,73 @@ export function createBhoteKoshiEventLayer({
function syncPresentationMode() {
const sceneDirected = _presentation === BHOTE_KOSHI_SCENE_PRESENTATION;
- const imageryAvailable = !sceneDirected || _sceneControls.imageryComparison === true;
- const scrubberAvailable = !sceneDirected || Boolean(_sceneController?.seekScene);
- const knownSceneBeat = !sceneDirected || _evidenceTimeline.some(
- ({ observation }) => observation.id === _sceneBeatId,
- );
- const evidenceAvailable = !sceneDirected
- || (_sceneSurface === BHOTE_KOSHI_SCENE_EVIDENCE_BEAT && knownSceneBeat);
+ const imageryAvailable =
+ !sceneDirected || _sceneControls.imageryComparison === true;
+ const scrubberAvailable =
+ !sceneDirected || Boolean(_sceneController?.seekScene);
+ const knownSceneBeat =
+ !sceneDirected ||
+ _evidenceTimeline.some(
+ ({ observation }) => observation.id === _sceneBeatId,
+ );
+ const evidenceAvailable =
+ !sceneDirected ||
+ (_sceneSurface === BHOTE_KOSHI_SCENE_EVIDENCE_BEAT && knownSceneBeat);
_panel?.classList?.toggle('bhote-event-scene-beat', sceneDirected);
- if (_panelRefs?.playSceneButton) _panelRefs.playSceneButton.hidden = !sceneDirected;
- if (_panelRefs?.imagerySection) _panelRefs.imagerySection.hidden = !imageryAvailable;
- if (_panelRefs?.observedStatus) _panelRefs.observedStatus.hidden = !imageryAvailable;
- if (_panelRefs?.progressInput) _panelRefs.progressInput.hidden = !scrubberAvailable;
+ if (_panelRefs?.playSceneButton)
+ _panelRefs.playSceneButton.hidden = !sceneDirected;
+ if (_panelRefs?.imagerySection)
+ _panelRefs.imagerySection.hidden = !imageryAvailable;
+ if (_panelRefs?.observedStatus)
+ _panelRefs.observedStatus.hidden = !imageryAvailable;
+ if (_panelRefs?.progressInput)
+ _panelRefs.progressInput.hidden = !scrubberAvailable;
if (_splitLine) _splitLine.hidden = !imageryAvailable;
if (_beforeLayer) _beforeLayer.show = imageryAvailable;
if (_afterLayer) _afterLayer.show = imageryAvailable;
if (_panelRefs?.caveat) {
_panelRefs.caveat.textContent = imageryAvailable
- ? (_event?.reconstruction?.caveat || '')
+ ? _event?.reconstruction?.caveat || ''
: 'SCHEMATIC CORRIDOR, NOT MODELED ARRIVAL TIME.';
}
- if (_panelRefs?.imageryCloudNote) _panelRefs.imageryCloudNote.hidden = !imageryAvailable;
+ if (_panelRefs?.imageryCloudNote)
+ _panelRefs.imageryCloudNote.hidden = !imageryAvailable;
for (const [button, control, sceneDefault] of [
[_panelRefs?.cinematicButton, 'cinematic'],
[_panelRefs?.storyReplayButton, 'storyReplay'],
[_panelRefs?.corridorButton, 'corridor', true],
]) {
if (!button) continue;
- const available = !sceneDirected || sceneDefault === true || _sceneControls[control] === true;
+ const available =
+ !sceneDirected ||
+ sceneDefault === true ||
+ _sceneControls[control] === true;
button.hidden = !available;
if (!available) button.disabled = true;
}
- if (_panelRefs?.openSourceButton) _panelRefs.openSourceButton.hidden = !evidenceAvailable;
+ if (_panelRefs?.openSourceButton)
+ _panelRefs.openSourceButton.hidden = !evidenceAvailable;
overlayHost.setVisible(BHOTE_KOSHI_OVERLAY_SOURCE_ID, evidenceAvailable);
}
function updateFloodTrace(progress) {
if (!_floodHalo || !_floodCore) return;
- if (_presentation === BHOTE_KOSHI_SCENE_PRESENTATION
- && _sceneSurface !== BHOTE_KOSHI_SCENE_EVIDENCE_BEAT) {
+ if (
+ _presentation === BHOTE_KOSHI_SCENE_PRESENTATION &&
+ _sceneSurface !== BHOTE_KOSHI_SCENE_EVIDENCE_BEAT
+ ) {
_floodHalo.show = false;
_floodCore.show = false;
return;
}
- if (!updateCorridorPositionCache(progress, activeFloodPositions(), _corridorPositionCache)) return;
+ if (
+ !updateCorridorPositionCache(
+ progress,
+ activeFloodPositions(),
+ _corridorPositionCache,
+ )
+ )
+ return;
const positions = _corridorPositionCache.positions;
_floodHalo.show = positions.length > 1;
_floodCore.show = positions.length > 1;
@@ -2603,21 +3220,31 @@ export function createBhoteKoshiEventLayer({
function updateSurgeFront(progress) {
if (!_surgePoint) return;
- if (_presentation === BHOTE_KOSHI_SCENE_PRESENTATION
- && _sceneSurface !== BHOTE_KOSHI_SCENE_EVIDENCE_BEAT) {
+ if (
+ _presentation === BHOTE_KOSHI_SCENE_PRESENTATION &&
+ _sceneSurface !== BHOTE_KOSHI_SCENE_EVIDENCE_BEAT
+ ) {
_surgePoint.show = false;
return;
}
const headPosition = _corridorPositionCache.headPosition;
- if (headPosition && !Cesium.Cartesian3.equals(_lastSurgePosition, headPosition)) {
+ if (
+ headPosition &&
+ !Cesium.Cartesian3.equals(_lastSurgePosition, headPosition)
+ ) {
_surgePoint.position = headPosition;
_lastSurgePosition = Cesium.Cartesian3.clone(
headPosition,
_lastSurgePosition || new Cesium.Cartesian3(),
);
}
- const visible = Boolean(headPosition) && progress > 0
- && (sceneUsesSourcePath() || sceneUsesPathHistory() || sceneKeepsFloodVisible() || progress < 1);
+ const visible =
+ Boolean(headPosition) &&
+ progress > 0 &&
+ (sceneUsesSourcePath() ||
+ sceneUsesPathHistory() ||
+ sceneKeepsFloodVisible() ||
+ progress < 1);
if (_lastSurgeVisible !== visible) {
_surgePoint.show = visible;
_lastSurgeVisible = visible;
@@ -2627,19 +3254,26 @@ export function createBhoteKoshiEventLayer({
function updateProgress(progress) {
_witnessEmbedActive = false;
_progress = clampUnit(progress);
- const sourceWindow = sceneUsesSourcePath() ? scenePathProgressWindow() : null;
- const floodProgress = scenePathMovesDuringCamera() || sceneUsesPathHistory()
- ? _scenePathRevealProgress
- : sourceWindow
- ? sourceWindow.start
- + (sourceWindow.target - sourceWindow.start) * _sceneRevealProgress
- : _presentation === BHOTE_KOSHI_SCENE_PRESENTATION
- && _sceneControls.evidencePath === 'none'
- ? 0
- : floodProgressForStoryProgress(_progress, _event?.reconstruction?.storyFloodWindow)
- * (sceneEvidenceDefersUntilCameraSettles() && !sceneEvidenceSequenceAnimates()
- ? _sceneRevealProgress
- : 1);
+ const sourceWindow = sceneUsesSourcePath()
+ ? scenePathProgressWindow()
+ : null;
+ const floodProgress =
+ scenePathMovesDuringCamera() || sceneUsesPathHistory()
+ ? _scenePathRevealProgress
+ : sourceWindow
+ ? sourceWindow.start +
+ (sourceWindow.target - sourceWindow.start) * _sceneRevealProgress
+ : _presentation === BHOTE_KOSHI_SCENE_PRESENTATION &&
+ _sceneControls.evidencePath === 'none'
+ ? 0
+ : floodProgressForStoryProgress(
+ _progress,
+ _event?.reconstruction?.storyFloodWindow,
+ ) *
+ (sceneEvidenceDefersUntilCameraSettles() &&
+ !sceneEvidenceSequenceAnimates()
+ ? _sceneRevealProgress
+ : 1);
updateFloodTrace(floodProgress);
updateSurgeFront(floodProgress);
applyCinematicCamera();
@@ -2650,10 +3284,14 @@ export function createBhoteKoshiEventLayer({
function setSplit(split) {
_split = clampUnit(split);
- if (_viewer && _viewer.scene.splitPosition !== _split) _viewer.scene.splitPosition = _split;
+ if (_viewer && _viewer.scene.splitPosition !== _split)
+ _viewer.scene.splitPosition = _split;
const cssValue = `${_split * 100}%`;
if (_lastSplitCssValue !== cssValue) {
- document.documentElement.style.setProperty('--bhote-koshi-split', cssValue);
+ document.documentElement.style.setProperty(
+ '--bhote-koshi-split',
+ cssValue,
+ );
_lastSplitCssValue = cssValue;
}
if (_splitHandle) {
@@ -2756,7 +3394,11 @@ export function createBhoteKoshiEventLayer({
throwIfEnableCancelled(signal, _enabled);
registerDynamicCredit(viewer, BHOTE_KOSHI_CREDIT);
_previousMapStack = _mapStackController?.getActiveId?.() || null;
- if (!sceneDirected && _mapStackController && _previousMapStack !== 'esri-imagery') {
+ if (
+ !sceneDirected &&
+ _mapStackController &&
+ _previousMapStack !== 'esri-imagery'
+ ) {
await _mapStackController.setStack('esri-imagery');
}
_eventMapGeneration = sceneDirected
@@ -2770,7 +3412,10 @@ export function createBhoteKoshiEventLayer({
createFloodDataSource();
ensureSplitLine(event);
const panelView = createPanel(event, {
- close: () => _dataManager?.setEnabled(BHOTE_KOSHI_LAYER_ID, false, { origin: 'user' }),
+ close: () =>
+ _dataManager?.setEnabled(BHOTE_KOSHI_LAYER_ID, false, {
+ origin: 'user',
+ }),
play: handlePlay,
playScene: playWholeScene,
toggleCinematic,
@@ -2801,8 +3446,12 @@ export function createBhoteKoshiEventLayer({
if (sceneEvidenceSequenceAnimates() && sceneEvidenceCameraSettled()) {
startSceneEvidenceReveal();
}
- if (!sceneDirected && !PASSIVE_ENABLE_ORIGINS.has(origin)) focusCorridor();
- if (!sceneDirected && (origin === 'scene' || !PASSIVE_ENABLE_ORIGINS.has(origin))) {
+ if (!sceneDirected && !PASSIVE_ENABLE_ORIGINS.has(origin))
+ focusCorridor();
+ if (
+ !sceneDirected &&
+ (origin === 'scene' || !PASSIVE_ENABLE_ORIGINS.has(origin))
+ ) {
const introGeneration = ++_introGeneration;
_introTimer = window.setTimeout(() => {
if (introGeneration !== _introGeneration) return;
@@ -2817,7 +3466,10 @@ export function createBhoteKoshiEventLayer({
try {
await disable();
} catch (cleanupError) {
- console.warn('[Data:BhoteKoshi] Failed-enable cleanup error:', cleanupError);
+ console.warn(
+ '[Data:BhoteKoshi] Failed-enable cleanup error:',
+ cleanupError,
+ );
}
throw error;
}
@@ -2871,7 +3523,9 @@ export function createBhoteKoshiEventLayer({
_lastSurgePosition = null;
_lastSurgeVisible = null;
_panel?.remove();
- document.getElementById?.('right-context-rail')?.classList.remove('bhote-event-active');
+ document
+ .getElementById?.('right-context-rail')
+ ?.classList.remove('bhote-event-active');
_panel = null;
_panelRefs = null;
_splitDragging = false;
@@ -2908,11 +3562,11 @@ export function createBhoteKoshiEventLayer({
_eventMapGeneration = null;
try {
if (
- _mapStackController
- && previousMapStack
- && previousMapStack !== 'esri-imagery'
- && _mapStackController.getActiveId?.() === 'esri-imagery'
- && _mapStackController.getSwitchGeneration?.() === eventMapGeneration
+ _mapStackController &&
+ previousMapStack &&
+ previousMapStack !== 'esri-imagery' &&
+ _mapStackController.getActiveId?.() === 'esri-imagery' &&
+ _mapStackController.getSwitchGeneration?.() === eventMapGeneration
) {
await _mapStackController.setStack(previousMapStack);
}
@@ -2944,16 +3598,20 @@ export function createBhoteKoshiEventLayer({
const previousPathDuringCamera = scenePathMovesDuringCamera();
const previousTravelId = _sceneControls.cameraTravel?.id;
const previousTravelActive = _sceneControls.cameraTravel?.active === true;
- const previousTravelCompleted = _sceneControls.cameraTravel?.completed === true;
- const previousTravelCancelled = _sceneControls.cameraTravel?.cancelled === true;
+ const previousTravelCompleted =
+ _sceneControls.cameraTravel?.completed === true;
+ const previousTravelCancelled =
+ _sceneControls.cameraTravel?.cancelled === true;
if (params.presentation === BHOTE_KOSHI_SCENE_PRESENTATION) {
_presentation = BHOTE_KOSHI_SCENE_PRESENTATION;
- _sceneSurface = params.sceneSurface === BHOTE_KOSHI_SCENE_EVIDENCE_BEAT
- ? BHOTE_KOSHI_SCENE_EVIDENCE_BEAT
- : BHOTE_KOSHI_SCENE_PANEL_ONLY;
- _sceneControls = params.sceneControls && typeof params.sceneControls === 'object'
- ? { ...params.sceneControls }
- : {};
+ _sceneSurface =
+ params.sceneSurface === BHOTE_KOSHI_SCENE_EVIDENCE_BEAT
+ ? BHOTE_KOSHI_SCENE_EVIDENCE_BEAT
+ : BHOTE_KOSHI_SCENE_PANEL_ONLY;
+ _sceneControls =
+ params.sceneControls && typeof params.sceneControls === 'object'
+ ? { ...params.sceneControls }
+ : {};
} else if (params.presentation === 'standalone') {
_presentation = 'standalone';
_sceneContext = null;
@@ -2987,10 +3645,13 @@ export function createBhoteKoshiEventLayer({
const beatChanged = previousBeatId !== _sceneBeatId;
const travelId = _sceneControls.cameraTravel?.id;
const travelActive = _sceneControls.cameraTravel?.active === true;
- const travelChanged = previousTravelId !== travelId
- || previousTravelActive !== travelActive
- || previousTravelCompleted !== (_sceneControls.cameraTravel?.completed === true)
- || previousTravelCancelled !== (_sceneControls.cameraTravel?.cancelled === true);
+ const travelChanged =
+ previousTravelId !== travelId ||
+ previousTravelActive !== travelActive ||
+ previousTravelCompleted !==
+ (_sceneControls.cameraTravel?.completed === true) ||
+ previousTravelCancelled !==
+ (_sceneControls.cameraTravel?.cancelled === true);
if (beatChanged || !cameraIsSettled || !sceneEvidenceMediaAutoplays()) {
stopSceneMediaPlayback();
_sceneMediaPlaybackCompletedBeatId = null;
@@ -3006,9 +3667,11 @@ export function createBhoteKoshiEventLayer({
const sequenceWindow = sceneEvidenceSequenceAnimates()
? evidenceSequenceWindow(_sceneBeatId, _evidenceTimeline)
: null;
- const beatProgress = sequenceWindow?.startProgress
- ?? evidenceBeatProgress(_sceneBeatId, _evidenceTimeline, _sceneBeatReveal);
- const hasProgress = beatProgress != null || Number.isFinite(Number(params.progress));
+ const beatProgress =
+ sequenceWindow?.startProgress ??
+ evidenceBeatProgress(_sceneBeatId, _evidenceTimeline, _sceneBeatReveal);
+ const hasProgress =
+ beatProgress != null || Number.isFinite(Number(params.progress));
const hasSplit = Number.isFinite(Number(params.split));
if (hasProgress) _progress = beatProgress ?? clampUnit(params.progress);
if (hasSplit) _split = clampUnit(params.split);
@@ -3018,20 +3681,27 @@ export function createBhoteKoshiEventLayer({
stopScenePathReveal();
}
void syncTerrainComparison().catch((error) => {
- console.warn('[Data:BhoteKoshi] Comparison surface unavailable:', error);
+ console.warn(
+ '[Data:BhoteKoshi] Comparison surface unavailable:',
+ error,
+ );
});
syncFloodSurfaceMode();
syncPresentationMode();
prepareScenePath();
const timelineSeekActive = applySceneTimelineSeek();
- const pathWindow = scenePathMovesDuringCamera() || sceneUsesPathHistory()
- ? scenePathProgressWindow()
- : null;
+ const pathWindow =
+ scenePathMovesDuringCamera() || sceneUsesPathHistory()
+ ? scenePathProgressWindow()
+ : null;
if (timelineSeekActive) {
// applySceneTimelineSeek reconstructed the exact authored phase.
} else if (sceneUsesPathHistory()) {
_scenePathRevealProgress = 1;
- } else if (pathWindow && (beatChanged || travelChanged || !previousPathDuringCamera)) {
+ } else if (
+ pathWindow &&
+ (beatChanged || travelChanged || !previousPathDuringCamera)
+ ) {
if (_sceneControls.cameraTravel?.completed) {
_scenePathRevealProgress = pathWindow.target;
} else if (!_sceneControls.cameraTravel?.cancelled) {
@@ -3040,16 +3710,24 @@ export function createBhoteKoshiEventLayer({
}
if (hasProgress) updateProgress(_progress);
if (hasSplit) setSplit(_split);
- if (!timelineSeekActive && scenePathMovesDuringCamera()
- && travelActive
- && (beatChanged || travelChanged || !previousPathDuringCamera)) {
+ if (
+ !timelineSeekActive &&
+ scenePathMovesDuringCamera() &&
+ travelActive &&
+ (beatChanged || travelChanged || !previousPathDuringCamera)
+ ) {
startScenePathReveal();
}
- if (!timelineSeekActive && cameraIsSettled && (nowDeferred || sceneEvidenceSequenceAnimates())
- && (beatChanged || !previouslyDeferred || !cameraWasSettled)) {
+ if (
+ !timelineSeekActive &&
+ cameraIsSettled &&
+ (nowDeferred || sceneEvidenceSequenceAnimates()) &&
+ (beatChanged || !previouslyDeferred || !cameraWasSettled)
+ ) {
startSceneEvidenceReveal();
}
- if (!sceneDirected && params.cinematic === true) startCinematic({ play: params.autoPlay !== false });
+ if (!sceneDirected && params.cinematic === true)
+ startCinematic({ play: params.autoPlay !== false });
else if (!sceneDirected && params.autoPlay === true) startPlayback();
}
return true;
@@ -3101,7 +3779,12 @@ export function createBhoteKoshiEventLayer({
};
},
getPlaybackState() {
- return { enabled: _enabled, playing: _playing, progress: _progress, split: _split };
+ return {
+ enabled: _enabled,
+ playing: _playing,
+ progress: _progress,
+ split: _split,
+ };
},
getCinematicState() {
const activeIndex = activeEvidenceIndex(_progress, _evidenceTimeline);
@@ -3110,7 +3793,8 @@ export function createBhoteKoshiEventLayer({
progress: _progress,
evidenceId: _evidenceTimeline[activeIndex]?.observation?.id || null,
mediaReady: _evidenceVideoReady,
- mediaEvidenceId: _evidenceTimeline[_evidenceVideoIndex]?.observation?.id || null,
+ mediaEvidenceId:
+ _evidenceTimeline[_evidenceVideoIndex]?.observation?.id || null,
};
},
};
diff --git a/src/data/bhoteKoshiEvent.test.mjs b/src/data/bhoteKoshiEvent.test.mjs
index c9983d4a4..bd69cd55e 100644
--- a/src/data/bhoteKoshiEvent.test.mjs
+++ b/src/data/bhoteKoshiEvent.test.mjs
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
+import { claimPointer, releasePointer } from './inputOwnership.js';
import * as Cesium from 'cesium';
import { SOURCE_PATH_BEAT_IDS } from './bhoteKoshiShotPaths.js';
import {
@@ -1777,6 +1778,13 @@ test('Debris-Dammed Lake card opens its source and is armed for approved local v
beatReveal: 0.36,
}, { origin: 'scene' });
await layer.enable(viewer, { origin: 'scene' });
+ const lease = claimPointer('draw');
+ try {
+ viewer._test.canvas.dispatch('click', { offsetX: 640, offsetY: 420 });
+ assert.equal(opened.length, 0, 'drawing owns the pointer over evidence cards');
+ } finally {
+ releasePointer(lease);
+ }
viewer._test.canvas.dispatch('click', { offsetX: 640, offsetY: 420 });
assert.deepEqual(opened, [[
event.evidenceSpine[1].media.sourceUrl,
@@ -2185,7 +2193,7 @@ test('Bhote Koshi frame path keeps evidence lookup scalar and wrapper-free', asy
);
assert.match(
source,
- /const beatIndex = currentEvidenceIndex\(\);\s*const sceneBeatIndex = sceneDirected\s*\? _evidenceTimeline\.findIndex\(\(\{ observation: item \}\) => item\.id === _sceneBeatId\)\s*:\s*beatIndex;\s*const observation = _evidenceTimeline\[sceneBeatIndex\]\?\.observation \|\| null;/,
+ /const beatIndex = currentEvidenceIndex\(\);\s*const sceneBeatIndex = sceneDirected\s*\? _evidenceTimeline\.findIndex\(\s*\(\{ observation: item \}\) => item\.id === _sceneBeatId,?\s*\)\s*:\s*beatIndex;\s*const observation =\s*_evidenceTimeline\[sceneBeatIndex\]\?\.observation \|\| null;/,
'syncPanel must resolve authored scene beats to one scalar index before reading the observation',
);
});
diff --git a/src/data/bhoteKoshiIncidentPlaces.js b/src/data/bhoteKoshiIncidentPlaces.js
index 3552f1f44..6f9dc1baf 100644
--- a/src/data/bhoteKoshiIncidentPlaces.js
+++ b/src/data/bhoteKoshiIncidentPlaces.js
@@ -1,22 +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_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;
diff --git a/src/data/bhoteKoshiLocator.js b/src/data/bhoteKoshiLocator.js
index 7cb1b0f2d..166fe95b4 100644
--- a/src/data/bhoteKoshiLocator.js
+++ b/src/data/bhoteKoshiLocator.js
@@ -12,14 +12,18 @@ import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js';
import { INCIDENT_OVERVIEW_PLACES } from './bhoteKoshiIncidentPlaces.js';
export const BHOTE_KOSHI_LOCATOR_LAYER_ID = 'bhote-koshi-locator';
-export const BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID = 'bhote-koshi-locator-callout';
+export const BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID =
+ 'bhote-koshi-locator-callout';
export const NEPAL_CONTEXT_PRESENTATION = 'nepal-context';
export const BHOTE_KOSHI_REGIONAL_PRESENTATION = 'bhote-koshi-regional';
export const BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION = 'bhote-koshi-city-context';
-export const BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION = 'bhote-koshi-incident-places';
+export const BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION =
+ 'bhote-koshi-incident-places';
export const BHOTE_KOSHI_FLOOD_PATH_PRESENTATION = 'bhote-koshi-flood-path';
-export const BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION = 'bhote-koshi-path-overview';
-export const BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION = 'bhote-koshi-trigger-record';
+export const BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION =
+ 'bhote-koshi-path-overview';
+export const BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION =
+ 'bhote-koshi-trigger-record';
const LEGACY_PATH_BORDER_PRESENTATION = 'bhote-koshi-path-border';
@@ -42,7 +46,8 @@ const FLOOD_PATH_DRAW_DURATION_MS = 4000;
const TRIGGER_APPROACH_ARM_MS = 500;
const TRIGGER_APPROACH_DURATION_SEC = 4.6;
const TRIGGER_ORBIT_DURATION_SEC = 2.8;
-const TRIGGER_SOURCE_URL = 'https://earthquake.usgs.gov/earthquakes/eventpage/us7000tbwb/executive';
+const TRIGGER_SOURCE_URL =
+ 'https://earthquake.usgs.gov/earthquakes/eventpage/us7000tbwb/executive';
const INCIDENT = Object.freeze({
lat: 28.3632333,
@@ -86,9 +91,30 @@ const NEPAL = Object.freeze({
title: 'NEPAL',
});
const NEARBY_CITIES = Object.freeze([
- Object.freeze({ id: 'kathmandu', title: 'KATHMANDU', lat: 27.7017, lon: 85.3206, heightM: 3600, placement: 'left' }),
- Object.freeze({ id: 'bhaktapur', title: 'BHAKTAPUR', lat: 27.673, lon: 85.43, heightM: 3400, placement: 'right' }),
- Object.freeze({ id: 'dhulikhel', title: 'DHULIKHEL', lat: 27.6221, lon: 85.5428, heightM: 3600, placement: 'right-lower' }),
+ Object.freeze({
+ id: 'kathmandu',
+ title: 'KATHMANDU',
+ lat: 27.7017,
+ lon: 85.3206,
+ heightM: 3600,
+ placement: 'left',
+ }),
+ Object.freeze({
+ id: 'bhaktapur',
+ title: 'BHAKTAPUR',
+ lat: 27.673,
+ lon: 85.43,
+ heightM: 3400,
+ placement: 'right',
+ }),
+ Object.freeze({
+ id: 'dhulikhel',
+ title: 'DHULIKHEL',
+ lat: 27.6221,
+ lon: 85.5428,
+ heightM: 3600,
+ placement: 'right-lower',
+ }),
]);
const BORDER_COLOR = Cesium.Color.fromCssColorString('#20e7f2');
const BORDER_ACCENT = '#20e7f2';
@@ -104,7 +130,10 @@ const DEFAULT_OVERLAY_HOST = Object.freeze({
const DEFAULT_BOUNDARY_RESOLVER = async () => null;
function abortError() {
- return new DOMException('Bhote Koshi locator activation cancelled', 'AbortError');
+ return new DOMException(
+ 'Bhote Koshi locator activation cancelled',
+ 'AbortError',
+ );
}
function normalizePresentation(value) {
@@ -118,8 +147,10 @@ function normalizePresentation(value) {
if (value === BHOTE_KOSHI_FLOOD_PATH_PRESENTATION) {
return BHOTE_KOSHI_FLOOD_PATH_PRESENTATION;
}
- if (value === BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION
- || value === LEGACY_PATH_BORDER_PRESENTATION) {
+ if (
+ value === BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION ||
+ value === LEGACY_PATH_BORDER_PRESENTATION
+ ) {
return BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION;
}
if (value === BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION) {
@@ -129,8 +160,10 @@ function normalizePresentation(value) {
}
function presentationUsesBoundary(value) {
- return value === BHOTE_KOSHI_REGIONAL_PRESENTATION
- || value === BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION;
+ return (
+ value === BHOTE_KOSHI_REGIONAL_PRESENTATION ||
+ value === BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION
+ );
}
function presentationAnimatesBoundary(value) {
@@ -145,7 +178,10 @@ function smoothstep(from, to, value) {
/** Resolve the persistent text callout's dot, leader, and card entrance. */
export function anchoredCalloutPresentation(elapsedMs) {
- const progress = Math.max(0, Math.min(1, Number(elapsedMs) / CALLOUT_REVEAL_DURATION_MS || 0));
+ const progress = Math.max(
+ 0,
+ Math.min(1, Number(elapsedMs) / CALLOUT_REVEAL_DURATION_MS || 0),
+ );
const contentAlpha = smoothstep(0.34, 1, progress);
return {
alpha: smoothstep(0, 0.18, progress),
@@ -165,12 +201,14 @@ export function revealPolylinePositions(positions, progress) {
const revealed = positions.slice(0, completed + 1);
const next = Math.min(completed + 1, positions.length - 1);
if (next > completed) {
- revealed.push(Cesium.Cartesian3.lerp(
- positions[completed],
- positions[next],
- scaled - completed,
- new Cesium.Cartesian3(),
- ));
+ revealed.push(
+ Cesium.Cartesian3.lerp(
+ positions[completed],
+ positions[next],
+ scaled - completed,
+ new Cesium.Cartesian3(),
+ ),
+ );
}
return revealed.length >= 2 ? revealed : [positions[0], positions[0]];
}
@@ -184,17 +222,19 @@ export function closeRegionRing(ring) {
if (normalized.length < 3) return [];
const [firstLon, firstLat] = normalized[0];
const [lastLon, lastLat] = normalized[normalized.length - 1];
- if (firstLon !== lastLon || firstLat !== lastLat) normalized.push([firstLon, firstLat]);
+ if (firstLon !== lastLon || firstLat !== lastLat)
+ normalized.push([firstLon, firstLat]);
return normalized;
}
/** Return the largest exterior ring from a Polygon or MultiPolygon GeoJSON. */
export function largestGeoJsonRing(geojson) {
- const polygons = geojson?.type === 'Polygon'
- ? [geojson.coordinates]
- : geojson?.type === 'MultiPolygon'
- ? geojson.coordinates
- : [];
+ const polygons =
+ geojson?.type === 'Polygon'
+ ? [geojson.coordinates]
+ : geojson?.type === 'MultiPolygon'
+ ? geojson.coordinates
+ : [];
const rings = polygons
.map((polygon) => polygon?.[0])
.filter((ring) => Array.isArray(ring) && ring.length >= 3);
@@ -242,11 +282,15 @@ export function createBhoteKoshiLocatorLayer({
}
function publishOverlayEntries({ moving = false } = {}) {
- overlayHost.setEntries(BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID, _overlayEntries, {
- cohortLimit: Math.max(1, _overlayEntries.length),
- collisionCapacity: Math.max(1, _overlayEntries.length),
- moving,
- });
+ overlayHost.setEntries(
+ BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID,
+ _overlayEntries,
+ {
+ cohortLimit: Math.max(1, _overlayEntries.length),
+ collisionCapacity: Math.max(1, _overlayEntries.length),
+ moving,
+ },
+ );
}
function clearTriggerApproach({ preserveCallout = false } = {}) {
@@ -277,17 +321,27 @@ export function createBhoteKoshiLocatorLayer({
function removeDataSource() {
stopAnimations();
overlayHost.clearSource(BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID);
- if (_dataSource && _viewer?.dataSources) _viewer.dataSources.remove(_dataSource, true);
+ if (_dataSource && _viewer?.dataSources)
+ _viewer.dataSources.remove(_dataSource, true);
_dataSource = null;
}
- function addCallout(dataSource, subject, color, {
- labelPlacement = 'right',
- sequenceStartedAt = now(),
- visibleAfterMs = 0,
- showAnchor = true,
- } = {}) {
- const position = Cesium.Cartesian3.fromDegrees(subject.lon, subject.lat, subject.heightM);
+ function addCallout(
+ dataSource,
+ subject,
+ color,
+ {
+ labelPlacement = 'right',
+ sequenceStartedAt = now(),
+ visibleAfterMs = 0,
+ showAnchor = true,
+ } = {},
+ ) {
+ const position = Cesium.Cartesian3.fromDegrees(
+ subject.lon,
+ subject.lat,
+ subject.heightM,
+ );
const visibleAt = sequenceStartedAt + visibleAfterMs;
const isVisible = () => now() >= visibleAt;
const pulseProgress = () => {
@@ -295,7 +349,9 @@ export function createBhoteKoshiLocatorLayer({
return (elapsed % CALLOUT_PULSE_DURATION_MS) / CALLOUT_PULSE_DURATION_MS;
};
const ringSize = new Cesium.CallbackProperty(() => {
- return CALLOUT_RING_MIN_SIZE_PX + CALLOUT_RING_SIZE_RANGE_PX * pulseProgress();
+ return (
+ CALLOUT_RING_MIN_SIZE_PX + CALLOUT_RING_SIZE_RANGE_PX * pulseProgress()
+ );
}, false);
const ringColor = new Cesium.CallbackProperty((_time, result) => {
const dynamicColor = Cesium.Color.clone(color, result);
@@ -304,7 +360,9 @@ export function createBhoteKoshiLocatorLayer({
}, false);
const ringOutlineColor = new Cesium.CallbackProperty((_time, result) => {
const dynamicColor = Cesium.Color.clone(color, result);
- dynamicColor.alpha = isVisible() ? 0.08 + 0.72 * (1 - pulseProgress()) : 0;
+ dynamicColor.alpha = isVisible()
+ ? 0.08 + 0.72 * (1 - pulseProgress())
+ : 0;
return dynamicColor;
}, false);
if (showAnchor) {
@@ -318,7 +376,12 @@ export function createBhoteKoshiLocatorLayer({
outlineColor: ringOutlineColor,
outlineWidth: 2,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
- scaleByDistance: new Cesium.NearFarScalar(250000, 0.8, 25000000, 1.35),
+ scaleByDistance: new Cesium.NearFarScalar(
+ 250000,
+ 0.8,
+ 25000000,
+ 1.35,
+ ),
},
});
}
@@ -346,7 +409,12 @@ export function createBhoteKoshiLocatorLayer({
minDistance: 0,
maxDistance: 5_000_000,
distanceFadeStartRatio: 0.9,
- distanceScale: { near: 1200, nearValue: 1, far: 4_000_000, farValue: 0.78 },
+ distanceScale: {
+ near: 1200,
+ nearValue: 1,
+ far: 4_000_000,
+ farValue: 0.78,
+ },
edgeFade: 'none',
horizonCull: true,
terrainOcclusion: false,
@@ -378,7 +446,12 @@ export function createBhoteKoshiLocatorLayer({
minDistance: 0,
maxDistance: 25_000_000,
distanceFadeStartRatio: 0.9,
- distanceScale: { near: 250000, nearValue: 1.05, far: 25000000, farValue: 0.72 },
+ distanceScale: {
+ near: 250000,
+ nearValue: 1.05,
+ far: 25000000,
+ farValue: 0.72,
+ },
edgeFade: 'none',
horizonCull: true,
terrainOcclusion: false,
@@ -403,8 +476,15 @@ export function createBhoteKoshiLocatorLayer({
_calloutPulseFrame = scheduleFrame(pulseFrame);
}
- function addIncidentCallout(dataSource, sequenceStartedAt, visibleAfterMs = 0) {
- addCallout(dataSource, INCIDENT, INCIDENT_COLOR, { sequenceStartedAt, visibleAfterMs });
+ function addIncidentCallout(
+ dataSource,
+ sequenceStartedAt,
+ visibleAfterMs = 0,
+ ) {
+ addCallout(dataSource, INCIDENT, INCIDENT_COLOR, {
+ sequenceStartedAt,
+ visibleAfterMs,
+ });
}
function addTriggerRecordCallout(dataSource) {
@@ -414,22 +494,26 @@ export function createBhoteKoshiLocatorLayer({
TRIGGER_RECORD.heightM,
);
const isVisible = () => now() >= _triggerCalloutStartedAt;
- const presentation = () => anchoredCalloutPresentation(
- now() - _triggerCalloutStartedAt,
- );
+ const presentation = () =>
+ anchoredCalloutPresentation(now() - _triggerCalloutStartedAt);
const pulseProgress = () => {
if (!isVisible()) return 0;
- return ((now() - _triggerCalloutStartedAt) % CALLOUT_PULSE_DURATION_MS)
- / CALLOUT_PULSE_DURATION_MS;
+ return (
+ ((now() - _triggerCalloutStartedAt) % CALLOUT_PULSE_DURATION_MS) /
+ CALLOUT_PULSE_DURATION_MS
+ );
};
dataSource.entities.add({
id: 'bhote-koshi-trigger-record-halo',
position,
show: new Cesium.CallbackProperty(isVisible, false),
point: {
- pixelSize: new Cesium.CallbackProperty(() => (
- CALLOUT_RING_MIN_SIZE_PX + CALLOUT_RING_SIZE_RANGE_PX * pulseProgress()
- ), false),
+ pixelSize: new Cesium.CallbackProperty(
+ () =>
+ CALLOUT_RING_MIN_SIZE_PX +
+ CALLOUT_RING_SIZE_RANGE_PX * pulseProgress(),
+ false,
+ ),
color: new Cesium.CallbackProperty((_time, result) => {
const color = Cesium.Color.clone(INCIDENT_COLOR, result);
color.alpha = 0.16 * (1 - pulseProgress());
@@ -463,9 +547,14 @@ export function createBhoteKoshiLocatorLayer({
protected: true,
active: true,
interactive: true,
- accessibilityLabel: 'Open the USGS initial report for the 26 August 2026 Nepal landslide',
+ accessibilityLabel:
+ 'Open the USGS initial report for the 26 August 2026 Nepal landslide',
activate: () => {
- const opened = window.open(TRIGGER_SOURCE_URL, '_blank', 'noopener,noreferrer');
+ const opened = window.open(
+ TRIGGER_SOURCE_URL,
+ '_blank',
+ 'noopener,noreferrer',
+ );
if (opened) opened.opener = null;
},
collisionGroup: 'ambient-card',
@@ -484,8 +573,13 @@ export function createBhoteKoshiLocatorLayer({
}
function startTriggerApproach(attempt) {
- if (!_enabled || _presentation !== BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION
- || _triggerApproachAttempt !== attempt || attempt.phase !== 'armed') return;
+ if (
+ !_enabled ||
+ _presentation !== BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION ||
+ _triggerApproachAttempt !== attempt ||
+ attempt.phase !== 'armed'
+ )
+ return;
attempt.phase = 'flying';
if (_triggerApproachTimer !== null) cancelDelay(_triggerApproachTimer);
_triggerApproachTimer = null;
@@ -518,9 +612,13 @@ export function createBhoteKoshiLocatorLayer({
duration: TRIGGER_APPROACH_DURATION_SEC,
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
complete: () => {
- if (_triggerApproachAttempt !== attempt || attempt.phase !== 'flying') return;
- if (!_enabled || generation !== _generation
- || _presentation !== BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION) {
+ if (_triggerApproachAttempt !== attempt || attempt.phase !== 'flying')
+ return;
+ if (
+ !_enabled ||
+ generation !== _generation ||
+ _presentation !== BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION
+ ) {
finish();
return;
}
@@ -555,12 +653,16 @@ export function createBhoteKoshiLocatorLayer({
_triggerApproachAttempt = attempt;
const start = () => startTriggerApproach(attempt);
const camera = _viewer?.camera;
- if (!camera?.moveStart?.addEventListener || !camera?.moveEnd?.addEventListener) {
+ if (
+ !camera?.moveStart?.addEventListener ||
+ !camera?.moveEnd?.addEventListener
+ ) {
start();
return;
}
_removeTriggerMoveStart = camera.moveStart.addEventListener(() => {
- if (_triggerApproachAttempt !== attempt || attempt.phase !== 'armed') return;
+ if (_triggerApproachAttempt !== attempt || attempt.phase !== 'armed')
+ return;
if (_triggerApproachTimer !== null) cancelDelay(_triggerApproachTimer);
_triggerApproachTimer = null;
_removeTriggerMoveStart?.();
@@ -585,13 +687,19 @@ export function createBhoteKoshiLocatorLayer({
function addNearbyCityCallouts(dataSource, sequenceStartedAt) {
NEARBY_CITIES.forEach((city, index) => {
- const visibleAt = sequenceStartedAt
- + CITY_CALLOUT_START_DELAY_MS
- + index * CITY_CALLOUT_STAGGER_MS;
- const progress = () => smoothstep(0, 1, (now() - visibleAt) / CITY_CALLOUT_REVEAL_MS);
+ const visibleAt =
+ sequenceStartedAt +
+ CITY_CALLOUT_START_DELAY_MS +
+ index * CITY_CALLOUT_STAGGER_MS;
+ const progress = () =>
+ smoothstep(0, 1, (now() - visibleAt) / CITY_CALLOUT_REVEAL_MS);
const labelOnLeft = city.placement === 'left';
const labelLower = city.placement === 'right-lower';
- const position = Cesium.Cartesian3.fromDegrees(city.lon, city.lat, city.heightM);
+ const position = Cesium.Cartesian3.fromDegrees(
+ city.lon,
+ city.lat,
+ city.heightM,
+ );
queueOverlayEntry({
id: `bhote-koshi-city-${city.id}`,
position,
@@ -612,7 +720,12 @@ export function createBhoteKoshiLocatorLayer({
minDistance: 0,
maxDistance: 3_000_000,
distanceFadeStartRatio: 0.82,
- distanceScale: { near: 100000, nearValue: 1.08, far: 3000000, farValue: 0.78 },
+ distanceScale: {
+ near: 100000,
+ nearValue: 1.08,
+ far: 3000000,
+ farValue: 0.78,
+ },
edgeFade: 'keyhole',
horizonCull: true,
terrainOcclusion: false,
@@ -622,32 +735,45 @@ export function createBhoteKoshiLocatorLayer({
});
}
- function addIncidentPlaceCallouts(dataSource, sequenceStartedAt, {
- pulseHalos = false, places = INCIDENT_OVERVIEW_PLACES,
- } = {}) {
+ function addIncidentPlaceCallouts(
+ dataSource,
+ sequenceStartedAt,
+ { pulseHalos = false, places = INCIDENT_OVERVIEW_PLACES } = {},
+ ) {
places.forEach((place, index) => {
- const visibleAt = sequenceStartedAt
- + PLACE_CALLOUT_START_DELAY_MS
- + index * PLACE_CALLOUT_STAGGER_MS;
- const progress = () => smoothstep(0, 1, (now() - visibleAt) / PLACE_CALLOUT_REVEAL_MS);
+ const visibleAt =
+ sequenceStartedAt +
+ PLACE_CALLOUT_START_DELAY_MS +
+ index * PLACE_CALLOUT_STAGGER_MS;
+ const progress = () =>
+ smoothstep(0, 1, (now() - visibleAt) / PLACE_CALLOUT_REVEAL_MS);
const labelOnLeft = place.placement === 'left';
const labelLower = place.placement === 'right-lower';
- const position = Cesium.Cartesian3.fromDegrees(place.lon, place.lat, place.heightM);
+ const position = Cesium.Cartesian3.fromDegrees(
+ place.lon,
+ place.lat,
+ place.heightM,
+ );
if (pulseHalos) {
- const pulseSequenceDurationMs = PLACE_CALLOUT_STAGGER_MS * places.length;
+ const pulseSequenceDurationMs =
+ PLACE_CALLOUT_STAGGER_MS * places.length;
const pulseWindowElapsed = () => {
const sequenceElapsed = Math.max(
0,
now() - sequenceStartedAt - PLACE_CALLOUT_START_DELAY_MS,
);
- return (sequenceElapsed % pulseSequenceDurationMs)
- - index * PLACE_CALLOUT_STAGGER_MS;
+ return (
+ (sequenceElapsed % pulseSequenceDurationMs) -
+ index * PLACE_CALLOUT_STAGGER_MS
+ );
};
const pulseIsActive = () => {
const elapsed = pulseWindowElapsed();
- return now() >= visibleAt
- && elapsed >= 0
- && elapsed < PLACE_CALLOUT_STAGGER_MS;
+ return (
+ now() >= visibleAt &&
+ elapsed >= 0 &&
+ elapsed < PLACE_CALLOUT_STAGGER_MS
+ );
};
const pulseProgress = () => {
return Math.max(
@@ -660,9 +786,12 @@ export function createBhoteKoshiLocatorLayer({
position,
show: new Cesium.CallbackProperty(pulseIsActive, false),
point: {
- pixelSize: new Cesium.CallbackProperty(() => (
- CALLOUT_RING_MIN_SIZE_PX + CALLOUT_RING_SIZE_RANGE_PX * pulseProgress()
- ), false),
+ pixelSize: new Cesium.CallbackProperty(
+ () =>
+ CALLOUT_RING_MIN_SIZE_PX +
+ CALLOUT_RING_SIZE_RANGE_PX * pulseProgress(),
+ false,
+ ),
color: new Cesium.CallbackProperty((_time, result) => {
const color = Cesium.Color.clone(INCIDENT_COLOR, result);
color.alpha = 0.16 * (1 - pulseProgress());
@@ -699,7 +828,12 @@ export function createBhoteKoshiLocatorLayer({
minDistance: 0,
maxDistance: 500_000,
distanceFadeStartRatio: 0.82,
- distanceScale: { near: 5000, nearValue: 1.08, far: 500000, farValue: 0.82 },
+ distanceScale: {
+ near: 5000,
+ nearValue: 1.08,
+ far: 500000,
+ farValue: 0.82,
+ },
edgeFade: 'keyhole',
horizonCull: true,
terrainOcclusion: false,
@@ -710,16 +844,18 @@ export function createBhoteKoshiLocatorLayer({
}
function addIncidentFloodPath(dataSource, sequenceStartedAt) {
- const positions = BHOTE_KOSHI_FLOOD_PATH.map(([lon, lat]) => (
- Cesium.Cartesian3.fromDegrees(lon, lat)
- ));
+ const positions = BHOTE_KOSHI_FLOOD_PATH.map(([lon, lat]) =>
+ Cesium.Cartesian3.fromDegrees(lon, lat),
+ );
const drawStartedAt = sequenceStartedAt + FLOOD_PATH_START_DELAY_MS;
- const animatedPositions = new Cesium.CallbackProperty(() => (
- revealPolylinePositions(
- positions,
- (now() - drawStartedAt) / FLOOD_PATH_DRAW_DURATION_MS,
- )
- ), false);
+ const animatedPositions = new Cesium.CallbackProperty(
+ () =>
+ revealPolylinePositions(
+ positions,
+ (now() - drawStartedAt) / FLOOD_PATH_DRAW_DURATION_MS,
+ ),
+ false,
+ );
dataSource.entities.add({
id: 'bhote-koshi-flood-path',
show: new Cesium.CallbackProperty(() => now() >= drawStartedAt, false),
@@ -739,9 +875,14 @@ export function createBhoteKoshiLocatorLayer({
const positions = Cesium.Cartesian3.fromDegreesArray(closed.flat());
const hierarchy = new Cesium.PolygonHierarchy(positions.slice(0, -1));
const drawStartedAt = sequenceStartedAt + BORDER_START_DELAY_MS;
- const animatedPositions = new Cesium.CallbackProperty(() => (
- revealPolylinePositions(positions, (now() - drawStartedAt) / BORDER_DRAW_DURATION_MS)
- ), false);
+ const animatedPositions = new Cesium.CallbackProperty(
+ () =>
+ revealPolylinePositions(
+ positions,
+ (now() - drawStartedAt) / BORDER_DRAW_DURATION_MS,
+ ),
+ false,
+ );
dataSource.entities.add({
id: 'nepal-border-fill',
show: new Cesium.CallbackProperty(() => now() >= drawStartedAt, false),
@@ -773,8 +914,12 @@ export function createBhoteKoshiLocatorLayer({
});
const generation = _generation;
const drawFrame = () => {
- if (!_enabled || generation !== _generation
- || !presentationAnimatesBoundary(_presentation)) return;
+ if (
+ !_enabled ||
+ generation !== _generation ||
+ !presentationAnimatesBoundary(_presentation)
+ )
+ return;
requestRender('bhote-koshi-locator-border-draw');
if (now() - drawStartedAt < BORDER_DRAW_DURATION_MS) {
_borderAnimationFrame = scheduleFrame(drawFrame);
@@ -797,37 +942,43 @@ export function createBhoteKoshiLocatorLayer({
} else if (_presentation === BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION) {
const sequenceStartedAt = now();
addIncidentPlaceCallouts(_dataSource, sequenceStartedAt, {
- pulseHalos: true, places: INCIDENT_OVERVIEW_PLACES,
+ pulseHalos: true,
+ places: INCIDENT_OVERVIEW_PLACES,
});
_placeRevealMoving = true;
- _placeRevealSettledAt = sequenceStartedAt
- + PLACE_CALLOUT_START_DELAY_MS
- + (INCIDENT_OVERVIEW_PLACES.length - 1) * PLACE_CALLOUT_STAGGER_MS
- + PLACE_CALLOUT_REVEAL_MS;
+ _placeRevealSettledAt =
+ sequenceStartedAt +
+ PLACE_CALLOUT_START_DELAY_MS +
+ (INCIDENT_OVERVIEW_PLACES.length - 1) * PLACE_CALLOUT_STAGGER_MS +
+ PLACE_CALLOUT_REVEAL_MS;
} else if (_presentation === BHOTE_KOSHI_FLOOD_PATH_PRESENTATION) {
const sequenceStartedAt = now();
- const settledPlacesAt = sequenceStartedAt
- - PLACE_CALLOUT_START_DELAY_MS
- - (INCIDENT_OVERVIEW_PLACES.length - 1) * PLACE_CALLOUT_STAGGER_MS
- - PLACE_CALLOUT_REVEAL_MS;
+ const settledPlacesAt =
+ sequenceStartedAt -
+ PLACE_CALLOUT_START_DELAY_MS -
+ (INCIDENT_OVERVIEW_PLACES.length - 1) * PLACE_CALLOUT_STAGGER_MS -
+ PLACE_CALLOUT_REVEAL_MS;
addIncidentFloodPath(_dataSource, sequenceStartedAt);
addIncidentPlaceCallouts(_dataSource, settledPlacesAt);
} else if (_presentation === BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION) {
const sequenceStartedAt = now();
- const settledPathAt = sequenceStartedAt
- - FLOOD_PATH_START_DELAY_MS
- - FLOOD_PATH_DRAW_DURATION_MS;
- const settledPlacesAt = sequenceStartedAt
- - PLACE_CALLOUT_START_DELAY_MS
- - (INCIDENT_OVERVIEW_PLACES.length - 1) * PLACE_CALLOUT_STAGGER_MS
- - PLACE_CALLOUT_REVEAL_MS;
+ const settledPathAt =
+ sequenceStartedAt -
+ FLOOD_PATH_START_DELAY_MS -
+ FLOOD_PATH_DRAW_DURATION_MS;
+ const settledPlacesAt =
+ sequenceStartedAt -
+ PLACE_CALLOUT_START_DELAY_MS -
+ (INCIDENT_OVERVIEW_PLACES.length - 1) * PLACE_CALLOUT_STAGGER_MS -
+ PLACE_CALLOUT_REVEAL_MS;
addIncidentFloodPath(_dataSource, settledPathAt);
addIncidentPlaceCallouts(_dataSource, settledPlacesAt);
} else if (_presentation === BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION) {
const sequenceStartedAt = now();
- const settledPathAt = sequenceStartedAt
- - FLOOD_PATH_START_DELAY_MS
- - FLOOD_PATH_DRAW_DURATION_MS;
+ const settledPathAt =
+ sequenceStartedAt -
+ FLOOD_PATH_START_DELAY_MS -
+ FLOOD_PATH_DRAW_DURATION_MS;
addIncidentFloodPath(_dataSource, settledPathAt);
addTriggerRecordCallout(_dataSource);
armTriggerApproach();
@@ -841,14 +992,22 @@ export function createBhoteKoshiLocatorLayer({
return;
}
if (_presentation === BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION) {
- const settledAt = sequenceStartedAt - BORDER_START_DELAY_MS - BORDER_DRAW_DURATION_MS;
- if (_boundaryRing) addNepalBoundary(_dataSource, _boundaryRing, settledAt);
- addIncidentCallout(_dataSource, sequenceStartedAt - CALLOUT_REVEAL_DURATION_MS);
+ const settledAt =
+ sequenceStartedAt - BORDER_START_DELAY_MS - BORDER_DRAW_DURATION_MS;
+ if (_boundaryRing)
+ addNepalBoundary(_dataSource, _boundaryRing, settledAt);
+ addIncidentCallout(
+ _dataSource,
+ sequenceStartedAt - CALLOUT_REVEAL_DURATION_MS,
+ );
addNearbyCityCallouts(_dataSource, sequenceStartedAt);
} else {
- if (_boundaryRing) addNepalBoundary(_dataSource, _boundaryRing, sequenceStartedAt);
+ if (_boundaryRing)
+ addNepalBoundary(_dataSource, _boundaryRing, sequenceStartedAt);
const incidentDelayMs = _boundaryRing
- ? BORDER_START_DELAY_MS + BORDER_DRAW_DURATION_MS + INCIDENT_CALLOUT_GAP_MS
+ ? BORDER_START_DELAY_MS +
+ BORDER_DRAW_DURATION_MS +
+ INCIDENT_CALLOUT_GAP_MS
: BORDER_START_DELAY_MS;
addIncidentCallout(_dataSource, sequenceStartedAt, incidentDelayMs);
}
@@ -880,7 +1039,8 @@ export function createBhoteKoshiLocatorLayer({
try {
const boundary = await boundaryResolver(signal);
- if (signal?.aborted || !_enabled || generation !== _generation) throw abortError();
+ if (signal?.aborted || !_enabled || generation !== _generation)
+ throw abortError();
_boundaryRing = closeRegionRing(boundary?.ring);
_boundaryReady = _boundaryRing.length >= 4;
_boundaryResolved = true;
@@ -897,7 +1057,10 @@ export function createBhoteKoshiLocatorLayer({
if (generation === _generation) removeDataSource();
throw error;
}
- console.warn('[Data:BhoteKoshiLocator] Nepal boundary lookup failed:', error);
+ console.warn(
+ '[Data:BhoteKoshiLocator] Nepal boundary lookup failed:',
+ error,
+ );
_boundaryResolved = true;
_boundaryRing = null;
if (presentationUsesBoundary(_presentation)) renderPresentation();
@@ -940,8 +1103,9 @@ export function createBhoteKoshiLocatorLayer({
},
setParams(params = {}) {
_presentation = normalizePresentation(params.presentation);
- _approachEnabled = _presentation === BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION
- && params.preview !== true;
+ _approachEnabled =
+ _presentation === BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION &&
+ params.preview !== true;
if (_enabled) renderPresentation();
return true;
},
@@ -955,35 +1119,40 @@ export function createBhoteKoshiLocatorLayer({
id: 'nepal-context',
label: 'NEPAL',
active: _presentation === NEPAL_CONTEXT_PRESENTATION,
- title: 'Show the Nepal context callout without a border or country fill',
+ title:
+ 'Show the Nepal context callout without a border or country fill',
params: { presentation: NEPAL_CONTEXT_PRESENTATION },
},
{
id: 'bhote-koshi-regional',
label: 'BHOTE',
active: _presentation === BHOTE_KOSHI_REGIONAL_PRESENTATION,
- title: 'Introduce Nepal, draw its border, then reveal the Bhote Koshi incident',
+ title:
+ 'Introduce Nepal, draw its border, then reveal the Bhote Koshi incident',
params: { presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION },
},
{
id: 'bhote-koshi-city-context',
label: 'CITIES',
active: _presentation === BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION,
- title: 'Keep the regional incident context and introduce nearby city labels',
+ title:
+ 'Keep the regional incident context and introduce nearby city labels',
params: { presentation: BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION },
},
{
id: 'bhote-koshi-incident-places',
label: 'PLACES',
active: _presentation === BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION,
- title: 'Introduce the reconstruction scene key places from trigger to downstream',
+ title:
+ 'Introduce the reconstruction scene key places from trigger to downstream',
params: { presentation: BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION },
},
{
id: 'bhote-koshi-flood-path',
label: 'PATH',
active: _presentation === BHOTE_KOSHI_FLOOD_PATH_PRESENTATION,
- title: 'Animate the sourced cyan river-centerline route to Trishuli Bazaar',
+ title:
+ 'Animate the sourced cyan river-centerline route to Trishuli Bazaar',
params: { presentation: BHOTE_KOSHI_FLOOD_PATH_PRESENTATION },
},
{
@@ -997,7 +1166,8 @@ export function createBhoteKoshiLocatorLayer({
id: 'bhote-koshi-trigger-record',
label: 'RECORD',
active: _presentation === BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
- title: 'Reveal the trigger record while making a slow camera approach',
+ title:
+ 'Reveal the trigger record while making a slow camera approach',
params: {
presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
preview: true,
@@ -1012,11 +1182,11 @@ export function createBhoteKoshiLocatorLayer({
count: _enabled ? 1 : 0,
status: !_enabled
? 'idle'
- : (_presentation === BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION
- || _presentation === BHOTE_KOSHI_FLOOD_PATH_PRESENTATION
- || _presentation === BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION
- || _presentation === BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION
- || _boundaryReady)
+ : _presentation === BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION ||
+ _presentation === BHOTE_KOSHI_FLOOD_PATH_PRESENTATION ||
+ _presentation === BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION ||
+ _presentation === BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION ||
+ _boundaryReady
? 'nominal'
: 'degraded',
source: 'OpenStreetMap + GeoPera',
diff --git a/src/data/bhoteKoshiShotPaths.js b/src/data/bhoteKoshiShotPaths.js
index 3eed1ea8a..adda5ba18 100644
--- a/src/data/bhoteKoshiShotPaths.js
+++ b/src/data/bhoteKoshiShotPaths.js
@@ -1,26 +1,40 @@
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',
+ '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);
+ 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; }
+ 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 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);
@@ -36,24 +50,42 @@ export function buildSourceShotPaths(event, route = BHOTE_KOSHI_FLOOD_PATH) {
// 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);
+ 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)) {
+ 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),
- })));
+ 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'));
+ 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);
diff --git a/src/data/layerState.js b/src/data/layerState.js
index d2415864d..68eebd777 100644
--- a/src/data/layerState.js
+++ b/src/data/layerState.js
@@ -302,10 +302,26 @@ export const SHARE_TRACKING_RESTORE_POLICIES = Object.freeze({
* owns stable URL ordering.
*/
export const LAYER_STATE_REGISTRY = Object.freeze([
- Object.freeze({ id: 'ais-live-vessels', token: 'a', disposition: 'enabled-only' }),
- Object.freeze({ id: 'alpr-cameras', 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: 'ais-live-vessels',
+ token: 'a',
+ disposition: 'enabled-only',
+ }),
+ Object.freeze({
+ id: 'alpr-cameras',
+ 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',
diff --git a/src/overlays/worldOverlay.js b/src/overlays/worldOverlay.js
index 46c8c9035..4266871a1 100644
--- a/src/overlays/worldOverlay.js
+++ b/src/overlays/worldOverlay.js
@@ -504,18 +504,22 @@ export function normalizeOverlayEntry(sourceId, entry) {
: 'keyhole',
horizonCull: entry.horizonCull !== false,
terrainOcclusion: entry.terrainOcclusion === true,
- 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),
+ 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),
@@ -1805,19 +1809,36 @@ function resetFrameDomains() {
// 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);
+ 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;
+ 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;
@@ -1942,19 +1963,31 @@ function snapshotAndProject(entry, source, viewProjection, keyhole) {
}
record.paintScale *= altitudeFactor;
}
- record.altitudeAlpha = entry.altitudeFadeEnd === Number.POSITIVE_INFINITY
- ? (Number.isFinite(cameraAltitude) && cameraAltitude < entry.minAltitude ? 0 : 1)
- : altitudeFade(cameraAltitude, record.altitudeOptions);
+ record.altitudeAlpha =
+ entry.altitudeFadeEnd === Number.POSITIVE_INFINITY
+ ? Number.isFinite(cameraAltitude) && cameraAltitude < entry.minAltitude
+ ? 0
+ : 1
+ : altitudeFade(cameraAltitude, record.altitudeOptions);
record.leaderProgress = 1;
record.contentAlpha = 1;
- if (entry.presentationScale !== 1 || entry.leaderProgress !== 1
- || entry.contentAlpha !== 1 || typeof entry.sourceAlpha === 'function') {
+ 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
- || record.altitudeAlpha <= 0 || record.sourceAlpha <= 0) return null;
+ if (
+ record.distanceAlpha <= 0 ||
+ record.paintScale <= 0 ||
+ record.altitudeAlpha <= 0 ||
+ record.sourceAlpha <= 0
+ )
+ return null;
measureOverlayEntry(_ctx, entry, record.layout);
record.placementInput.anchorX = record.screen.x;
diff --git a/src/overlays/worldOverlayDraw.js b/src/overlays/worldOverlayDraw.js
index 7288fe968..f64a3170d 100644
--- a/src/overlays/worldOverlayDraw.js
+++ b/src/overlays/worldOverlayDraw.js
@@ -572,13 +572,11 @@ function drawLeader(
ctx.beginPath();
ctx.moveTo(startX, startY);
if (
- style === 'elbow'
- && (placement.corner === 'left' || placement.corner === 'right')
+ 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 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;
@@ -590,7 +588,10 @@ function drawLeader(
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.lineTo(
+ startX + Math.min(horizontalLength, remaining) * direction,
+ elbowY,
+ );
}
ctx.stroke();
return;
@@ -615,10 +616,17 @@ function drawAnchorDot(ctx, entry, placement) {
ctx.stroke();
}
-function drawCardChrome(ctx, entry, placement, selected = false, drawLeaderLine = true) {
+function drawCardChrome(
+ ctx,
+ entry,
+ placement,
+ selected = false,
+ drawLeaderLine = true,
+) {
const { x, y, w, h } = placement.rect;
const accent = entry.accent || WORLD_OVERLAY_STYLE.accent;
- if (drawLeaderLine) drawLeader(ctx, placement, accent, 1, 1, entry.leaderStyle);
+ if (drawLeaderLine)
+ drawLeader(ctx, placement, accent, 1, 1, entry.leaderStyle);
ctx.beginPath();
roundedRectPath(ctx, x, y, w, h);
ctx.fillStyle = selected
@@ -893,7 +901,8 @@ export function paintCard(
entry.leaderStyle,
);
drawAnchorDot(ctx, entry, placement);
- ctx.globalAlpha = alpha * Math.max(0, Math.min(1, Number(contentAlpha) || 0));
+ ctx.globalAlpha =
+ alpha * Math.max(0, Math.min(1, Number(contentAlpha) || 0));
drawCardChrome(ctx, entry, placement, false, false);
} else {
drawCardChrome(ctx, entry, placement, false);
@@ -1112,15 +1121,33 @@ export function paintOverlayEntry(
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.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, leaderProgress, contentAlpha);
+ return paintThumbnail(
+ ctx,
+ entry,
+ placement,
+ alpha,
+ leaderProgress,
+ contentAlpha,
+ );
}
if (entry.variant === 'card') {
- return paintCard(ctx, entry, placement, alpha, leaderProgress, contentAlpha);
+ return paintCard(
+ ctx,
+ entry,
+ placement,
+ alpha,
+ leaderProgress,
+ contentAlpha,
+ );
}
- if (entry.variant === 'track') return paintTrack(ctx, entry, placement, alpha);
+ if (entry.variant === 'track')
+ return paintTrack(ctx, entry, placement, alpha);
return paintLabel(ctx, entry, placement, alpha);
}
diff --git a/src/scenes/director.js b/src/scenes/director.js
index 6f62d7158..5958080c3 100644
--- a/src/scenes/director.js
+++ b/src/scenes/director.js
@@ -151,7 +151,10 @@ function recipeToScene(recipe) {
return {
id: uid('shot'),
title: keyframe.title || `Shot ${idx + 1}`,
- durationSec: Math.max(0.2, keyframe.duration || DEFAULT_SHOT_DURATION_SEC),
+ durationSec: Math.max(
+ 0.2,
+ keyframe.duration || DEFAULT_SHOT_DURATION_SEC,
+ ),
holdSec: Math.max(0, keyframe.hold || 0),
camera: {
lat: keyframe.lat,
@@ -164,14 +167,17 @@ function recipeToScene(recipe) {
visual: {
style: recipe.style || 'normal',
bloom: {
- enabled: typeof post.bloom === 'number' ? post.bloom > 0 : !!post.bloom,
- intensity: typeof post.bloom === 'number'
- ? decodeBloomIntensity(post.bloom, 1)
- : BLOOM_INTENSITY_DEFAULT,
+ enabled:
+ typeof post.bloom === 'number' ? post.bloom > 0 : !!post.bloom,
+ intensity:
+ typeof post.bloom === 'number'
+ ? decodeBloomIntensity(post.bloom, 1)
+ : BLOOM_INTENSITY_DEFAULT,
version: BLOOM_SCALE_VERSION,
},
sharpen: {
- enabled: typeof post.sharpen === 'boolean' ? post.sharpen : !!post.sharpen,
+ enabled:
+ typeof post.sharpen === 'boolean' ? post.sharpen : !!post.sharpen,
intensity: 65,
},
hud: {
@@ -194,10 +200,14 @@ function recipeToScene(recipe) {
return {
id: recipe.id || uid('scene'),
title: recipe.title || 'Untitled Scene',
- releaseLayerIds: [...new Set(
- (Array.isArray(recipe.releaseLayerIds) ? recipe.releaseLayerIds : [])
- .filter((layerId) => typeof layerId === 'string' && layerId.trim()),
- )],
+ releaseLayerIds: [
+ ...new Set(
+ (Array.isArray(recipe.releaseLayerIds)
+ ? recipe.releaseLayerIds
+ : []
+ ).filter((layerId) => typeof layerId === 'string' && layerId.trim()),
+ ),
+ ],
appliedShotPacks: [],
shots,
};
@@ -212,9 +222,9 @@ function createDefaultProject() {
version: PROJECT_VERSION,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
- installedBuiltInSceneIds: SCENE_RECIPES
- .filter((recipe) => typeof recipe.installAlongsideSceneId === 'string')
- .map((recipe) => recipe.id),
+ installedBuiltInSceneIds: SCENE_RECIPES.filter(
+ (recipe) => typeof recipe.installAlongsideSceneId === 'string',
+ ).map((recipe) => recipe.id),
scenes: SCENE_RECIPES.map(recipeToScene),
};
}
@@ -291,8 +301,13 @@ function normalizeShot(
),
),
},
- ...(SCENE_MAP_STACK_IDS.has(visual.mapStack) ? { mapStack: visual.mapStack } : {}),
- styleParams: visual.styleParams && typeof visual.styleParams === 'object' ? deepClone(visual.styleParams) : {},
+ ...(SCENE_MAP_STACK_IDS.has(visual.mapStack)
+ ? { mapStack: visual.mapStack }
+ : {}),
+ styleParams:
+ visual.styleParams && typeof visual.styleParams === 'object'
+ ? deepClone(visual.styleParams)
+ : {},
},
layers: Object.fromEntries(
Object.entries(rawShot?.layers || {}).map(([layerId, value]) => [
@@ -300,7 +315,9 @@ function normalizeShot(
normalizeLayerEntry(value),
]),
),
- ...(typeof rawShot?.sourcePackId === 'string' ? { sourcePackId: rawShot.sourcePackId } : {}),
+ ...(typeof rawShot?.sourcePackId === 'string'
+ ? { sourcePackId: rawShot.sourcePackId }
+ : {}),
...(Number.isFinite(Number(rawShot?.sourcePackVersion))
? { sourcePackVersion: Number(rawShot.sourcePackVersion) }
: {}),
@@ -330,11 +347,20 @@ function normalizeProject(rawProject) {
return {
id: scene?.id || uid('scene'),
title: scene?.title || `Scene ${sceneIdx + 1}`,
- releaseLayerIds: [...new Set(
- (Array.isArray(scene?.releaseLayerIds) ? scene.releaseLayerIds : [])
- .filter((layerId) => typeof layerId === 'string' && layerId.trim()),
- )],
- appliedShotPacks: (Array.isArray(scene?.appliedShotPacks) ? scene.appliedShotPacks : [])
+ releaseLayerIds: [
+ ...new Set(
+ (Array.isArray(scene?.releaseLayerIds)
+ ? scene.releaseLayerIds
+ : []
+ ).filter(
+ (layerId) => typeof layerId === 'string' && layerId.trim(),
+ ),
+ ),
+ ],
+ appliedShotPacks: (Array.isArray(scene?.appliedShotPacks)
+ ? scene.appliedShotPacks
+ : []
+ )
.filter((entry) => typeof entry?.id === 'string')
.map((entry) => ({
id: entry.id,
@@ -342,10 +368,10 @@ function normalizeProject(rawProject) {
...(entry.shotBindings && typeof entry.shotBindings === 'object'
? {
shotBindings: Object.fromEntries(
- Object.entries(entry.shotBindings)
- .filter(([title, shotId]) => (
- typeof title === 'string' && typeof shotId === 'string'
- )),
+ Object.entries(entry.shotBindings).filter(
+ ([title, shotId]) =>
+ typeof title === 'string' && typeof shotId === 'string',
+ ),
),
}
: {}),
@@ -364,12 +390,14 @@ function normalizeProject(rawProject) {
version: PROJECT_VERSION,
createdAt: rawProject.createdAt || new Date().toISOString(),
updatedAt: new Date().toISOString(),
- installedBuiltInSceneIds: [...new Set(
- (Array.isArray(rawProject.installedBuiltInSceneIds)
- ? rawProject.installedBuiltInSceneIds
- : [])
- .filter((sceneId) => typeof sceneId === 'string' && sceneId.trim()),
- )],
+ installedBuiltInSceneIds: [
+ ...new Set(
+ (Array.isArray(rawProject.installedBuiltInSceneIds)
+ ? rawProject.installedBuiltInSceneIds
+ : []
+ ).filter((sceneId) => typeof sceneId === 'string' && sceneId.trim()),
+ ),
+ ],
scenes,
};
}
@@ -388,7 +416,12 @@ export class SceneDirector {
* @param {Object} styleManager - Controls visual state (bloom, sharpen, HUD, detection, style presets)
* @param {Object} dataManager - Manages data layer enable/disable and per-layer params
*/
- constructor(viewer, styleManager, dataManager, { isMapStackAvailable = () => false } = {}) {
+ constructor(
+ viewer,
+ styleManager,
+ dataManager,
+ { isMapStackAvailable = () => false } = {},
+ ) {
this._destroyed = false;
this._pendingWork = new Set();
this.viewer = viewer;
@@ -432,26 +465,47 @@ export class SceneDirector {
/** @type {string|null} Scene whose layer state most recently landed. */
this._loadedSceneId = null;
- this._presentation = { status: 'Ready', progress: 0, runtime: '', playbackActive: false, keyboardEnabled: false };
- this._state = createStateChannel(() => ({ ...this.getPlaybackStatus(), ...this._presentation, hasRun: !!this._lastRunJson }));
+ this._presentation = {
+ status: 'Ready',
+ progress: 0,
+ runtime: '',
+ playbackActive: false,
+ keyboardEnabled: false,
+ };
+ this._state = createStateChannel(() => ({
+ ...this.getPlaybackStatus(),
+ ...this._presentation,
+ hasRun: !!this._lastRunJson,
+ }));
this._bootstrapLegacyShotPacks();
// Upgrade only already-installed packs; unrelated Scenes are untouched.
for (const scene of this._project.scenes) {
for (const marker of [...(scene.appliedShotPacks || [])]) {
const recipe = getSceneAppendRecipeById(marker.id);
- if (recipe?.expansionFromVersion && marker.version > 0
- && marker.version <= recipe.expansionFromVersion) {
- this.appendShotPack(scene.id, marker.id, { render: false, announce: false });
+ if (
+ recipe?.expansionFromVersion &&
+ marker.version > 0 &&
+ marker.version <= recipe.expansionFromVersion
+ ) {
+ this.appendShotPack(scene.id, marker.id, {
+ render: false,
+ announce: false,
+ });
}
}
}
this._initUI();
- this._visibilityUnsubscribe = this.dataManager.subscribeVisibilityRequests?.((change) => {
- if (change.enabled !== false || !['user', 'voice', 'tool'].includes(change.origin)) return;
- const scene = this._getSelectedScene();
- if (!scene?.releaseLayerIds?.includes(change.layerId)) return;
- this.stopScene('Scene layer turned off');
- });
+ this._visibilityUnsubscribe =
+ this.dataManager.subscribeVisibilityRequests?.((change) => {
+ if (
+ change.enabled !== false ||
+ !['user', 'voice', 'tool'].includes(change.origin)
+ )
+ return;
+ const scene = this._getSelectedScene();
+ if (!scene?.releaseLayerIds?.includes(change.layerId)) return;
+ this.stopScene('Scene layer turned off');
+ });
}
_trackWork(promise) {
@@ -499,7 +553,10 @@ export class SceneDirector {
const installed = new Set(project.installedBuiltInSceneIds || []);
let migrated = false;
for (const recipe of SCENE_RECIPES) {
- if (typeof recipe.installAlongsideSceneId !== 'string' || installed.has(recipe.id)) {
+ if (
+ typeof recipe.installAlongsideSceneId !== 'string' ||
+ installed.has(recipe.id)
+ ) {
continue;
}
let anchorIndex = project.scenes.findIndex(
@@ -527,7 +584,10 @@ export class SceneDirector {
} catch (e) {
// Keep the migrated in-memory project usable even if this origin
// refuses persistence; the normal UI save path will surface errors.
- console.warn('[Scenes] Could not persist built-in scene migration:', e);
+ console.warn(
+ '[Scenes] Could not persist built-in scene migration:',
+ e,
+ );
}
}
return project;
@@ -754,16 +814,22 @@ export class SceneDirector {
const expectedTitles = Array.isArray(bootstrap.fromShotTitles)
? bootstrap.fromShotTitles
: [];
- const scene = this._project.scenes.find((candidate) => (
- candidate.title === bootstrap.targetSceneTitle
- && candidate.shots.length === expectedTitles.length
- && candidate.shots.every((shot, index) => shot.title === expectedTitles[index])
- ));
+ const scene = this._project.scenes.find(
+ (candidate) =>
+ candidate.title === bootstrap.targetSceneTitle &&
+ candidate.shots.length === expectedTitles.length &&
+ candidate.shots.every(
+ (shot, index) => shot.title === expectedTitles[index],
+ ),
+ );
if (!scene) continue;
const originalProject = deepClone(this._project);
try {
- localStorage.setItem(STORAGE_CHECKPOINT_KEY, JSON.stringify(originalProject));
+ localStorage.setItem(
+ STORAGE_CHECKPOINT_KEY,
+ JSON.stringify(originalProject),
+ );
} catch {
this._toastStorageError();
continue;
@@ -774,7 +840,11 @@ export class SceneDirector {
id: null,
cameraPath: bootstrap.cameraPath,
}).shots;
- for (let index = 0; index < Math.min(scene.shots.length, canonicalBase.length); index++) {
+ for (
+ let index = 0;
+ index < Math.min(scene.shots.length, canonicalBase.length);
+ index++
+ ) {
canonicalBase[index].id = scene.shots[index].id;
canonicalBase[index].camera = deepClone(scene.shots[index].camera);
}
@@ -807,56 +877,95 @@ export class SceneDirector {
* @param {boolean} [options.announce=true]
* @returns {{appended: boolean, reason?: string, shotCount?: number, firstShotId?: string}}
*/
- appendShotPack(sceneId, packId, {
- writeCheckpoint = true,
- render = true,
- announce = true,
- } = {}) {
- const scene = this._project.scenes.find((candidate) => candidate.id === sceneId);
+ appendShotPack(
+ sceneId,
+ packId,
+ { writeCheckpoint = true, render = true, announce = true } = {},
+ ) {
+ const scene = this._project.scenes.find(
+ (candidate) => candidate.id === sceneId,
+ );
if (!scene) return { appended: false, reason: 'scene-not-found' };
const recipe = getSceneAppendRecipeById(packId);
if (!recipe) return { appended: false, reason: 'pack-not-found' };
scene.appliedShotPacks ||= [];
- const marker = scene.appliedShotPacks.find((entry) => entry.id === recipe.id);
+ const marker = scene.appliedShotPacks.find(
+ (entry) => entry.id === recipe.id,
+ );
const targetVersion = Number(recipe.version) || 1;
if ((Number(marker?.version) || 0) >= targetVersion) {
return { appended: false, reason: 'already-appended' };
}
const pack = recipeToScene(recipe);
- const existingPackShots = scene.shots.filter((shot) => shot.sourcePackId === recipe.id);
- const expanding = recipe.expansionFromVersion && marker?.version > 0
- && marker.version <= recipe.expansionFromVersion
- && existingPackShots.length !== recipe.requiredSourcePackBeatIds.length;
- const existingPackBeatIds = existingPackShots.map((shot) =>
- shot.layers?.[recipe.requiredSourcePackLayerId]?.params?.beatId);
- const previousSourcePackVariants = Array.isArray(recipe.previousRequiredSourcePackBeatIdVariants)
+ const existingPackShots = scene.shots.filter(
+ (shot) => shot.sourcePackId === recipe.id,
+ );
+ const expanding =
+ recipe.expansionFromVersion &&
+ marker?.version > 0 &&
+ marker.version <= recipe.expansionFromVersion &&
+ existingPackShots.length !== recipe.requiredSourcePackBeatIds.length;
+ const existingPackBeatIds = existingPackShots.map(
+ (shot) => shot.layers?.[recipe.requiredSourcePackLayerId]?.params?.beatId,
+ );
+ const previousSourcePackVariants = Array.isArray(
+ recipe.previousRequiredSourcePackBeatIdVariants,
+ )
? recipe.previousRequiredSourcePackBeatIdVariants
: [recipe.previousRequiredSourcePackBeatIds];
- if (expanding && !previousSourcePackVariants.some((beatIds) =>
- JSON.stringify(existingPackBeatIds) === JSON.stringify(beatIds))) {
- return { appended: false, updated: false, reason: 'source-pack-mismatch' };
+ if (
+ expanding &&
+ !previousSourcePackVariants.some(
+ (beatIds) =>
+ JSON.stringify(existingPackBeatIds) === JSON.stringify(beatIds),
+ )
+ ) {
+ return {
+ appended: false,
+ updated: false,
+ reason: 'source-pack-mismatch',
+ };
}
- const adoptableTitles = new Set(Array.isArray(recipe.adoptExistingShotTitles)
- ? recipe.adoptExistingShotTitles
- : []);
+ const adoptableTitles = new Set(
+ Array.isArray(recipe.adoptExistingShotTitles)
+ ? recipe.adoptExistingShotTitles
+ : [],
+ );
const adoptedShotIds = new Map();
if (expanding && adoptableTitles.size) {
for (const packShot of pack.shots) {
if (!adoptableTitles.has(packShot.title)) continue;
- const packBeatId = packShot.layers?.[recipe.requiredSourcePackLayerId]?.params?.beatId;
- if (existingPackShots.some((shot) =>
- shot.layers?.[recipe.requiredSourcePackLayerId]?.params?.beatId === packBeatId)) continue;
- const candidates = scene.shots.filter((shot) =>
- !shot.sourcePackId && shot.title === packShot.title);
- if (candidates.length === 1) adoptedShotIds.set(packShot.id, candidates[0].id);
+ const packBeatId =
+ packShot.layers?.[recipe.requiredSourcePackLayerId]?.params?.beatId;
+ if (
+ existingPackShots.some(
+ (shot) =>
+ shot.layers?.[recipe.requiredSourcePackLayerId]?.params
+ ?.beatId === packBeatId,
+ )
+ )
+ continue;
+ const candidates = scene.shots.filter(
+ (shot) => !shot.sourcePackId && shot.title === packShot.title,
+ );
+ if (candidates.length === 1)
+ adoptedShotIds.set(packShot.id, candidates[0].id);
}
}
const appendedShots = marker
- ? (expanding ? pack.shots.filter((shot) => !existingPackShots.some((existing) =>
- existing.layers?.[recipe.requiredSourcePackLayerId]?.params?.beatId
- === shot.layers?.[recipe.requiredSourcePackLayerId]?.params?.beatId)
- && !adoptedShotIds.has(shot.id)) : [])
+ ? expanding
+ ? pack.shots.filter(
+ (shot) =>
+ !existingPackShots.some(
+ (existing) =>
+ existing.layers?.[recipe.requiredSourcePackLayerId]?.params
+ ?.beatId ===
+ shot.layers?.[recipe.requiredSourcePackLayerId]?.params
+ ?.beatId,
+ ) && !adoptedShotIds.has(shot.id),
+ )
+ : []
: pack.shots;
const nextShots = deepClone([...scene.shots, ...appendedShots]);
for (const [packShotId, existingShotId] of adoptedShotIds) {
@@ -873,13 +982,21 @@ export class SceneDirector {
if (expanding) {
// Insert additions around existing beats, never replace their IDs/cameras.
for (let i = pack.shots.length - 1; i >= 0; i--) {
- const added = appendedShots.find((shot) => shot.id === pack.shots[i].id);
+ const added = appendedShots.find(
+ (shot) => shot.id === pack.shots[i].id,
+ );
if (!added) continue;
const at = nextShots.findIndex((shot) => shot.id === added.id);
const [shot] = nextShots.splice(at, 1);
- const nextBeatId = pack.shots[i + 1]?.layers?.[recipe.requiredSourcePackLayerId]?.params?.beatId;
- const before = nextShots.findIndex((item) => item.sourcePackId === recipe.id
- && item.layers?.[recipe.requiredSourcePackLayerId]?.params?.beatId === nextBeatId);
+ const nextBeatId =
+ pack.shots[i + 1]?.layers?.[recipe.requiredSourcePackLayerId]?.params
+ ?.beatId;
+ const before = nextShots.findIndex(
+ (item) =>
+ item.sourcePackId === recipe.id &&
+ item.layers?.[recipe.requiredSourcePackLayerId]?.params?.beatId ===
+ nextBeatId,
+ );
nextShots.splice(before < 0 ? nextShots.length : before, 0, shot);
}
}
@@ -888,51 +1005,80 @@ export class SceneDirector {
: [];
const addedBindings = Object.fromEntries([
...appendedShots.map((shot) => [shot.title, shot.id]),
- ...[...adoptedShotIds].map(([packShotId, existingShotId]) => [
- pack.shots.find((shot) => shot.id === packShotId)?.title,
- existingShotId,
- ]).filter(([title]) => typeof title === 'string'),
+ ...[...adoptedShotIds]
+ .map(([packShotId, existingShotId]) => [
+ pack.shots.find((shot) => shot.id === packShotId)?.title,
+ existingShotId,
+ ])
+ .filter(([title]) => typeof title === 'string'),
]);
const markerBindings = marker?.shotBindings
? { ...marker.shotBindings, ...addedBindings }
: null;
if (requiredShotTitles.length) {
const resolvedShots = markerBindings
- ? requiredShotTitles.map((title) => (
- nextShots.find((shot) => shot.id === markerBindings[title]) || null
- ))
+ ? requiredShotTitles.map(
+ (title) =>
+ nextShots.find((shot) => shot.id === markerBindings[title]) ||
+ null,
+ )
: nextShots;
- const inventoryMatches = resolvedShots.length === requiredShotTitles.length
- && (markerBindings
- ? resolvedShots.every(Boolean)
- && new Set(resolvedShots.map((shot) => shot.id)).size === requiredShotTitles.length
- : resolvedShots.every((shot, index) => shot.title === requiredShotTitles[index]));
+ const inventoryMatches =
+ resolvedShots.length === requiredShotTitles.length &&
+ (markerBindings
+ ? resolvedShots.every(Boolean) &&
+ new Set(resolvedShots.map((shot) => shot.id)).size ===
+ requiredShotTitles.length
+ : resolvedShots.every(
+ (shot, index) => shot.title === requiredShotTitles[index],
+ ));
if (!inventoryMatches) {
- this._updateStatus(`Cannot update ${recipe.title}: shot inventory changed`);
- return { appended: false, updated: false, reason: 'shot-inventory-mismatch' };
+ this._updateStatus(
+ `Cannot update ${recipe.title}: shot inventory changed`,
+ );
+ return {
+ appended: false,
+ updated: false,
+ reason: 'shot-inventory-mismatch',
+ };
}
}
- const requiredSourcePackBeatIds = Array.isArray(recipe.requiredSourcePackBeatIds)
+ const requiredSourcePackBeatIds = Array.isArray(
+ recipe.requiredSourcePackBeatIds,
+ )
? recipe.requiredSourcePackBeatIds
: [];
- const requiredSourcePackLayerId = typeof recipe.requiredSourcePackLayerId === 'string'
- ? recipe.requiredSourcePackLayerId
- : null;
+ const requiredSourcePackLayerId =
+ typeof recipe.requiredSourcePackLayerId === 'string'
+ ? recipe.requiredSourcePackLayerId
+ : null;
if (requiredSourcePackBeatIds.length && requiredSourcePackLayerId) {
const sourcePackBeatIds = nextShots
.filter((shot) => shot.sourcePackId === recipe.id)
- .map((shot) => shot.layers?.[requiredSourcePackLayerId]?.params?.beatId);
- const sourcePackMatches = sourcePackBeatIds.length === requiredSourcePackBeatIds.length
- && sourcePackBeatIds.every((beatId, index) => beatId === requiredSourcePackBeatIds[index]);
+ .map(
+ (shot) => shot.layers?.[requiredSourcePackLayerId]?.params?.beatId,
+ );
+ const sourcePackMatches =
+ sourcePackBeatIds.length === requiredSourcePackBeatIds.length &&
+ sourcePackBeatIds.every(
+ (beatId, index) => beatId === requiredSourcePackBeatIds[index],
+ );
if (!sourcePackMatches) {
- this._updateStatus(`Cannot update ${recipe.title}: evidence beats changed`);
- return { appended: false, updated: false, reason: 'source-pack-mismatch' };
+ this._updateStatus(
+ `Cannot update ${recipe.title}: evidence beats changed`,
+ );
+ return {
+ appended: false,
+ updated: false,
+ reason: 'source-pack-mismatch',
+ };
}
}
const resolvePatchedShot = (patch) => {
const boundShotId = markerBindings?.[patch.title];
- if (boundShotId) return nextShots.find((shot) => shot.id === boundShotId) || null;
+ if (boundShotId)
+ return nextShots.find((shot) => shot.id === boundShotId) || null;
const matches = nextShots.filter((shot) => shot.title === patch.title);
return matches.length === 1 ? matches[0] : null;
};
@@ -941,8 +1087,14 @@ export class SceneDirector {
shot: resolvePatchedShot(patch),
}));
if (resolvedPatches.some(({ shot }) => !shot)) {
- this._updateStatus(`Cannot update ${recipe.title}: shot bindings are incomplete`);
- return { appended: false, updated: false, reason: 'shot-bindings-incomplete' };
+ this._updateStatus(
+ `Cannot update ${recipe.title}: shot bindings are incomplete`,
+ );
+ return {
+ appended: false,
+ updated: false,
+ reason: 'shot-bindings-incomplete',
+ };
}
let patchedShotCount = 0;
@@ -965,21 +1117,28 @@ export class SceneDirector {
}
patchedShotCount++;
}
- const nextReleaseLayerIds = [...new Set([
- ...(scene.releaseLayerIds || []),
- ...(pack.releaseLayerIds || []),
- ])];
+ const nextReleaseLayerIds = [
+ ...new Set([
+ ...(scene.releaseLayerIds || []),
+ ...(pack.releaseLayerIds || []),
+ ]),
+ ];
const shotBindings = Object.fromEntries(
- requiredShotTitles.map((title) => [
- title,
- nextShots.find((shot) => shot.id === markerBindings?.[title])?.id
- ?? nextShots.find((shot) => shot.title === title)?.id,
- ]).filter(([, shotId]) => typeof shotId === 'string'),
+ requiredShotTitles
+ .map((title) => [
+ title,
+ nextShots.find((shot) => shot.id === markerBindings?.[title])?.id ??
+ nextShots.find((shot) => shot.title === title)?.id,
+ ])
+ .filter(([, shotId]) => typeof shotId === 'string'),
);
if (writeCheckpoint) {
try {
- localStorage.setItem(STORAGE_CHECKPOINT_KEY, JSON.stringify(this._project));
+ localStorage.setItem(
+ STORAGE_CHECKPOINT_KEY,
+ JSON.stringify(this._project),
+ );
} catch {
this._toastStorageError();
return { appended: false, reason: 'checkpoint-failed' };
@@ -1007,9 +1166,11 @@ export class SceneDirector {
this._renderShotList();
}
if (announce) {
- this._updateStatus(marker
- ? `Updated ${patchedShotCount} ${patchedShotCount === 1 ? 'shot' : 'shots'}: ${recipe.title}`
- : `Appended ${appendedShots.length} shots: ${recipe.title}`);
+ this._updateStatus(
+ marker
+ ? `Updated ${patchedShotCount} ${patchedShotCount === 1 ? 'shot' : 'shots'}: ${recipe.title}`
+ : `Appended ${appendedShots.length} shots: ${recipe.title}`,
+ );
}
return {
appended: appendedShots.length > 0,
@@ -1114,20 +1275,26 @@ export class SceneDirector {
* @param {Object|null} [options.sceneSeek=null] - Deterministic authored-clock snapshot
*/
loadShot(sceneId, shotId, options) {
- if (this._destroyed) return Promise.resolve({ started: false, reason: 'destroyed' });
+ if (this._destroyed)
+ return Promise.resolve({ started: false, reason: 'destroyed' });
this._sceneSeekGeneration++;
return this._trackWork(this._loadShot(sceneId, shotId, options));
}
- async _loadShot(sceneId, shotId, { flyDuration = 2.2, fromCamera = null, sceneSeek = null } = {}) {
+ async _loadShot(
+ sceneId,
+ shotId,
+ { flyDuration = 2.2, fromCamera = null, sceneSeek = null } = {},
+ ) {
if (this._running) return { started: false, reason: 'already-running' };
const { scene, shot } = this._getShot(sceneId, shotId);
if (!scene || !shot) return { started: false, reason: 'shot-not-found' };
- const previousScene = this._project.scenes.find((item) => (
- item.id === this._loadedSceneId
- )) || null;
+ const previousScene =
+ this._project.scenes.find((item) => item.id === this._loadedSceneId) ||
+ null;
- if (!this._claimCameraOwnership()) return { started: false, reason: 'camera-unavailable' };
+ if (!this._claimCameraOwnership())
+ return { started: false, reason: 'camera-unavailable' };
if (fromCamera) this._setCameraView(fromCamera);
// Supersede the previous LOAD before reserving this one: aborting first
@@ -1145,7 +1312,8 @@ export class SceneDirector {
if (previousScene && previousScene.id !== scene.id) {
const released = await this._releaseSceneLayers(previousScene, token);
if (!released || token.cancelled) {
- if (!token.cancelled) this._updateStatus(`Could not leave scene: ${previousScene.title}`);
+ if (!token.cancelled)
+ this._updateStatus(`Could not leave scene: ${previousScene.title}`);
return;
}
this._loadedSceneId = null;
@@ -1156,16 +1324,19 @@ export class SceneDirector {
this._renderSceneSelect();
this._renderShotList();
- await this.styleManager.applyVisualState(
- this._visualStateForShot(shot),
- { isCurrent: () => !token.cancelled },
- );
+ await this.styleManager.applyVisualState(this._visualStateForShot(shot), {
+ isCurrent: () => !token.cancelled,
+ });
if (token.cancelled) return;
- const seekState = sceneSeek && typeof sceneSeek === 'object' ? sceneSeek : null;
- const layerResult = await this._applyLayerStates(this._layerStatesForShot(scene, shot, {
- cameraSettled: seekState ? seekState.cameraProgress >= 1 : false,
- sceneSeek: seekState,
- }), token);
+ const seekState =
+ sceneSeek && typeof sceneSeek === 'object' ? sceneSeek : null;
+ const layerResult = await this._applyLayerStates(
+ this._layerStatesForShot(scene, shot, {
+ cameraSettled: seekState ? seekState.cameraProgress >= 1 : false,
+ sceneSeek: seekState,
+ }),
+ token,
+ );
if (token.cancelled) return;
// Layer state is already user-visible before the camera flight settles.
// Record its owner now so a newer cross-scene LOAD can release it even if
@@ -1189,13 +1360,18 @@ export class SceneDirector {
const holdSec = this._effectiveShotHoldSec(scene, shot);
const flightShare = flyDuration / Math.max(0.001, flyDuration + holdSec);
const sceneTiming = this._sceneTimingForShot(scene, shot);
- this._startShotProgress(token, flyDuration, sceneTiming.startProgress,
- sceneTiming.startProgress + sceneTiming.durationProgress * flightShare, {
+ this._startShotProgress(
+ token,
+ flyDuration,
+ sceneTiming.startProgress,
+ sceneTiming.startProgress + sceneTiming.durationProgress * flightShare,
+ {
scene,
shot,
sceneElapsedFrom: sceneTiming.startElapsedSec,
sceneElapsedTo: sceneTiming.startElapsedSec + flyDuration,
- });
+ },
+ );
const cameraTravel = this._beginShotTravel(scene, shot, flyDuration);
try {
// _flyCamera calls Cesium's flyTo synchronously before yielding. Publish
@@ -1215,14 +1391,18 @@ export class SceneDirector {
if (token.cancelled) return;
this._settleShotLayerStates(scene, shot, token, cameraTravel);
if (token.cancelled) return;
- this._startShotProgress(token, holdSec,
+ this._startShotProgress(
+ token,
+ holdSec,
sceneTiming.startProgress + sceneTiming.durationProgress * flightShare,
- sceneTiming.endProgress, {
+ sceneTiming.endProgress,
+ {
scene,
shot,
sceneElapsedFrom: sceneTiming.startElapsedSec + flyDuration,
sceneElapsedTo: sceneTiming.endElapsedSec,
- });
+ },
+ );
if (this._loadAbort === controller) this._loadAbort = null;
this._shotOutcome('shot-loaded', scene, shot);
@@ -1235,22 +1415,30 @@ export class SceneDirector {
* This keeps an appended event lens from leaking backward into the scene's
* original authored shots while leaving ordinary sparse layer state intact.
*/
- _layerStatesForShot(scene, shot, {
- cameraSettled = false,
- cameraTravel = null,
- sceneSeek = null,
- } = {}) {
+ _layerStatesForShot(
+ scene,
+ shot,
+ { cameraSettled = false, cameraTravel = null, sceneSeek = null } = {},
+ ) {
const states = deepClone(shot?.layers || {});
- const shotIndex = scene?.shots?.findIndex(({ id }) => id === shot?.id) ?? -1;
- const sceneShotDurations = scene?.shots?.map(
- (sceneShot) => this._shotRuntimeDurationSec(scene, sceneShot),
- ) || [];
- const sceneDurationSec = sceneShotDurations.reduce((sum, duration) => sum + duration, 0);
+ const shotIndex =
+ scene?.shots?.findIndex(({ id }) => id === shot?.id) ?? -1;
+ const sceneShotDurations =
+ scene?.shots?.map((sceneShot) =>
+ this._shotRuntimeDurationSec(scene, sceneShot),
+ ) || [];
+ const sceneDurationSec = sceneShotDurations.reduce(
+ (sum, duration) => sum + duration,
+ 0,
+ );
const authoredSceneElapsedSec = sceneShotDurations
.slice(0, Math.max(0, shotIndex))
.reduce((sum, duration) => sum + duration, 0);
const sceneElapsedSec = sceneSeek
- ? Math.max(0, Math.min(sceneDurationSec, Number(sceneSeek.sceneElapsedSec) || 0))
+ ? Math.max(
+ 0,
+ Math.min(sceneDurationSec, Number(sceneSeek.sceneElapsedSec) || 0),
+ )
: authoredSceneElapsedSec;
const sourceRecipe = getSceneAppendRecipeById(shot?.sourcePackId);
for (const state of Object.values(states)) {
@@ -1258,22 +1446,29 @@ export class SceneDirector {
state.params.sceneSurface ||= shot.sourcePackId
? 'evidence-beat'
: 'panel-only';
- const runtimeControls = sourceRecipe?.runtimeControlsByBeat?.[state.params.beatId];
+ const runtimeControls =
+ sourceRecipe?.runtimeControlsByBeat?.[state.params.beatId];
if (runtimeControls) {
state.params.sceneControls = {
...state.params.sceneControls,
...runtimeControls,
- ...(runtimeControls.deferEvidenceUntilCameraSettled ? { cameraSettled } : {}),
- ...(sceneSeek ? {
- timelineSeek: {
- shotElapsedSec: Number(sceneSeek.shotElapsedSec) || 0,
- flightDurationSec: Number(sceneSeek.flightDurationSec) || 0,
- holdDurationSec: Number(sceneSeek.holdDurationSec) || 0,
- holdElapsedSec: Number(sceneSeek.holdElapsedSec) || 0,
- cameraProgress: clamp01(Number(sceneSeek.cameraProgress) || 0),
- holdProgress: clamp01(Number(sceneSeek.holdProgress) || 0),
- },
- } : {}),
+ ...(runtimeControls.deferEvidenceUntilCameraSettled
+ ? { cameraSettled }
+ : {}),
+ ...(sceneSeek
+ ? {
+ timelineSeek: {
+ shotElapsedSec: Number(sceneSeek.shotElapsedSec) || 0,
+ flightDurationSec: Number(sceneSeek.flightDurationSec) || 0,
+ holdDurationSec: Number(sceneSeek.holdDurationSec) || 0,
+ holdElapsedSec: Number(sceneSeek.holdElapsedSec) || 0,
+ cameraProgress: clamp01(
+ Number(sceneSeek.cameraProgress) || 0,
+ ),
+ holdProgress: clamp01(Number(sceneSeek.holdProgress) || 0),
+ },
+ }
+ : {}),
};
if (runtimeControls.evidencePathDuringCamera && cameraTravel) {
state.params.sceneControls.cameraTravel = { ...cameraTravel };
@@ -1290,12 +1485,15 @@ export class SceneDirector {
holdSec: this._effectiveShotHoldSec(scene, shot, states),
sceneElapsedSec,
sceneDurationSec,
- sceneProgress: sceneDurationSec > 0 ? sceneElapsedSec / sceneDurationSec : 0,
- ...(sceneSeek ? {
- shotElapsedSec: Number(sceneSeek.shotElapsedSec) || 0,
- shotProgress: clamp01(Number(sceneSeek.shotProgress) || 0),
- seeking: true,
- } : {}),
+ sceneProgress:
+ sceneDurationSec > 0 ? sceneElapsedSec / sceneDurationSec : 0,
+ ...(sceneSeek
+ ? {
+ shotElapsedSec: Number(sceneSeek.shotElapsedSec) || 0,
+ shotProgress: clamp01(Number(sceneSeek.shotProgress) || 0),
+ seeking: true,
+ }
+ : {}),
};
}
for (const layerId of scene?.releaseLayerIds || []) {
@@ -1307,8 +1505,9 @@ export class SceneDirector {
/** Keep installed append-pack shots on any surface declared by their source recipe. */
_visualStateForShot(shot) {
const sourceRecipe = getSceneAppendRecipeById(shot?.sourcePackId);
- const sceneBeatState = Object.values(shot?.layers || {})
- .find((state) => state?.params?.presentation === 'scene-beat');
+ const sceneBeatState = Object.values(shot?.layers || {}).find(
+ (state) => state?.params?.presentation === 'scene-beat',
+ );
const controls = {
...sceneBeatState?.params?.sceneControls,
...sourceRecipe?.runtimeControlsByBeat?.[sceneBeatState?.params?.beatId],
@@ -1316,19 +1515,29 @@ export class SceneDirector {
if (controls.imageryComparison === true) {
return { ...(shot.visual || {}), mapStack: 'esri-imagery' };
}
- if (sourceRecipe?.standaloneSurfaceBeatIds?.includes(sceneBeatState?.params?.beatId)) {
+ if (
+ sourceRecipe?.standaloneSurfaceBeatIds?.includes(
+ sceneBeatState?.params?.beatId,
+ )
+ ) {
return { ...(shot.visual || {}), mapStack: 'esri-imagery' };
}
- const visual = sourceRecipe?.photorealSurfaceBeatIds?.includes(sceneBeatState?.params?.beatId)
+ const visual = sourceRecipe?.photorealSurfaceBeatIds?.includes(
+ sceneBeatState?.params?.beatId,
+ )
? { ...(shot.visual || {}), mapStack: 'photoreal' }
- : (shot?.visual || {});
- const isNepalShot = shot?.layers?.['bhote-koshi-2026']?.enabled
- || shot?.layers?.['bhote-koshi-locator']?.enabled;
+ : shot?.visual || {};
+ const isNepalShot =
+ shot?.layers?.['bhote-koshi-2026']?.enabled ||
+ shot?.layers?.['bhote-koshi-locator']?.enabled;
// Check actual loaded capability, not the presence of a credential. A key
// can exist even when its tileset failed to initialize. Resolve at playback
// time so saved cameras and provider preferences remain portable.
- if (isNepalShot && visual.mapStack === 'photoreal'
- && !this._isMapStackAvailable('photoreal')) {
+ if (
+ isNepalShot &&
+ visual.mapStack === 'photoreal' &&
+ !this._isMapStackAvailable('photoreal')
+ ) {
return { ...visual, mapStack: 'esri-imagery' };
}
return visual;
@@ -1339,20 +1548,34 @@ export class SceneDirector {
const states = resolvedStates || this._layerStatesForShot(scene, shot);
const recipe = getSceneAppendRecipeById(shot?.sourcePackId);
const overrides = Object.values(states)
- .filter(state => state?.enabled && state.params?.presentation === 'scene-beat')
- .map(state => recipe?.runtimeHoldSecByBeat?.[state.params.beatId])
- .filter(value => Number.isFinite(value) && value >= 0);
+ .filter(
+ (state) =>
+ state?.enabled && state.params?.presentation === 'scene-beat',
+ )
+ .map((state) => recipe?.runtimeHoldSecByBeat?.[state.params.beatId])
+ .filter((value) => Number.isFinite(value) && value >= 0);
const authoredHoldSec = overrides.length
- ? Math.max(...overrides) : Number(shot?.holdSec) || 0;
+ ? Math.max(...overrides)
+ : Number(shot?.holdSec) || 0;
const overview = states['bhote-koshi-locator'];
- const overviewHoldSec = overview?.enabled
- && overview.params?.presentation === 'bhote-koshi-incident-places'
- ? INCIDENT_OVERVIEW_HOLD_SEC : 0;
- return Object.values(states).reduce((holdSec, state) => {
- const controls = state?.params?.sceneControls;
- return Math.max(holdSec, Number(controls?.minimumHoldSec) || 0,
- controls?.evidenceSequence === true ? Number(controls.evidenceSequenceDurationSec) || 0 : 0);
- }, Math.max(overviewHoldSec, authoredHoldSec));
+ const overviewHoldSec =
+ overview?.enabled &&
+ overview.params?.presentation === 'bhote-koshi-incident-places'
+ ? INCIDENT_OVERVIEW_HOLD_SEC
+ : 0;
+ return Object.values(states).reduce(
+ (holdSec, state) => {
+ const controls = state?.params?.sceneControls;
+ return Math.max(
+ holdSec,
+ Number(controls?.minimumHoldSec) || 0,
+ controls?.evidenceSequence === true
+ ? Number(controls.evidenceSequenceDurationSec) || 0
+ : 0,
+ );
+ },
+ Math.max(overviewHoldSec, authoredHoldSec),
+ );
}
/** Resolve one shot's authored flight plus any runtime-enforced hold. */
@@ -1361,35 +1584,49 @@ export class SceneDirector {
const sourceRecipe = getSceneAppendRecipeById(shot?.sourcePackId);
for (const state of Object.values(states)) {
if (state?.params?.presentation !== 'scene-beat') continue;
- const runtimeControls = sourceRecipe?.runtimeControlsByBeat?.[state.params.beatId];
+ const runtimeControls =
+ sourceRecipe?.runtimeControlsByBeat?.[state.params.beatId];
if (runtimeControls) {
- state.params.sceneControls = { ...state.params.sceneControls, ...runtimeControls };
+ state.params.sceneControls = {
+ ...state.params.sceneControls,
+ ...runtimeControls,
+ };
}
}
- return (shot?.durationSec || DEFAULT_SHOT_DURATION_SEC)
- + this._effectiveShotHoldSec(scene, shot, states);
+ return (
+ (shot?.durationSec || DEFAULT_SHOT_DURATION_SEC) +
+ this._effectiveShotHoldSec(scene, shot, states)
+ );
}
/** Signal camera-dependent layers only after the authored flight completes. */
_settleShotLayerStates(scene, shot, token = null, cameraTravel = null) {
if (token?.cancelled) return false;
- const settledTravel = cameraTravel ? {
- ...cameraTravel,
- active: false,
- completed: true,
- cancelled: false,
- } : null;
+ const settledTravel = cameraTravel
+ ? {
+ ...cameraTravel,
+ active: false,
+ completed: true,
+ cancelled: false,
+ }
+ : null;
const states = this._layerStatesForShot(scene, shot, {
cameraSettled: true,
cameraTravel: settledTravel,
});
for (const [layerId, state] of Object.entries(states)) {
- if (!state?.enabled || state.params?.sceneControls?.deferEvidenceUntilCameraSettled !== true) {
+ if (
+ !state?.enabled ||
+ state.params?.sceneControls?.deferEvidenceUntilCameraSettled !== true
+ ) {
continue;
}
- this.dataManager.setLayerParams(layerId, state.params, { origin: 'scene' });
+ this.dataManager.setLayerParams(layerId, state.params, {
+ origin: 'scene',
+ });
}
- if (this._activeSceneTravel?.id === cameraTravel?.id) this._activeSceneTravel = null;
+ if (this._activeSceneTravel?.id === cameraTravel?.id)
+ this._activeSceneTravel = null;
return !token?.cancelled;
}
@@ -1397,7 +1634,10 @@ export class SceneDirector {
_beginShotTravel(scene, shot, durationSec) {
const travel = {
id: ++this._sceneTravelGeneration,
- durationSec: Math.max(0.2, Number(durationSec) || DEFAULT_SHOT_DURATION_SEC),
+ durationSec: Math.max(
+ 0.2,
+ Number(durationSec) || DEFAULT_SHOT_DURATION_SEC,
+ ),
active: true,
completed: false,
cancelled: false,
@@ -1410,8 +1650,14 @@ export class SceneDirector {
_publishShotTravel(scene, shot, cameraTravel) {
const states = this._layerStatesForShot(scene, shot, { cameraTravel });
for (const [layerId, state] of Object.entries(states)) {
- if (!state?.enabled || state.params?.sceneControls?.evidencePathDuringCamera !== true) continue;
- this.dataManager.setLayerParams(layerId, state.params, { origin: 'scene' });
+ if (
+ !state?.enabled ||
+ state.params?.sceneControls?.evidencePathDuringCamera !== true
+ )
+ continue;
+ this.dataManager.setLayerParams(layerId, state.params, {
+ origin: 'scene',
+ });
}
}
@@ -1420,7 +1666,9 @@ export class SceneDirector {
// The opening locator owns an additional delayed approach/orbit even after
// the director's authored flight has settled. Revoke it before cancelling
// the camera, whose moveEnd/complete callbacks may already be queued.
- this.dataManager?.layers?.get('bhote-koshi-locator')?.module.cancelSceneMotion?.();
+ this.dataManager?.layers
+ ?.get('bhote-koshi-locator')
+ ?.module.cancelSceneMotion?.();
const active = this._activeSceneTravel;
if (!active) return false;
this._activeSceneTravel = null;
@@ -1436,21 +1684,40 @@ export class SceneDirector {
cameraTravel: cancelledTravel,
});
for (const [layerId, state] of Object.entries(states)) {
- if (!state?.enabled || state.params?.sceneControls?.evidencePathDuringCamera !== true) continue;
+ if (
+ !state?.enabled ||
+ state.params?.sceneControls?.evidencePathDuringCamera !== true
+ )
+ continue;
let cancelled = false;
try {
- cancelled = this.dataManager.setLayerParams(layerId, state.params, { origin: 'scene' }) !== false;
+ cancelled =
+ this.dataManager.setLayerParams(layerId, state.params, {
+ origin: 'scene',
+ }) !== false;
} catch (error) {
- console.warn(`[Scenes] Could not cancel camera-led state for ${layerId}:`, error);
+ console.warn(
+ `[Scenes] Could not cancel camera-led state for ${layerId}:`,
+ error,
+ );
}
if (cancelled) continue;
// Fail closed: an exceptional manager/module must not leave its previous
// RAF or deadline clock moving after STOP or a superseding LOAD.
try {
- Promise.resolve(this.dataManager.setEnabled(layerId, false, { origin: 'scene' }))
- .catch((error) => console.warn(`[Scenes] Could not disable stale layer ${layerId}:`, error));
+ Promise.resolve(
+ this.dataManager.setEnabled(layerId, false, { origin: 'scene' }),
+ ).catch((error) =>
+ console.warn(
+ `[Scenes] Could not disable stale layer ${layerId}:`,
+ error,
+ ),
+ );
} catch (error) {
- console.warn(`[Scenes] Could not disable stale layer ${layerId}:`, error);
+ console.warn(
+ `[Scenes] Could not disable stale layer ${layerId}:`,
+ error,
+ );
}
}
return true;
@@ -1463,7 +1730,8 @@ export class SceneDirector {
const { scene, shot } = this._getShot(sceneId, shotId);
if (!scene || !shot) return { started: false, reason: 'shot-not-found' };
const shotIndex = scene.shots.findIndex(({ id }) => id === shot.id);
- const previousShot = scene.shots[(shotIndex - 1 + scene.shots.length) % scene.shots.length];
+ const previousShot =
+ scene.shots[(shotIndex - 1 + scene.shots.length) % scene.shots.length];
const result = await this.loadShot(sceneId, shotId, {
flyDuration: shot.durationSec || DEFAULT_SHOT_DURATION_SEC,
fromCamera: previousShot?.camera || null,
@@ -1475,7 +1743,11 @@ export class SceneDirector {
async continueScene(sceneId, shotId) {
const { scene, shot } = this._getShot(sceneId, shotId);
if (!scene || !shot) return { started: false, reason: 'shot-not-found' };
- return this.startScene(sceneId, { single: true, afterShotId: shotId, preview: false });
+ return this.startScene(sceneId, {
+ single: true,
+ afterShotId: shotId,
+ preview: false,
+ });
}
/** Load the adjacent shot inside one scene without wrapping at either end. */
@@ -1493,10 +1765,14 @@ export class SceneDirector {
/** Resolve absolute authored-clock timing for one shot. */
_sceneTimingForShot(scene, shot) {
- const durations = scene?.shots?.map((item) => this._shotRuntimeDurationSec(scene, item)) || [];
- const shotIndex = scene?.shots?.findIndex(({ id }) => id === shot?.id) ?? -1;
+ const durations =
+ scene?.shots?.map((item) => this._shotRuntimeDurationSec(scene, item)) ||
+ [];
+ const shotIndex =
+ scene?.shots?.findIndex(({ id }) => id === shot?.id) ?? -1;
const totalSec = durations.reduce((sum, duration) => sum + duration, 0);
- const startElapsedSec = durations.slice(0, Math.max(0, shotIndex))
+ const startElapsedSec = durations
+ .slice(0, Math.max(0, shotIndex))
.reduce((sum, duration) => sum + duration, 0);
const durationSec = shotIndex >= 0 ? durations[shotIndex] : 0;
const endElapsedSec = Math.min(totalSec, startElapsedSec + durationSec);
@@ -1518,8 +1794,9 @@ export class SceneDirector {
const source = fromCamera || target;
if (!source || !target) return target || source || null;
const t = clamp01(Number(progress) || 0);
- const eased = t < 0.5 ? 4 * t ** 3 : 1 - ((-2 * t + 2) ** 3) / 2;
- const lerp = (from, to) => Number(from) + (Number(to) - Number(from)) * eased;
+ const eased = t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2;
+ const lerp = (from, to) =>
+ Number(from) + (Number(to) - Number(from)) * eased;
const lerpAngle = (from, to) => {
const start = Number(from) || 0;
const delta = ((Number(to) - start + 540) % 360) - 180;
@@ -1539,7 +1816,9 @@ export class SceneDirector {
_sceneSeekState(scene, progress) {
if (!scene?.shots?.length) return null;
const normalized = clamp01(Number(progress) || 0);
- const durations = scene.shots.map((shot) => this._shotRuntimeDurationSec(scene, shot));
+ const durations = scene.shots.map((shot) =>
+ this._shotRuntimeDurationSec(scene, shot),
+ );
const totalSec = durations.reduce((sum, duration) => sum + duration, 0);
const targetSec = normalized * Math.max(0, totalSec);
let startElapsedSec = 0;
@@ -1555,11 +1834,20 @@ export class SceneDirector {
const shot = scene.shots[shotIndex];
const flightDurationSec = shot.durationSec || DEFAULT_SHOT_DURATION_SEC;
const holdDurationSec = this._effectiveShotHoldSec(scene, shot);
- const shotDurationSec = Math.max(0.001, flightDurationSec + holdDurationSec);
- const shotElapsedSec = Math.max(0, Math.min(shotDurationSec, targetSec - startElapsedSec));
- const cameraProgress = clamp01(shotElapsedSec / Math.max(0.001, flightDurationSec));
+ const shotDurationSec = Math.max(
+ 0.001,
+ flightDurationSec + holdDurationSec,
+ );
+ const shotElapsedSec = Math.max(
+ 0,
+ Math.min(shotDurationSec, targetSec - startElapsedSec),
+ );
+ const cameraProgress = clamp01(
+ shotElapsedSec / Math.max(0.001, flightDurationSec),
+ );
const holdElapsedSec = Math.max(0, shotElapsedSec - flightDurationSec);
- const holdProgress = holdDurationSec > 0 ? clamp01(holdElapsedSec / holdDurationSec) : 1;
+ const holdProgress =
+ holdDurationSec > 0 ? clamp01(holdElapsedSec / holdDurationSec) : 1;
const previousCamera = scene.shots[shotIndex - 1]?.camera || shot.camera;
return {
sceneProgress: totalSec > 0 ? targetSec / totalSec : 0,
@@ -1574,7 +1862,11 @@ export class SceneDirector {
holdElapsedSec,
cameraProgress,
holdProgress,
- camera: this._cameraAtProgress(previousCamera, shot.camera, cameraProgress),
+ camera: this._cameraAtProgress(
+ previousCamera,
+ shot.camera,
+ cameraProgress,
+ ),
};
}
@@ -1587,10 +1879,18 @@ export class SceneDirector {
}
/** Publish one authoritative scene-clock snapshot to attached panels. */
- _publishSceneClock(scene, shot, sceneElapsedSec, { running = this._running, seeking = false } = {}) {
+ _publishSceneClock(
+ scene,
+ shot,
+ sceneElapsedSec,
+ { running = this._running, seeking = false } = {},
+ ) {
if (!scene || !shot) return;
const timing = this._sceneTimingForShot(scene, shot);
- const elapsedSec = Math.max(0, Math.min(timing.totalSec, Number(sceneElapsedSec) || 0));
+ const elapsedSec = Math.max(
+ 0,
+ Math.min(timing.totalSec, Number(sceneElapsedSec) || 0),
+ );
this._sceneClockSnapshot = {
sceneId: scene.id,
shotId: shot.id,
@@ -1603,7 +1903,9 @@ export class SceneDirector {
seeking: Boolean(seeking),
};
for (const listener of this._sceneClockListeners) {
- try { listener({ ...this._sceneClockSnapshot }); } catch (error) {
+ try {
+ listener({ ...this._sceneClockSnapshot });
+ } catch (error) {
console.warn('[Scenes] Scene clock listener failed:', error);
}
}
@@ -1619,8 +1921,13 @@ export class SceneDirector {
_seekLoadedShot(scene, seekState) {
if (this._destroyed) return false;
const shot = seekState?.shot;
- if (!scene || !shot || this._loadedSceneId !== scene.id
- || this._selectedShotId !== shot.id) return false;
+ if (
+ !scene ||
+ !shot ||
+ this._loadedSceneId !== scene.id ||
+ this._selectedShotId !== shot.id
+ )
+ return false;
if (!this._claimCameraOwnership()) return false;
this._cancelActiveSceneTravel();
this._loadAbort?.abort();
@@ -1635,7 +1942,11 @@ export class SceneDirector {
let applied = true;
for (const [layerId, state] of Object.entries(states)) {
if (!state?.enabled || !state.params) continue;
- if (this.dataManager.setLayerParams(layerId, state.params, { origin: 'scene' }) === false) {
+ if (
+ this.dataManager.setLayerParams(layerId, state.params, {
+ origin: 'scene',
+ }) === false
+ ) {
applied = false;
}
}
@@ -1664,12 +1975,19 @@ export class SceneDirector {
if (this._running) {
await this._waitForRunIdle();
}
- if (this._destroyed || generation !== this._sceneSeekGeneration) return false;
+ if (this._destroyed || generation !== this._sceneSeekGeneration)
+ return false;
const seekState = this._sceneSeekState(scene, progress);
if (!seekState) return false;
if (this._seekLoadedShot(scene, seekState)) return true;
- const result = await this._loadShot(scene.id, seekState.shot.id, { sceneSeek: seekState });
- return !this._destroyed && generation === this._sceneSeekGeneration && result?.started === true;
+ const result = await this._loadShot(scene.id, seekState.shot.id, {
+ sceneSeek: seekState,
+ });
+ return (
+ !this._destroyed &&
+ generation === this._sceneSeekGeneration &&
+ result?.started === true
+ );
}
/**
@@ -1686,7 +2004,11 @@ export class SceneDirector {
return {
signal,
get cancelled() {
- return director._destroyed || signal?.aborted || director._loadGeneration !== generation;
+ return (
+ director._destroyed ||
+ signal?.aborted ||
+ director._loadGeneration !== generation
+ );
},
};
}
@@ -1716,7 +2038,8 @@ export class SceneDirector {
/** Place the camera at an authored replay start pose without an intermediate flight. */
_setCameraView(cameraState) {
- if (!cameraState || typeof this.viewer?.camera?.setView !== 'function') return false;
+ if (!cameraState || typeof this.viewer?.camera?.setView !== 'function')
+ return false;
this.styleManager?.clearSearchedLocation?.();
this.viewer.camera.cancelFlight?.();
this.viewer.camera.setView({
@@ -1838,17 +2161,26 @@ export class SceneDirector {
* @returns {Promise<{started: boolean, reason?: string, shots?: number}>}
*/
startScene(sceneId, options) {
- if (this._destroyed) return Promise.resolve({ started: false, reason: 'destroyed' });
+ if (this._destroyed)
+ return Promise.resolve({ started: false, reason: 'destroyed' });
this._sceneSeekGeneration++;
return this._trackWork(this._startScene(sceneId, options));
}
- async _startScene(sceneId, { single = false, afterShotId = null, preview = true } = {}) {
+ async _startScene(
+ sceneId,
+ { single = false, afterShotId = null, preview = true } = {},
+ ) {
if (this._running) return { started: false, reason: 'already-running' };
- let queue = this._buildPlaybackQueue(sceneId || this._selectedSceneId || this._project.scenes[0]?.id, { single });
+ let queue = this._buildPlaybackQueue(
+ sceneId || this._selectedSceneId || this._project.scenes[0]?.id,
+ { single },
+ );
if (afterShotId !== null) {
- const index = queue.findIndex(({ scene, shot }) => scene.id === sceneId && shot.id === afterShotId);
+ const index = queue.findIndex(
+ ({ scene, shot }) => scene.id === sceneId && shot.id === afterShotId,
+ );
if (index < 0) return { started: false, reason: 'shot-not-found' };
queue = queue.slice(index + 1);
if (!queue.length) return { started: false, reason: 'scene-complete' };
@@ -1902,8 +2234,11 @@ export class SceneDirector {
// Pre-compute total duration for the progress bar
const estimatedDurationSec = queue.reduce((sum, item) => {
- return sum + (item.shot.durationSec || 0)
- + this._effectiveShotHoldSec(item.scene, item.shot);
+ return (
+ sum +
+ (item.shot.durationSec || 0) +
+ this._effectiveShotHoldSec(item.scene, item.shot)
+ );
}, 0);
// Initialize telemetry accumulator for this run
@@ -1922,10 +2257,13 @@ export class SceneDirector {
let activeScene = null;
try {
- const loadedScene = this._project.scenes.find((scene) => scene.id === this._loadedSceneId);
+ const loadedScene = this._project.scenes.find(
+ (scene) => scene.id === this._loadedSceneId,
+ );
if (loadedScene && loadedScene.id !== queue[0].scene.id) {
const released = await this._releaseSceneLayers(loadedScene, token);
- if (!released) throw new Error(`Could not leave scene: ${loadedScene.title}`);
+ if (!released)
+ throw new Error(`Could not leave scene: ${loadedScene.title}`);
this._loadedSceneId = null;
}
@@ -1948,7 +2286,9 @@ export class SceneDirector {
this._renderSceneSelect();
this._renderShotList();
- this._updateStatus(`Running ${idx + 1}/${queue.length}: ${scene.title} / ${shot.title}`);
+ this._updateStatus(
+ `Running ${idx + 1}/${queue.length}: ${scene.title} / ${shot.title}`,
+ );
this._logEvent('shot_start', {
sceneId: scene.id,
@@ -1966,12 +2306,18 @@ export class SceneDirector {
// switch can hold this shot for seconds; without a re-check the layer
// pass below still runs and the operator watches layers keep toggling
// after they hit Stop. Cancellation is re-read after each one.
- await this.styleManager.applyVisualState(this._visualStateForShot(shot), {
- isCurrent: () => !token.cancelled,
- });
+ await this.styleManager.applyVisualState(
+ this._visualStateForShot(shot),
+ {
+ isCurrent: () => !token.cancelled,
+ },
+ );
if (token.cancelled) break;
const flightDuration = shot.durationSec || DEFAULT_SHOT_DURATION_SEC;
- await this._applyLayerStates(this._layerStatesForShot(scene, shot), token);
+ await this._applyLayerStates(
+ this._layerStatesForShot(scene, shot),
+ token,
+ );
if (token.cancelled) break;
this._loadedSceneId = scene.id;
const sceneTiming = this._startSceneClockTicker(scene, shot, token);
@@ -1987,7 +2333,9 @@ export class SceneDirector {
if (token.cancelled) break;
clearInterval(this._sceneClockTimer);
this._sceneClockTimer = null;
- this._publishSceneClock(scene, shot, sceneTiming.endElapsedSec, { running: true });
+ this._publishSceneClock(scene, shot, sceneTiming.endElapsedSec, {
+ running: true,
+ });
this._logEvent('shot_end', {
sceneId: scene.id,
@@ -2062,9 +2410,17 @@ export class SceneDirector {
this._cancelActiveSceneTravel();
this.viewer.camera.cancelFlight();
if (this._sceneClockSnapshot) {
- this._sceneClockSnapshot = { ...this._sceneClockSnapshot, running: false, stopped: true };
+ this._sceneClockSnapshot = {
+ ...this._sceneClockSnapshot,
+ running: false,
+ stopped: true,
+ };
for (const listener of this._sceneClockListeners) {
- try { listener({ ...this._sceneClockSnapshot }); } catch { /* Observer cannot block cancellation. */ }
+ try {
+ listener({ ...this._sceneClockSnapshot });
+ } catch {
+ /* Observer cannot block cancellation. */
+ }
}
}
if (!this._running || !this._runToken) return;
@@ -2173,8 +2529,13 @@ export class SceneDirector {
if (token?.cancelled) return abort();
const signal = token?.signal;
- const registered = new Set(this.dataManager.getAll().map((layer) => layer.id));
- for (const { id, enabled, params } of sceneLayerPlan(targetStates, registered)) {
+ const registered = new Set(
+ this.dataManager.getAll().map((layer) => layer.id),
+ );
+ for (const { id, enabled, params } of sceneLayerPlan(
+ targetStates,
+ registered,
+ )) {
let settled;
if (params && typeof this.dataManager.restoreLayerState === 'function') {
const outcome = await this.dataManager.restoreLayerState(
@@ -2221,7 +2582,9 @@ export class SceneDirector {
* @returns {Promise} True only when every owned layer is released
*/
async _releaseSceneLayers(scene, token = null) {
- const layerIds = Array.isArray(scene?.releaseLayerIds) ? scene.releaseLayerIds : [];
+ const layerIds = Array.isArray(scene?.releaseLayerIds)
+ ? scene.releaseLayerIds
+ : [];
let released = true;
for (const layerId of layerIds) {
if (token?.cancelled) return false;
@@ -2232,12 +2595,20 @@ export class SceneDirector {
});
if (settled === false) {
released = false;
- console.warn(`[Scenes] Scene-owned layer refused release: ${layerId}`);
- this._logEvent('scene_layer_release_refused', { sceneId: scene.id, layerId });
+ console.warn(
+ `[Scenes] Scene-owned layer refused release: ${layerId}`,
+ );
+ this._logEvent('scene_layer_release_refused', {
+ sceneId: scene.id,
+ layerId,
+ });
}
} catch (error) {
released = false;
- console.warn(`[Scenes] Scene-owned layer release failed: ${layerId}`, error);
+ console.warn(
+ `[Scenes] Scene-owned layer release failed: ${layerId}`,
+ error,
+ );
this._logEvent('scene_layer_release_error', {
sceneId: scene.id,
layerId,
@@ -2352,12 +2723,25 @@ export class SceneDirector {
/** Let an opt-in media owner finish playback and its fade before the next flight. */
async _holdShot(scene, shot, token) {
- const readers = Object.entries(this._layerStatesForShot(scene, shot)).flatMap(([id, state]) => {
+ const readers = Object.entries(
+ this._layerStatesForShot(scene, shot),
+ ).flatMap(([id, state]) => {
const module = this.dataManager?.layers?.get(id)?.module;
- if (!state.enabled || typeof module?.getSceneShotMediaHold !== 'function') return [];
+ if (!state.enabled || typeof module?.getSceneShotMediaHold !== 'function')
+ return [];
const read = () => module.getSceneShotMediaHold(state.params?.beatId);
const initial = read();
- return initial ? [{ read, maxWaitMs: Math.min(20000, Math.max(0, Number(initial.maxWaitMs) || 0)) }] : [];
+ return initial
+ ? [
+ {
+ read,
+ maxWaitMs: Math.min(
+ 20000,
+ Math.max(0, Number(initial.maxWaitMs) || 0),
+ ),
+ },
+ ]
+ : [];
});
if (!readers.length) {
await this._sleep(this._effectiveShotHoldSec(scene, shot) * 1000, token);
@@ -2369,7 +2753,9 @@ export class SceneDirector {
if (!pending.length) return;
if (pending.some(({ maxWaitMs }) => Date.now() - began >= maxWaitMs)) {
this.stopScene('Scene media timed out');
- throw new Error('Scene media did not finish within its bounded playback window');
+ throw new Error(
+ 'Scene media did not finish within its bounded playback window',
+ );
}
await this._sleep(70, token);
}
@@ -2408,14 +2794,22 @@ export class SceneDirector {
if (token.cancelled || this._running) return;
this._setProgress(from);
if (sceneClock) {
- this._publishSceneClock(sceneClock.scene, sceneClock.shot,
- sceneClock.sceneElapsedFrom, { running: false });
+ this._publishSceneClock(
+ sceneClock.scene,
+ sceneClock.shot,
+ sceneClock.sceneElapsedFrom,
+ { running: false },
+ );
}
if (seconds <= 0) {
this._setProgress(to);
if (sceneClock) {
- this._publishSceneClock(sceneClock.scene, sceneClock.shot,
- sceneClock.sceneElapsedTo, { running: false });
+ this._publishSceneClock(
+ sceneClock.scene,
+ sceneClock.shot,
+ sceneClock.sceneElapsedTo,
+ { running: false },
+ );
}
return;
}
@@ -2432,8 +2826,9 @@ export class SceneDirector {
this._publishSceneClock(
sceneClock.scene,
sceneClock.shot,
- sceneClock.sceneElapsedFrom
- + (sceneClock.sceneElapsedTo - sceneClock.sceneElapsedFrom) * fraction,
+ sceneClock.sceneElapsedFrom +
+ (sceneClock.sceneElapsedTo - sceneClock.sceneElapsedFrom) *
+ fraction,
{ running: false },
);
}
@@ -2451,17 +2846,27 @@ export class SceneDirector {
clearInterval(this._sceneClockTimer);
const timing = this._sceneTimingForShot(scene, shot);
const startedAt = Date.now();
- this._publishSceneClock(scene, shot, timing.startElapsedSec, { running: true });
+ this._publishSceneClock(scene, shot, timing.startElapsedSec, {
+ running: true,
+ });
const timer = setInterval(() => {
if (token.cancelled || !this._running) {
clearInterval(timer);
if (this._sceneClockTimer === timer) this._sceneClockTimer = null;
return;
}
- const shotElapsedSec = Math.min(timing.durationSec, (Date.now() - startedAt) / 1000);
- this._publishSceneClock(scene, shot, timing.startElapsedSec + shotElapsedSec, {
- running: true,
- });
+ const shotElapsedSec = Math.min(
+ timing.durationSec,
+ (Date.now() - startedAt) / 1000,
+ );
+ this._publishSceneClock(
+ scene,
+ shot,
+ timing.startElapsedSec + shotElapsedSec,
+ {
+ running: true,
+ },
+ );
}, 50);
timer.unref?.();
this._sceneClockTimer = timer;
@@ -2505,13 +2910,22 @@ export class SceneDirector {
this._runToken = null;
this._setButtons(false);
if (this._sceneClockSnapshot) {
- const scene = this._project.scenes.find(({ id }) => id === this._sceneClockSnapshot.sceneId);
- const shot = scene?.shots?.find(({ id }) => id === this._sceneClockSnapshot.shotId);
+ const scene = this._project.scenes.find(
+ ({ id }) => id === this._sceneClockSnapshot.sceneId,
+ );
+ const shot = scene?.shots?.find(
+ ({ id }) => id === this._sceneClockSnapshot.shotId,
+ );
if (scene && shot) {
- this._publishSceneClock(scene, shot, this._sceneClockSnapshot.sceneElapsedSec, {
- running: false,
- seeking: this._sceneClockSnapshot.seeking,
- });
+ this._publishSceneClock(
+ scene,
+ shot,
+ this._sceneClockSnapshot.sceneElapsedSec,
+ {
+ running: false,
+ seeking: this._sceneClockSnapshot.seeking,
+ },
+ );
}
}
for (const resolve of this._runIdleResolvers) resolve();
diff --git a/src/scenes/nepalEvidencePack.js b/src/scenes/nepalEvidencePack.js
index c9a734dca..eaca287bc 100644
--- a/src/scenes/nepalEvidencePack.js
+++ b/src/scenes/nepalEvidencePack.js
@@ -13,7 +13,7 @@ const ADDITIONAL_SHOTS = [
alt: 10145,
heading: 224,
pitch: -38,
- hold: 11
+ hold: 11,
},
{
id: 'dhunche',
@@ -23,7 +23,7 @@ const ADDITIONAL_SHOTS = [
alt: 6212,
heading: 208,
pitch: -39,
- hold: 16
+ hold: 16,
},
{
id: 'mailung-upper-trishuli',
@@ -33,7 +33,7 @@ const ADDITIONAL_SHOTS = [
alt: 6353,
heading: 211,
pitch: -40,
- hold: 13
+ hold: 13,
},
{
id: 'mailung-bazzar',
@@ -43,7 +43,7 @@ const ADDITIONAL_SHOTS = [
alt: 6331,
heading: 212,
pitch: -40,
- hold: 15
+ hold: 15,
},
{
id: 'dandagaun',
@@ -53,7 +53,7 @@ const ADDITIONAL_SHOTS = [
alt: 6311,
heading: 214,
pitch: -40,
- hold: 15
+ hold: 15,
},
{
id: 'dandagaun-viewpoint',
@@ -63,7 +63,7 @@ const ADDITIONAL_SHOTS = [
alt: 6740,
heading: 215,
pitch: -41,
- hold: 13
+ hold: 13,
},
{
id: 'betrawati-bazaar',
@@ -73,7 +73,7 @@ const ADDITIONAL_SHOTS = [
alt: 7034,
heading: 216,
pitch: -41,
- hold: 12
+ hold: 12,
},
{
id: 'bhainse',
@@ -83,7 +83,7 @@ const ADDITIONAL_SHOTS = [
alt: 8051,
heading: 219,
pitch: -41,
- hold: 13
+ hold: 13,
},
{
id: 'devighat-taadi-khola-bridge',
@@ -93,7 +93,7 @@ const ADDITIONAL_SHOTS = [
alt: 9056,
heading: 224,
pitch: -42,
- hold: 13
+ hold: 13,
},
{
id: 'charaudi',
@@ -103,7 +103,7 @@ const ADDITIONAL_SHOTS = [
alt: 10347,
heading: 232,
pitch: -42,
- hold: 11
+ hold: 11,
},
{
id: 'final-view',
@@ -116,8 +116,8 @@ const ADDITIONAL_SHOTS = [
roll: 174,
duration: 4,
hold: 11,
- locatorPresentation: 'bhote-koshi-incident-places'
- }
+ locatorPresentation: 'bhote-koshi-incident-places',
+ },
];
const EVIDENCE_ORDER = [
'immediate-collapse-viewpoint',
@@ -136,7 +136,7 @@ const EVIDENCE_ORDER = [
'bidur-trishuli-bridge',
'devighat-taadi-khola-bridge',
'charaudi',
- 'final-view'
+ 'final-view',
];
// Per-reach motion timing keeps the schematic front ahead of the fixed 4.2s
@@ -147,60 +147,66 @@ const PATH_DURATION_BY_BEAT = Object.freeze({
'second-landslide': 6.5,
'timure-cluster': 2.1,
'syabru-besi': 2.8,
- 'dhunche': 2.5,
+ dhunche: 2.5,
'mailung-upper-trishuli': 2.9,
'mailung-bazzar': 2.1,
- 'dandagaun': 1.2,
+ dandagaun: 1.2,
'dandagaun-viewpoint': 2.5,
'betrawati-bazaar': 2.9,
- 'bhainse': 1.5,
+ 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': {
+ 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: locatorPresentation },
+ params: {
+ presentation: 'scene-beat',
+ beatId: id,
+ beatReveal: 0.36,
+ split: 0.5,
+ },
},
- } : {}),
- },
- }));
- const byBeat = new Map([...base.cameraPath, ...extraShots].map((shot) => [
- shot.layers[layerId].params.beatId, shot,
- ]));
+ ...(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',
+ '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',
+ 'devighat-taadi-khola-bridge',
+ 'charaudi',
+ 'final-view',
]);
for (const shot of cameraPath) {
const id = shot.layers[layerId].params.beatId;
@@ -209,33 +215,43 @@ export function expandNepalEvidencePack(base) {
runtimeControlsByBeat[id] = {
...runtimeControlsByBeat[id],
evidenceMediaAutoplay: !isFinalView,
- ...(isFinalView ? {} : {
- mediaPlaybackHoldSec: id === 'mailung-bazzar' ? 7 : 6,
- mediaExitDurationSec: 0.65,
- }),
+ ...(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,
- } : {}),
+ 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),
+ evidencePathDurationSec:
+ authoredPathDurationSec || Math.max(4, shot.hold),
minimumHoldSec: Math.max(4, shot.hold),
};
}
@@ -256,33 +272,40 @@ export function expandNepalEvidencePack(base) {
evidencePathElevation: 'source',
};
}
- const pathDurationSec = Number(runtimeControlsByBeat[id].evidencePathDurationSec)
- || (!cameraLedPathBeats.has(id) && runtimeControlsByBeat[id].evidenceSequence === true
+ 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;
+ 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,
- ),
+ 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],
+ ['immediate-collapse-viewpoint', 10],
+ ['gyirong-border-gate', 8, 2.5],
+ ['timure-cluster', 12],
+ ['syabru-besi', 11],
]) {
runtimeControlsByBeat[id] = {
...runtimeControlsByBeat[id],
@@ -296,7 +319,10 @@ export function expandNepalEvidencePack(base) {
}
const finalView = byBeat.get('final-view');
return Object.freeze({
- ...base, version: 18, cameraPath, runtimeControlsByBeat,
+ ...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,
diff --git a/src/scenes/recipes.js b/src/scenes/recipes.js
index 745cf0c3b..e04c8692b 100644
--- a/src/scenes/recipes.js
+++ b/src/scenes/recipes.js
@@ -6,558 +6,558 @@
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: [
+ 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',
- visual: { mapStack: 'photoreal' },
+ lat: 27.5962,
+ lon: 78.5718,
+ alt: 15628002,
+ heading: 0,
+ pitch: -90,
+ roll: 0,
+ duration: 4,
+ hold: 0.9,
layers: {
- 'bhote-koshi-2026': {
+ 'bhote-koshi-locator': {
enabled: true,
- params: {
- presentation: 'scene-beat',
- beatId: 'immediate-collapse-viewpoint',
- beatReveal: 0.36,
- split: 0.5,
- },
+ params: { presentation: 'nepal-context' },
},
},
},
{
title: 'Nepal-Focused Globe Rotation',
- visual: { mapStack: 'photoreal' },
+ lat: 28.7248,
+ lon: 81.3226,
+ alt: 2068966,
+ heading: 0,
+ pitch: -90,
+ roll: 0,
+ duration: 4,
+ hold: 0.9,
layers: {
- 'bhote-koshi-2026': {
+ 'bhote-koshi-locator': {
enabled: true,
- params: {
- presentation: 'scene-beat',
- beatId: 'debris-dammed-lake',
- beatReveal: 0.36,
- split: 0.5,
- },
+ params: { presentation: 'nepal-context' },
},
},
},
{
title: 'Bhote Koshi Regional Approach',
- visual: { mapStack: 'photoreal' },
+ lat: 27.0354,
+ lon: 84.6287,
+ alt: 809543,
+ heading: 0,
+ pitch: -90,
+ roll: 0,
+ duration: 4,
+ hold: 0.9,
layers: {
- 'bhote-koshi-2026': {
+ 'bhote-koshi-locator': {
enabled: true,
- params: {
- presentation: 'scene-beat',
- beatId: 'gyirong-border-gate',
- beatReveal: 0.36,
- split: 0.5,
- },
+ params: { presentation: 'bhote-koshi-regional' },
},
},
},
{
title: 'Bhote Koshi Nearby Cities',
- visual: { mapStack: 'photoreal' },
+ lat: 27.891,
+ lon: 85.428,
+ alt: 225734,
+ heading: 0,
+ pitch: -90,
+ roll: 0,
+ duration: 4,
+ hold: 0.9,
layers: {
- 'bhote-koshi-2026': {
+ 'bhote-koshi-locator': {
enabled: true,
- params: {
- presentation: 'scene-beat',
- beatId: 'timure-cluster',
- beatReveal: 0.36,
- split: 0.5,
- },
+ params: { presentation: 'bhote-koshi-city-context' },
},
},
},
{
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' },
+ lat: 28.0529,
+ lon: 85.2189,
+ alt: 126931,
+ heading: 0,
+ pitch: -90,
+ roll: 0,
+ duration: 4,
+ hold: 11,
layers: {
- 'bhote-koshi-2026': {
+ 'bhote-koshi-locator': {
enabled: true,
- params: {
- presentation: 'scene-beat',
- beatId: 'syabru-besi',
- beatReveal: 0.36,
- split: 0.5,
- },
+ params: { presentation: 'bhote-koshi-incident-places' },
},
},
},
{
title: 'Bhote Koshi Flood Path',
- visual: { mapStack: 'photoreal' },
+ lat: 28.1691,
+ lon: 85.352,
+ alt: 60328,
+ heading: 0,
+ pitch: -90,
+ roll: 0,
+ duration: 4,
+ hold: 0.9,
layers: {
- 'bhote-koshi-2026': {
+ 'bhote-koshi-locator': {
enabled: true,
- params: {
- presentation: 'scene-beat',
- beatId: 'bidur-trishuli-bridge',
- beatReveal: 0.36,
- split: 0.5,
- },
+ params: { presentation: 'bhote-koshi-flood-path' },
},
},
},
{
title: 'Bhote Koshi Corridor Overview',
- visual: { mapStack: 'photoreal' },
+ lat: 27.8986,
+ lon: 84.8723,
+ alt: 53247,
+ heading: 60,
+ pitch: -48,
+ roll: 0,
+ duration: 4,
+ hold: 0.9,
layers: {
- 'bhote-koshi-2026': {
+ 'bhote-koshi-locator': {
enabled: true,
- params: {
- presentation: 'scene-beat',
- beatId: 'gyirong-border-gate',
- beatReveal: 0.36,
- split: 0.5,
- },
+ params: { presentation: 'bhote-koshi-path-overview' },
},
},
},
{
title: 'Bhote Koshi Upper Valley',
- holdSec: 8,
- visual: { mapStack: 'photoreal' },
+ lat: 28.4187,
+ lon: 85.4341,
+ alt: 7977,
+ heading: 155,
+ pitch: -28,
+ roll: 0,
+ duration: 4,
+ hold: 8,
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',
- },
+ 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',
+ },
+ 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,
- 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 },
+ },
+ 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: '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: '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: '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: '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: '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: '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,
},
},
},
},
- {
- 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)
@@ -568,25 +568,27 @@ const BHOTE_KOSHI_NEPAL_APPEND_RECIPE = BHOTE_KOSHI_NEPAL_BASE_RECIPE
// 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}` })),
- })
+ 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 EVENT_RECIPES = [BHOTE_KOSHI_NEPAL_BOOTSTRAP_RECIPE].filter(Boolean);
const PUBLIC_SCENE_RECIPES = [
{
@@ -963,9 +965,7 @@ const PUBLIC_SCENE_RECIPES = [
];
/** Build the recipe list without mutating stored user-authored projects. */
-export function createSceneRecipes({
- localDemoRecipes = EVENT_RECIPES,
-} = {}) {
+export function createSceneRecipes({ localDemoRecipes = EVENT_RECIPES } = {}) {
return [...localDemoRecipes, ...PUBLIC_SCENE_RECIPES];
}