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]; }