fix: cancel Nepal media preloads and respect drawing ownership

Retain active-flight preloading while canceling hidden frames and pending Facebook sessions on stop, disable, and replacement. Add lifecycle and pointer-ownership regression coverage. Apply the runtime formatting adopted on main and keep the existing source-shape check insensitive to that formatting.
This commit is contained in:
Sameh Khamis committed 2026-09-15 08:29:01 -07:00
1 parent 1a936ebac1
commit a9482f0a5b
15 files changed
+3584 -1803

No files matched your search

+2
View File
@@ -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.
+2
View File
@@ -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.
+352 -175
View File
@@ -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 = '<path pathLength="1" fill="none" stroke="rgba(147,213,228,.72)" stroke-width="1.5"/><circle r="4" fill="#6be8ff" stroke="#ffffff" stroke-width="1.5"/>';
leader.innerHTML =
'<path pathLength="1" fill="none" stroke="rgba(147,213,228,.72)" stroke-width="1.5"/><circle r="4" fill="#6be8ff" stroke="#ffffff" stroke-width="1.5"/>';
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`;
},
});
}
+95
View File
@@ -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();
});
+1221 -537
View File
File diff suppressed because it is too large. Load diff
+9 -1
View File
@@ -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',
);
});
+112 -17
View File
@@ -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;
+310 -140
View File
@@ -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',
+52 -20
View File
@@ -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);
+20 -4
View File
@@ -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',
+64 -31
View File
@@ -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;
+43 -16
View File
@@ -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);
}
+701 -287
View File
File diff suppressed because it is too large. Load diff
+109 -83
View File
@@ -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,
+492 -492
View File
File diff suppressed because it is too large. Load diff