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 # 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. - 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. - 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 # 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. 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. 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 X_WIDGET_URL = 'https://platform.twitter.com/widgets.js';
const FACEBOOK_SDK_ID = 'facebook-jssdk'; 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 EMBED_TIMEOUT_MS = 15000;
const CARD_EXIT_DURATION_MS = 620; const CARD_EXIT_DURATION_MS = 620;
const MEDIA_PRECONNECT_ORIGINS = Object.freeze({ const MEDIA_PRECONNECT_ORIGINS = Object.freeze({
@@ -44,7 +45,8 @@ let _facebookEmbedSequence = 0;
const youtubeApis = new WeakMap(); const youtubeApis = new WeakMap();
function loadYouTubeApi(documentRef, globalRef) { 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); if (youtubeApis.has(documentRef)) return youtubeApis.get(documentRef);
const pending = new Promise((resolve, reject) => { const pending = new Promise((resolve, reject) => {
const previous = globalRef.onYouTubeIframeAPIReady; const previous = globalRef.onYouTubeIframeAPIReady;
@@ -55,30 +57,54 @@ function loadYouTubeApi(documentRef, globalRef) {
if (settled) return; if (settled) return;
settled = true; settled = true;
globalRef.clearTimeout(timer); globalRef.clearTimeout(timer);
if (globalRef.onYouTubeIframeAPIReady === ready) globalRef.onYouTubeIframeAPIReady = previous; if (globalRef.onYouTubeIframeAPIReady === ready)
if (error) { script.remove(); reject(error); } globalRef.onYouTubeIframeAPIReady = previous;
else resolve(globalRef.YT); if (error) {
script.remove();
reject(error);
} else resolve(globalRef.YT);
}; };
const ready = () => { const ready = () => {
try { previous?.(); } finally { try {
finish(typeof globalRef.YT?.Player === 'function' ? null : new Error('YouTube API unavailable')); previous?.();
} finally {
finish(
typeof globalRef.YT?.Player === 'function'
? null
: new Error('YouTube API unavailable'),
);
} }
}; };
globalRef.onYouTubeIframeAPIReady = ready; globalRef.onYouTubeIframeAPIReady = ready;
script.src = 'https://www.youtube.com/iframe_api'; script.src = 'https://www.youtube.com/iframe_api';
script.async = true; script.async = true;
script.addEventListener('error', () => finish(new Error('YouTube API unavailable')), { once: true }); script.addEventListener(
timer = globalRef.setTimeout(() => finish(new Error('YouTube API timed out')), 5000); 'error',
() => finish(new Error('YouTube API unavailable')),
{ once: true },
);
timer = globalRef.setTimeout(
() => finish(new Error('YouTube API timed out')),
5000,
);
documentRef.head.append(script); documentRef.head.append(script);
}).catch((error) => { youtubeApis.delete(documentRef); throw error; }); }).catch((error) => {
youtubeApis.delete(documentRef);
throw error;
});
youtubeApis.set(documentRef, pending); youtubeApis.set(documentRef, pending);
return pending; return pending;
} }
/** A trimmed clip follows provider time, never time spent loading its iframe. */ /** A trimmed clip follows provider time, never time spent loading its iframe. */
export function createTrimmedYouTubePlayback({ export function createTrimmedYouTubePlayback({
iframe, endAtSec, startAtSec = 0, loadApi, globalRef = globalThis, iframe,
isCurrent = () => true, onStatus = () => {}, endAtSec,
startAtSec = 0,
loadApi,
globalRef = globalThis,
isCurrent = () => true,
onStatus = () => {},
}) { }) {
let player = null; let player = null;
let phase = 'starting'; let phase = 'starting';
@@ -87,7 +113,8 @@ export function createTrimmedYouTubePlayback({
let currentTime = startAtSec; let currentTime = startAtSec;
let poll = null; let poll = null;
let deadline = null; let deadline = null;
const live = () => active && isCurrent() && ['starting', 'playing'].includes(phase); const live = () =>
active && isCurrent() && ['starting', 'playing'].includes(phase);
const clear = () => { const clear = () => {
globalRef.clearTimeout(deadline); globalRef.clearTimeout(deadline);
globalRef.clearTimeout(poll); globalRef.clearTimeout(poll);
@@ -97,7 +124,11 @@ export function createTrimmedYouTubePlayback({
if (!live()) return; if (!live()) return;
phase = outcome; phase = outcome;
clear(); clear();
try { player?.pauseVideo?.(); } catch { /* Removed provider frame. */ } try {
player?.pauseVideo?.();
} catch {
/* Removed provider frame. */
}
onStatus(outcome); onStatus(outcome);
}; };
const sample = (stateHint) => { const sample = (stateHint) => {
@@ -109,55 +140,84 @@ export function createTrimmedYouTubePlayback({
const time = player?.getCurrentTime?.(); const time = player?.getCurrentTime?.();
if (Number.isFinite(time)) currentTime = time; if (Number.isFinite(time)) currentTime = time;
const state = stateHint ?? player?.getPlayerState?.(); const state = stateHint ?? player?.getPlayerState?.();
if (currentTime >= endAtSec || (state === 0 && currentTime >= endAtSec - 0.1)) { if (
finish('completed'); return; 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. // Autoplay can begin before the API attaches, without a new PLAYING edge.
if (!started && (state === 1 || currentTime > previousTime)) { if (!started && (state === 1 || currentTime > previousTime)) {
started = true; started = true;
phase = 'playing'; phase = 'playing';
globalRef.clearTimeout(deadline); 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); poll = globalRef.setTimeout(() => sample(), 100);
}; };
deadline = globalRef.setTimeout(() => finish('timeout'), 5000); deadline = globalRef.setTimeout(() => finish('timeout'), 5000);
Promise.resolve().then(loadApi).then((api) => { Promise.resolve()
if (!live()) return; .then(loadApi)
player = new api.Player(iframe, { events: { .then((api) => {
onReady(event) { if (!live()) return;
if (!live()) return; player = new api.Player(iframe, {
player = event.target; events: {
sample(); onReady(event) {
if (!live()) return; if (!live()) return;
try { event.target.mute(); event.target.playVideo(); } catch { finish('unavailable'); } player = event.target;
}, sample();
onStateChange(event) { if (!live()) return;
if (!live()) return; try {
if (event.target) player = event.target; event.target.mute();
sample(event.data); event.target.playVideo();
}, } catch {
onError: () => finish('unavailable'), finish('unavailable');
onAutoplayBlocked: () => finish('blocked'), }
} }); },
}).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 { return {
getState: () => ({ phase, currentTime }), getState: () => ({ phase, currentTime }),
destroy() { destroy() {
active = false; active = false;
phase = 'cancelled'; phase = 'cancelled';
clear(); clear();
try { player?.destroy?.(); } catch { /* Detached frame already released. */ } try {
player?.destroy?.();
} catch {
/* Detached frame already released. */
}
player = null; player = null;
}, },
}; };
} }
function facebookSdkReady(api) { function facebookSdkReady(api) {
return typeof api?.XFBML?.parse === 'function' return (
&& typeof api?.Event?.subscribe === 'function'; typeof api?.XFBML?.parse === 'function' &&
typeof api?.Event?.subscribe === 'function'
);
} }
function loadFacebookSdk(documentRef, globalRef = globalThis) { function loadFacebookSdk(documentRef, globalRef = globalThis) {
@@ -199,10 +259,11 @@ function loadFacebookSdk(documentRef, globalRef = globalThis) {
() => finish(new Error('Facebook player API could not load.')), () => finish(new Error('Facebook player API could not load.')),
{ once: true }, { once: true },
); );
timer = globalRef.setTimeout?.( timer =
() => finish(new Error('Facebook player API timed out.')), globalRef.setTimeout?.(
EMBED_TIMEOUT_MS, () => finish(new Error('Facebook player API timed out.')),
) ?? null; EMBED_TIMEOUT_MS,
) ?? null;
}).catch((error) => { }).catch((error) => {
_facebookSdkPromise = null; _facebookSdkPromise = null;
throw error; throw error;
@@ -221,22 +282,24 @@ export function publicEmbeddedMediaUrl(value) {
} }
const host = url.hostname.toLowerCase(); const host = url.hostname.toLowerCase();
if ( if (
url.protocol !== 'https:' url.protocol !== 'https:' ||
|| url.username url.username ||
|| url.password url.password ||
|| (url.port && url.port !== '443') (url.port && url.port !== '443') ||
|| !host.includes('.') !host.includes('.') ||
|| host.includes(':') host.includes(':') ||
|| /^\d+(\.\d+){3}$/.test(host) /^\d+(\.\d+){3}$/.test(host) ||
|| /(^|\.)(localhost|local|internal|test|invalid)$/.test(host) /(^|\.)(localhost|local|internal|test|invalid)$/.test(host)
) return null; )
return null;
return url; return url;
} }
function youtubeVideoId(url) { function youtubeVideoId(url) {
const host = url.hostname.toLowerCase(); const host = url.hostname.toLowerCase();
if (!YOUTUBE_HOSTS.has(host)) return null; 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'); if (url.pathname === '/watch') return url.searchParams.get('v');
return /^\/(?:embed|shorts)\/([\w-]{11})\/?$/.exec(url.pathname)?.[1] || null; return /^\/(?:embed|shorts)\/([\w-]{11})\/?$/.exec(url.pathname)?.[1] || null;
} }
@@ -244,16 +307,21 @@ function youtubeVideoId(url) {
function isFacebookVideoUrl(url) { function isFacebookVideoUrl(url) {
const host = url.hostname.toLowerCase(); const host = url.hostname.toLowerCase();
if (!FACEBOOK_HOSTS.has(host)) return false; if (!FACEBOOK_HOSTS.has(host)) return false;
if (host === 'fb.watch' || host === 'www.fb.watch') return /^\/[\w.-]+\/?$/.test(url.pathname); if (host === 'fb.watch' || host === 'www.fb.watch')
return /^\/reel\/[\w.-]+\/?$/.test(url.pathname) return /^\/[\w.-]+\/?$/.test(url.pathname);
|| (url.pathname === '/watch/' && Boolean(url.searchParams.get('v'))) return (
|| /\/videos\/[\w.-]+\/?$/.test(url.pathname) /^\/reel\/[\w.-]+\/?$/.test(url.pathname) ||
|| /^\/share\/(?:v|r)\/[\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) { function xPostId(url) {
if (!X_HOSTS.has(url.hostname.toLowerCase())) return null; 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. */ /** 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. */ /** Provider-owned iframe URL for YouTube or Facebook callouts. */
export function embeddedMediaFrameUrl(source, { export function embeddedMediaFrameUrl(
autoplay = false, source,
startAtSec = 0, { autoplay = false, startAtSec = 0, endAtSec = null } = {},
endAtSec = null, ) {
} = {}) {
if (source?.provider === 'youtube') { if (source?.provider === 'youtube') {
const url = new URL(`https://www.youtube-nocookie.com/embed/${source.id}`); const url = new URL(`https://www.youtube-nocookie.com/embed/${source.id}`);
url.searchParams.set('playsinline', '1'); url.searchParams.set('playsinline', '1');
@@ -342,24 +409,40 @@ function loadXWidgets(documentRef, globalRef = globalThis) {
script.dataset.gevXWidgets = 'true'; script.dataset.gevXWidgets = 'true';
documentRef.head?.append(script); documentRef.head?.append(script);
} }
script.addEventListener?.('error', () => finish(new Error('X embed could not load.')), { script.addEventListener?.(
once: true, 'error',
}); () => finish(new Error('X embed could not load.')),
{
once: true,
},
);
const waitForApi = () => { const waitForApi = () => {
if (xWidgetsReady(globalRef.twttr)) { if (xWidgetsReady(globalRef.twttr)) {
finish(); finish();
return; return;
} }
if (typeof globalRef.twttr?.ready === 'function') globalRef.twttr.ready(ready); if (typeof globalRef.twttr?.ready === 'function')
globalRef.twttr.ready(ready);
else ready(); else ready();
}; };
script.addEventListener?.('load', waitForApi, { once: true }); 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(); if (script.dataset?.gevXLoaded === 'true') waitForApi();
else script.addEventListener?.('load', () => { script.dataset.gevXLoaded = 'true'; }, { else
once: true, script.addEventListener?.(
}); 'load',
timer = setTimeout(() => finish(new Error('X embed timed out.')), EMBED_TIMEOUT_MS); () => {
script.dataset.gevXLoaded = 'true';
},
{
once: true,
},
);
timer = setTimeout(
() => finish(new Error('X embed timed out.')),
EMBED_TIMEOUT_MS,
);
}).catch((error) => { }).catch((error) => {
_xWidgetsPromise = null; _xWidgetsPromise = null;
throw error; throw error;
@@ -368,19 +451,26 @@ function loadXWidgets(documentRef, globalRef = globalThis) {
} }
function providerLabel(provider) { function providerLabel(provider) {
return ({ youtube: 'YOUTUBE', facebook: 'FACEBOOK', x: 'X' })[provider] || 'SOURCE'; return (
{ youtube: 'YOUTUBE', facebook: 'FACEBOOK', x: 'X' }[provider] || 'SOURCE'
);
} }
function isPortraitMedia(media = {}) { function isPortraitMedia(media = {}) {
const width = Number(media.width); const width = Number(media.width);
const height = Number(media.height); const height = Number(media.height);
return String(media.orientation || '').toLowerCase() === 'portrait' return (
|| (Number.isFinite(width) && Number.isFinite(height) && height > width); String(media.orientation || '').toLowerCase() === 'portrait' ||
(Number.isFinite(width) && Number.isFinite(height) && height > width)
);
} }
function ensureProviderConnections(documentRef, provider) { function ensureProviderConnections(documentRef, provider) {
for (const origin of MEDIA_PRECONNECT_ORIGINS[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'); const link = documentRef.createElement('link');
link.rel = 'preconnect'; link.rel = 'preconnect';
link.href = origin; link.href = origin;
@@ -394,17 +484,30 @@ function anchorOnScreen(viewer, anchor, root) {
if (!viewer?.scene || !anchor || !root) return null; if (!viewer?.scene || !anchor || !root) return null;
const camera = viewer.camera; const camera = viewer.camera;
if (camera?.positionWC && camera?.directionWC) { 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; 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; if (!pixel) return null;
const canvasBounds = viewer.canvas?.getBoundingClientRect?.(); const canvasBounds = viewer.canvas?.getBoundingClientRect?.();
const rootBounds = root.getBoundingClientRect?.(); const rootBounds = root.getBoundingClientRect?.();
if (!canvasBounds || !rootBounds) return null; if (!canvasBounds || !rootBounds) return null;
const x = pixel.x + canvasBounds.left - rootBounds.left; const x = pixel.x + canvasBounds.left - rootBounds.left;
const y = pixel.y + canvasBounds.top - rootBounds.top; 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 }; return { x, y, width: rootBounds.width, height: rootBounds.height };
} }
@@ -419,15 +522,27 @@ function placeCard(card, leader, anchor) {
let laneRight = anchor.width - margin; let laneRight = anchor.width - margin;
let laneBottom = anchor.height - margin; let laneBottom = anchor.height - margin;
if (rootBounds && documentRef?.querySelector) { if (rootBounds && documentRef?.querySelector) {
const leftPanel = documentRef.querySelector('#scene-panel')?.getBoundingClientRect?.(); const leftPanel = documentRef
const rightRail = documentRef.querySelector('#right-context-rail')?.getBoundingClientRect?.(); .querySelector('#scene-panel')
const commandDock = documentRef.querySelector('#command-dock')?.getBoundingClientRect?.(); ?.getBoundingClientRect?.();
if (leftPanel?.width > 0) laneLeft = Math.max(laneLeft, leftPanel.right - rootBounds.left + margin); const rightRail = documentRef
if (rightRail?.width > 0) laneRight = Math.min(laneRight, rightRail.left - rootBounds.left - margin); .querySelector('#right-context-rail')
if (commandDock?.height > 0) laneBottom = Math.min( ?.getBoundingClientRect?.();
laneBottom, const commandDock = documentRef
commandDock.top - rootBounds.top - margin, .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) { if (laneRight - laneLeft < width) {
laneLeft = margin; laneLeft = margin;
laneRight = anchor.width - margin; laneRight = anchor.width - margin;
@@ -437,11 +552,13 @@ function placeCard(card, leader, anchor) {
if (left + width > laneRight) left = anchor.x - width - gap; if (left + width > laneRight) left = anchor.x - width - gap;
left = Math.max(laneLeft, Math.min(laneRight - width, left)); left = Math.max(laneLeft, Math.min(laneRight - width, left));
const belowAnchor = anchor.y + gap; const belowAnchor = anchor.y + gap;
const prefersBelow = card.classList.contains('is-youtube') const prefersBelow =
|| card.classList.contains('is-portrait'); card.classList.contains('is-youtube') ||
const desiredTop = prefersBelow && belowAnchor + height <= laneBottom card.classList.contains('is-portrait');
? belowAnchor const desiredTop =
: anchor.y - height * 0.42; prefersBelow && belowAnchor + height <= laneBottom
? belowAnchor
: anchor.y - height * 0.42;
const top = Math.max(margin, Math.min(laneBottom - height, desiredTop)); const top = Math.max(margin, Math.min(laneBottom - height, desiredTop));
card.style.left = `${left}px`; card.style.left = `${left}px`;
card.style.top = `${top}px`; card.style.top = `${top}px`;
@@ -449,7 +566,9 @@ function placeCard(card, leader, anchor) {
const localY = anchor.y - top; const localY = anchor.y - top;
const edgeX = localX < 0 ? 0 : width; const edgeX = localX < 0 ? 0 : width;
const edgeY = Math.max(16, Math.min(height - 16, localY)); 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'); const dot = leader.querySelector('circle');
dot?.setAttribute('cx', String(localX)); dot?.setAttribute('cx', String(localX));
dot?.setAttribute('cy', String(localY)); dot?.setAttribute('cy', String(localY));
@@ -492,9 +611,9 @@ export function createBhoteKoshiEmbeddedMedia({
let generation = 0; let generation = 0;
const leavingRecords = new Set(); const leavingRecords = new Set();
const prefersReducedMotion = () => ( const prefersReducedMotion = () =>
globalRef.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches === true globalRef.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ===
); true;
const scheduleFrame = (callback) => { const scheduleFrame = (callback) => {
if (typeof globalRef.requestAnimationFrame === 'function') { if (typeof globalRef.requestAnimationFrame === 'function') {
return { kind: 'frame', id: globalRef.requestAnimationFrame(callback) }; return { kind: 'frame', id: globalRef.requestAnimationFrame(callback) };
@@ -507,7 +626,8 @@ export function createBhoteKoshiEmbeddedMedia({
}; };
const cancelScheduledFrame = (scheduled) => { const cancelScheduledFrame = (scheduled) => {
if (!scheduled) return; if (!scheduled) return;
if (scheduled.kind === 'frame') globalRef.cancelAnimationFrame?.(scheduled.id); if (scheduled.kind === 'frame')
globalRef.cancelAnimationFrame?.(scheduled.id);
else globalRef.clearTimeout?.(scheduled.id); else globalRef.clearTimeout?.(scheduled.id);
}; };
@@ -585,34 +705,41 @@ export function createBhoteKoshiEmbeddedMedia({
embed.dataset.autoplay = 'false'; embed.dataset.autoplay = 'false';
session.embed = embed; session.embed = embed;
host.append(embed); host.append(embed);
void facebookLoader(documentRef, globalRef).then((api) => { void facebookLoader(documentRef, globalRef)
if (!session.active) return; .then((api) => {
session.api = api; if (!session.active) return;
session.readyListener = (message) => { session.api = api;
if (!session.active || message?.type !== 'video' session.readyListener = (message) => {
|| (message.id && message.id !== embed.id)) return; if (
globalRef.clearTimeout?.(session.timer); !session.active ||
session.timer = null; message?.type !== 'video' ||
session.player = message.instance; (message.id && message.id !== embed.id)
session.ready = true; )
session.error = null; return;
session.onReady?.(); globalRef.clearTimeout?.(session.timer);
setFacebookSessionPlayback(session, session.shouldPlay); session.timer = null;
}; session.player = message.instance;
api.Event.subscribe('xfbml.ready', session.readyListener); session.ready = true;
api.XFBML.parse(session.host); session.error = null;
session.timer = globalRef.setTimeout?.(() => { session.onReady?.();
if (!session.active || session.ready) return; setFacebookSessionPlayback(session, session.shouldPlay);
session.error = new Error( };
'Facebook embed unavailable. The post may be private, removed, region-restricted, or blocked.', api.Event.subscribe('xfbml.ready', session.readyListener);
); api.XFBML.parse(session.host);
session.onError?.(session.error); session.timer =
}, EMBED_TIMEOUT_MS) ?? null; globalRef.setTimeout?.(() => {
}).catch((error) => { if (!session.active || session.ready) return;
if (!session.active) return; session.error = new Error(
session.error = error; 'Facebook embed unavailable. The post may be private, removed, region-restricted, or blocked.',
session.onError?.(error); );
}); session.onError?.(session.error);
}, EMBED_TIMEOUT_MS) ?? null;
})
.catch((error) => {
if (!session.active) return;
session.error = error;
session.onError?.(error);
});
return session; 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; generation += 1;
const current = record; const current = record;
record = null; record = null;
@@ -662,15 +791,17 @@ export function createBhoteKoshiEmbeddedMedia({
current.card.classList.remove('is-entering'); current.card.classList.remove('is-entering');
current.card.classList.add('is-leaving'); current.card.classList.add('is-leaving');
leavingRecords.add(current); leavingRecords.add(current);
current.exitTimer = globalRef.setTimeout?.( current.exitTimer =
() => removeRecord(current), globalRef.setTimeout?.(
CARD_EXIT_DURATION_MS, () => removeRecord(current),
) ?? null; CARD_EXIT_DURATION_MS,
) ?? null;
} }
function clearWarm(key = null) { function clearWarm(key = null) {
if (!warmRecord || (key && warmRecord.key !== key)) return; if (!warmRecord || (key && warmRecord.key !== key)) return;
if (warmRecord.facebookSession) destroyFacebookSession(warmRecord.facebookSession); if (warmRecord.facebookSession)
destroyFacebookSession(warmRecord.facebookSession);
else warmRecord.iframe?.remove?.(); else warmRecord.iframe?.remove?.();
warmRecord = null; warmRecord = null;
} }
@@ -694,9 +825,10 @@ export function createBhoteKoshiEmbeddedMedia({
clearWarm(); clearWarm();
return false; return false;
} }
const startAtSec = source.provider === 'youtube' const startAtSec =
? Math.max(0, Number(media.embedStartAtSec ?? media.clipInSec) || 0) source.provider === 'youtube'
: 0; ? Math.max(0, Number(media.embedStartAtSec ?? media.clipInSec) || 0)
: 0;
ensureProviderConnections(documentRef, source.provider); ensureProviderConnections(documentRef, source.provider);
const endAtSec = media.embedEndAtSec; const endAtSec = media.embedEndAtSec;
const key = `${source.provider}:${source.id || source.url}:${startAtSec}:${endAtSec ?? ''}`; const key = `${source.provider}:${source.id || source.url}:${startAtSec}:${endAtSec ?? ''}`;
@@ -713,7 +845,11 @@ export function createBhoteKoshiEmbeddedMedia({
} }
const iframe = documentRef.createElement('iframe'); const iframe = documentRef.createElement('iframe');
iframe.className = 'bhote-embedded-media-warmup'; 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.title = `Preloading ${providerLabel(source.provider)} media`;
iframe.tabIndex = -1; iframe.tabIndex = -1;
iframe.setAttribute('aria-hidden', 'true'); iframe.setAttribute('aria-hidden', 'true');
@@ -729,15 +865,20 @@ export function createBhoteKoshiEmbeddedMedia({
try { try {
const api = await xLoader(documentRef, globalRef); const api = await xLoader(documentRef, globalRef);
if (token !== generation || record !== current) return; if (token !== generation || record !== current) return;
const widget = await api.widgets.createTweet(current.source.id, current.player, { const widget = await api.widgets.createTweet(
align: 'center', current.source.id,
conversation: 'none', current.player,
dnt: true, {
theme: 'dark', align: 'center',
width: 390, conversation: 'none',
}); dnt: true,
theme: 'dark',
width: 390,
},
);
if (token !== generation || record !== current) return; 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'; current.status.textContent = 'X POST · PROVIDER CONTROLS';
position(); position();
} catch (error) { } catch (error) {
@@ -749,8 +890,9 @@ export function createBhoteKoshiEmbeddedMedia({
} }
function bindFacebook(current, token, warmKey, autoplay) { function bindFacebook(current, token, warmKey, autoplay) {
const session = takeWarmFacebook(warmKey, current.player) const session =
|| createFacebookSession(current.source, current.player); takeWarmFacebook(warmKey, current.player) ||
createFacebookSession(current.source, current.player);
current.facebookSession = session; current.facebookSession = session;
session.shouldPlay = autoplay === true; session.shouldPlay = autoplay === true;
session.onReady = () => { session.onReady = () => {
@@ -775,6 +917,7 @@ export function createBhoteKoshiEmbeddedMedia({
} }
function setPlayback(playing) { function setPlayback(playing) {
if (!playing) clearWarm();
// Removing an uncontrolled provider frame also cancels delayed autoplay. // Removing an uncontrolled provider frame also cancels delayed autoplay.
// Provider commands alone cannot stop a frame that has not loaded yet. // Provider commands alone cannot stop a frame that has not loaded yet.
if (!playing && record && !record.facebookSession) { if (!playing && record && !record.facebookSession) {
@@ -792,37 +935,49 @@ export function createBhoteKoshiEmbeddedMedia({
hide(); hide();
return false; return false;
} }
const startAtSec = source.provider === 'youtube' const startAtSec =
? Math.max(0, Number(media.embedStartAtSec ?? media.clipInSec) || 0) source.provider === 'youtube'
: 0; ? Math.max(0, Number(media.embedStartAtSec ?? media.clipInSec) || 0)
: 0;
ensureProviderConnections(documentRef, source.provider); ensureProviderConnections(documentRef, source.provider);
const endAtSec = media.embedEndAtSec; const endAtSec = media.embedEndAtSec;
const warmKey = `${source.provider}:${source.id || source.url}:${startAtSec}:${endAtSec ?? ''}`; const warmKey = `${source.provider}:${source.id || source.url}:${startAtSec}:${endAtSec ?? ''}`;
const key = source.provider === 'facebook' const key =
? warmKey source.provider === 'facebook'
: `${warmKey}:${autoplay ? 1 : 0}`; ? warmKey
: `${warmKey}:${autoplay ? 1 : 0}`;
if (record?.key === key) { if (record?.key === key) {
record.anchor = anchor; record.anchor = anchor;
if (source.provider === 'facebook') setPlayback(autoplay); if (source.provider === 'facebook') setPlayback(autoplay);
position(); position();
return true; return true;
} }
hide(); if (warmRecord && warmRecord.key !== warmKey) clearWarm();
hide({ preserveWarm: true });
const token = ++generation; const token = ++generation;
const card = documentRef.createElement('section'); const card = documentRef.createElement('section');
const portraitClass = isPortraitMedia(media) ? ' is-portrait' : ''; const portraitClass = isPortraitMedia(media) ? ' is-portrait' : '';
card.className = `bhote-embedded-callout is-${source.provider}${portraitClass}`; card.className = `bhote-embedded-callout is-${source.provider}${portraitClass}`;
card.dataset.provider = source.provider; card.dataset.provider = source.provider;
card.setAttribute('aria-label', `Embedded ${providerLabel(source.provider)} media for ${observation?.title || 'witness source'}`); card.setAttribute(
const leader = documentRef.createElementNS('http://www.w3.org/2000/svg', 'svg'); '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.classList.add('bhote-embedded-callout-leader');
leader.setAttribute('aria-hidden', 'true'); 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'); const player = documentRef.createElement('div');
player.className = 'bhote-embedded-callout-player'; player.className = 'bhote-embedded-callout-player';
const footer = documentRef.createElement('footer'); const footer = documentRef.createElement('footer');
const title = documentRef.createElement('strong'); 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'); const status = documentRef.createElement('small');
status.setAttribute('role', 'status'); status.setAttribute('role', 'status');
status.textContent = `${providerLabel(source.provider)} · EMBEDDED SOURCE`; status.textContent = `${providerLabel(source.provider)} · EMBEDDED SOURCE`;
@@ -854,36 +1009,58 @@ export function createBhoteKoshiEmbeddedMedia({
bindFacebook(record, token, warmKey, autoplay); bindFacebook(record, token, warmKey, autoplay);
} else { } else {
const iframe = documentRef.createElement('iframe'); const iframe = documentRef.createElement('iframe');
iframe.src = embeddedMediaFrameUrl(source, { autoplay, startAtSec, endAtSec }); iframe.src = embeddedMediaFrameUrl(source, {
const controlled = autoplay && Number.isFinite(Number(endAtSec)) && Number(endAtSec) > startAtSec; autoplay,
startAtSec,
endAtSec,
});
const controlled =
autoplay &&
Number.isFinite(Number(endAtSec)) &&
Number(endAtSec) > startAtSec;
if (controlled) { if (controlled) {
const url = new URL(iframe.src); const url = new URL(iframe.src);
url.searchParams.set('enablejsapi', '1'); 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.src = url.href;
} }
iframe.title = `${providerLabel(source.provider)} media: ${observation?.title || 'witness source'}`; 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.allowFullscreen = true;
iframe.loading = 'eager'; iframe.loading = 'eager';
iframe.referrerPolicy = 'strict-origin-when-cross-origin'; iframe.referrerPolicy = 'strict-origin-when-cross-origin';
iframe.addEventListener?.('load', () => { iframe.addEventListener?.(
if (token !== generation || record?.key !== key) return; 'load',
clearWarm(warmKey); () => {
if (!record.youtubeSession || ['starting', 'playing'].includes(record.youtubeSession.getState().phase)) { if (token !== generation || record?.key !== key) return;
status.textContent = `${providerLabel(source.provider)} · PROVIDER CONTROLS`; clearWarm(warmKey);
} if (
position(); !record.youtubeSession ||
}, { once: true }); ['starting', 'playing'].includes(
record.youtubeSession.getState().phase,
)
) {
status.textContent = `${providerLabel(source.provider)} · PROVIDER CONTROLS`;
}
position();
},
{ once: true },
);
player.append(iframe); player.append(iframe);
if (controlled) { if (controlled) {
const current = record; const current = record;
current.youtubeSession = createTrimmedYouTubePlayback({ current.youtubeSession = createTrimmedYouTubePlayback({
iframe, endAtSec: Number(endAtSec), startAtSec, globalRef, iframe,
endAtSec: Number(endAtSec),
startAtSec,
globalRef,
loadApi: () => youtubeLoader(documentRef, globalRef), loadApi: () => youtubeLoader(documentRef, globalRef),
isCurrent: () => token === generation && record === current, isCurrent: () => token === generation && record === current,
onStatus: (phase) => { 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); 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 assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import test from 'node:test'; import test from 'node:test';
import { claimPointer, releasePointer } from './inputOwnership.js';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { SOURCE_PATH_BEAT_IDS } from './bhoteKoshiShotPaths.js'; import { SOURCE_PATH_BEAT_IDS } from './bhoteKoshiShotPaths.js';
import { import {
@@ -1777,6 +1778,13 @@ test('Debris-Dammed Lake card opens its source and is armed for approved local v
beatReveal: 0.36, beatReveal: 0.36,
}, { origin: 'scene' }); }, { origin: 'scene' });
await layer.enable(viewer, { 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 }); viewer._test.canvas.dispatch('click', { offsetX: 640, offsetY: 420 });
assert.deepEqual(opened, [[ assert.deepEqual(opened, [[
event.evidenceSpine[1].media.sourceUrl, event.evidenceSpine[1].media.sourceUrl,
@@ -2185,7 +2193,7 @@ test('Bhote Koshi frame path keeps evidence lookup scalar and wrapper-free', asy
); );
assert.match( assert.match(
source, 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', '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. // Overview pins follow the event pack through Devighat.
// Charaudi remains a separate evidence shot, and none of these observation // Charaudi remains a separate evidence shot, and none of these observation
// locations extends the mapped flood path. // locations extends the mapped flood path.
export const INCIDENT_OVERVIEW_PLACES = Object.freeze([ 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'], 'immediate-collapse-viewpoint',
['gyirong-border-gate', 'BORDER GATE', 28.2808338, 85.3780248, 1818, 'right'], 'COLLAPSE VIEWPOINT',
['timure-cluster', 'TIMURE', 28.2519361, 85.3665833, 1689, 'left'], 28.3632333,
['syabru-besi', 'SYABRU BESI', 28.1602583333333, 85.3325666666667, 1417, 'right'], 85.4423639,
['dhunche', 'DHUNCHE', 28.1131861111111, 85.2953305555556, 1300, 'left'], 2850,
['mailung-upper-trishuli', 'UPPER TRISHULI', 28.090925, 85.2322916666667, 950, 'right'], 'left',
['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'], 'debris-dammed-lake',
['betrawati-bazaar', 'BETRAWATI', 27.9758992572246, 85.1835331689033, 600, 'left'], 'DAMMED LAKE',
['bhainse', 'BHAINSE', 27.9623111111111, 85.1739777777778, 500, 'right-lower'], 28.3346111,
['bidur-trishuli-bridge', 'BIDUR BRIDGE', 27.920858, 85.148029, 510, 'left'], 85.4861389,
['devighat-taadi-khola-bridge', 'DEVIGHAT BRIDGE', 27.8585278, 85.1090318, 350, 'right'], 2750,
].map(([id, title, lat, lon, heightM, placement]) => Object.freeze({ id, title, lat, lon, heightM, placement }))); '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; 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'; import { INCIDENT_OVERVIEW_PLACES } from './bhoteKoshiIncidentPlaces.js';
export const BHOTE_KOSHI_LOCATOR_LAYER_ID = 'bhote-koshi-locator'; 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 NEPAL_CONTEXT_PRESENTATION = 'nepal-context';
export const BHOTE_KOSHI_REGIONAL_PRESENTATION = 'bhote-koshi-regional'; export const BHOTE_KOSHI_REGIONAL_PRESENTATION = 'bhote-koshi-regional';
export const BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION = 'bhote-koshi-city-context'; 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_FLOOD_PATH_PRESENTATION = 'bhote-koshi-flood-path';
export const BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION = 'bhote-koshi-path-overview'; export const BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION =
export const BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION = 'bhote-koshi-trigger-record'; 'bhote-koshi-path-overview';
export const BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION =
'bhote-koshi-trigger-record';
const LEGACY_PATH_BORDER_PRESENTATION = 'bhote-koshi-path-border'; 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_ARM_MS = 500;
const TRIGGER_APPROACH_DURATION_SEC = 4.6; const TRIGGER_APPROACH_DURATION_SEC = 4.6;
const TRIGGER_ORBIT_DURATION_SEC = 2.8; 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({ const INCIDENT = Object.freeze({
lat: 28.3632333, lat: 28.3632333,
@@ -86,9 +91,30 @@ const NEPAL = Object.freeze({
title: 'NEPAL', title: 'NEPAL',
}); });
const NEARBY_CITIES = Object.freeze([ const NEARBY_CITIES = Object.freeze([
Object.freeze({ id: 'kathmandu', title: 'KATHMANDU', lat: 27.7017, lon: 85.3206, heightM: 3600, placement: 'left' }), Object.freeze({
Object.freeze({ id: 'bhaktapur', title: 'BHAKTAPUR', lat: 27.673, lon: 85.43, heightM: 3400, placement: 'right' }), id: 'kathmandu',
Object.freeze({ id: 'dhulikhel', title: 'DHULIKHEL', lat: 27.6221, lon: 85.5428, heightM: 3600, placement: 'right-lower' }), 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_COLOR = Cesium.Color.fromCssColorString('#20e7f2');
const BORDER_ACCENT = '#20e7f2'; const BORDER_ACCENT = '#20e7f2';
@@ -104,7 +130,10 @@ const DEFAULT_OVERLAY_HOST = Object.freeze({
const DEFAULT_BOUNDARY_RESOLVER = async () => null; const DEFAULT_BOUNDARY_RESOLVER = async () => null;
function abortError() { function abortError() {
return new DOMException('Bhote Koshi locator activation cancelled', 'AbortError'); return new DOMException(
'Bhote Koshi locator activation cancelled',
'AbortError',
);
} }
function normalizePresentation(value) { function normalizePresentation(value) {
@@ -118,8 +147,10 @@ function normalizePresentation(value) {
if (value === BHOTE_KOSHI_FLOOD_PATH_PRESENTATION) { if (value === BHOTE_KOSHI_FLOOD_PATH_PRESENTATION) {
return BHOTE_KOSHI_FLOOD_PATH_PRESENTATION; return BHOTE_KOSHI_FLOOD_PATH_PRESENTATION;
} }
if (value === BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION if (
|| value === LEGACY_PATH_BORDER_PRESENTATION) { value === BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION ||
value === LEGACY_PATH_BORDER_PRESENTATION
) {
return BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION; return BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION;
} }
if (value === BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION) { if (value === BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION) {
@@ -129,8 +160,10 @@ function normalizePresentation(value) {
} }
function presentationUsesBoundary(value) { function presentationUsesBoundary(value) {
return value === BHOTE_KOSHI_REGIONAL_PRESENTATION return (
|| value === BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION; value === BHOTE_KOSHI_REGIONAL_PRESENTATION ||
value === BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION
);
} }
function presentationAnimatesBoundary(value) { function presentationAnimatesBoundary(value) {
@@ -145,7 +178,10 @@ function smoothstep(from, to, value) {
/** Resolve the persistent text callout's dot, leader, and card entrance. */ /** Resolve the persistent text callout's dot, leader, and card entrance. */
export function anchoredCalloutPresentation(elapsedMs) { 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); const contentAlpha = smoothstep(0.34, 1, progress);
return { return {
alpha: smoothstep(0, 0.18, progress), alpha: smoothstep(0, 0.18, progress),
@@ -165,12 +201,14 @@ export function revealPolylinePositions(positions, progress) {
const revealed = positions.slice(0, completed + 1); const revealed = positions.slice(0, completed + 1);
const next = Math.min(completed + 1, positions.length - 1); const next = Math.min(completed + 1, positions.length - 1);
if (next > completed) { if (next > completed) {
revealed.push(Cesium.Cartesian3.lerp( revealed.push(
positions[completed], Cesium.Cartesian3.lerp(
positions[next], positions[completed],
scaled - completed, positions[next],
new Cesium.Cartesian3(), scaled - completed,
)); new Cesium.Cartesian3(),
),
);
} }
return revealed.length >= 2 ? revealed : [positions[0], positions[0]]; return revealed.length >= 2 ? revealed : [positions[0], positions[0]];
} }
@@ -184,17 +222,19 @@ export function closeRegionRing(ring) {
if (normalized.length < 3) return []; if (normalized.length < 3) return [];
const [firstLon, firstLat] = normalized[0]; const [firstLon, firstLat] = normalized[0];
const [lastLon, lastLat] = normalized[normalized.length - 1]; 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 normalized;
} }
/** Return the largest exterior ring from a Polygon or MultiPolygon GeoJSON. */ /** Return the largest exterior ring from a Polygon or MultiPolygon GeoJSON. */
export function largestGeoJsonRing(geojson) { export function largestGeoJsonRing(geojson) {
const polygons = geojson?.type === 'Polygon' const polygons =
? [geojson.coordinates] geojson?.type === 'Polygon'
: geojson?.type === 'MultiPolygon' ? [geojson.coordinates]
? geojson.coordinates : geojson?.type === 'MultiPolygon'
: []; ? geojson.coordinates
: [];
const rings = polygons const rings = polygons
.map((polygon) => polygon?.[0]) .map((polygon) => polygon?.[0])
.filter((ring) => Array.isArray(ring) && ring.length >= 3); .filter((ring) => Array.isArray(ring) && ring.length >= 3);
@@ -242,11 +282,15 @@ export function createBhoteKoshiLocatorLayer({
} }
function publishOverlayEntries({ moving = false } = {}) { function publishOverlayEntries({ moving = false } = {}) {
overlayHost.setEntries(BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID, _overlayEntries, { overlayHost.setEntries(
cohortLimit: Math.max(1, _overlayEntries.length), BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID,
collisionCapacity: Math.max(1, _overlayEntries.length), _overlayEntries,
moving, {
}); cohortLimit: Math.max(1, _overlayEntries.length),
collisionCapacity: Math.max(1, _overlayEntries.length),
moving,
},
);
} }
function clearTriggerApproach({ preserveCallout = false } = {}) { function clearTriggerApproach({ preserveCallout = false } = {}) {
@@ -277,17 +321,27 @@ export function createBhoteKoshiLocatorLayer({
function removeDataSource() { function removeDataSource() {
stopAnimations(); stopAnimations();
overlayHost.clearSource(BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID); 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; _dataSource = null;
} }
function addCallout(dataSource, subject, color, { function addCallout(
labelPlacement = 'right', dataSource,
sequenceStartedAt = now(), subject,
visibleAfterMs = 0, color,
showAnchor = true, {
} = {}) { labelPlacement = 'right',
const position = Cesium.Cartesian3.fromDegrees(subject.lon, subject.lat, subject.heightM); sequenceStartedAt = now(),
visibleAfterMs = 0,
showAnchor = true,
} = {},
) {
const position = Cesium.Cartesian3.fromDegrees(
subject.lon,
subject.lat,
subject.heightM,
);
const visibleAt = sequenceStartedAt + visibleAfterMs; const visibleAt = sequenceStartedAt + visibleAfterMs;
const isVisible = () => now() >= visibleAt; const isVisible = () => now() >= visibleAt;
const pulseProgress = () => { const pulseProgress = () => {
@@ -295,7 +349,9 @@ export function createBhoteKoshiLocatorLayer({
return (elapsed % CALLOUT_PULSE_DURATION_MS) / CALLOUT_PULSE_DURATION_MS; return (elapsed % CALLOUT_PULSE_DURATION_MS) / CALLOUT_PULSE_DURATION_MS;
}; };
const ringSize = new Cesium.CallbackProperty(() => { 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); }, false);
const ringColor = new Cesium.CallbackProperty((_time, result) => { const ringColor = new Cesium.CallbackProperty((_time, result) => {
const dynamicColor = Cesium.Color.clone(color, result); const dynamicColor = Cesium.Color.clone(color, result);
@@ -304,7 +360,9 @@ export function createBhoteKoshiLocatorLayer({
}, false); }, false);
const ringOutlineColor = new Cesium.CallbackProperty((_time, result) => { const ringOutlineColor = new Cesium.CallbackProperty((_time, result) => {
const dynamicColor = Cesium.Color.clone(color, 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; return dynamicColor;
}, false); }, false);
if (showAnchor) { if (showAnchor) {
@@ -318,7 +376,12 @@ export function createBhoteKoshiLocatorLayer({
outlineColor: ringOutlineColor, outlineColor: ringOutlineColor,
outlineWidth: 2, outlineWidth: 2,
disableDepthTestDistance: Number.POSITIVE_INFINITY, 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, minDistance: 0,
maxDistance: 5_000_000, maxDistance: 5_000_000,
distanceFadeStartRatio: 0.9, 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', edgeFade: 'none',
horizonCull: true, horizonCull: true,
terrainOcclusion: false, terrainOcclusion: false,
@@ -378,7 +446,12 @@ export function createBhoteKoshiLocatorLayer({
minDistance: 0, minDistance: 0,
maxDistance: 25_000_000, maxDistance: 25_000_000,
distanceFadeStartRatio: 0.9, 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', edgeFade: 'none',
horizonCull: true, horizonCull: true,
terrainOcclusion: false, terrainOcclusion: false,
@@ -403,8 +476,15 @@ export function createBhoteKoshiLocatorLayer({
_calloutPulseFrame = scheduleFrame(pulseFrame); _calloutPulseFrame = scheduleFrame(pulseFrame);
} }
function addIncidentCallout(dataSource, sequenceStartedAt, visibleAfterMs = 0) { function addIncidentCallout(
addCallout(dataSource, INCIDENT, INCIDENT_COLOR, { sequenceStartedAt, visibleAfterMs }); dataSource,
sequenceStartedAt,
visibleAfterMs = 0,
) {
addCallout(dataSource, INCIDENT, INCIDENT_COLOR, {
sequenceStartedAt,
visibleAfterMs,
});
} }
function addTriggerRecordCallout(dataSource) { function addTriggerRecordCallout(dataSource) {
@@ -414,22 +494,26 @@ export function createBhoteKoshiLocatorLayer({
TRIGGER_RECORD.heightM, TRIGGER_RECORD.heightM,
); );
const isVisible = () => now() >= _triggerCalloutStartedAt; const isVisible = () => now() >= _triggerCalloutStartedAt;
const presentation = () => anchoredCalloutPresentation( const presentation = () =>
now() - _triggerCalloutStartedAt, anchoredCalloutPresentation(now() - _triggerCalloutStartedAt);
);
const pulseProgress = () => { const pulseProgress = () => {
if (!isVisible()) return 0; if (!isVisible()) return 0;
return ((now() - _triggerCalloutStartedAt) % CALLOUT_PULSE_DURATION_MS) return (
/ CALLOUT_PULSE_DURATION_MS; ((now() - _triggerCalloutStartedAt) % CALLOUT_PULSE_DURATION_MS) /
CALLOUT_PULSE_DURATION_MS
);
}; };
dataSource.entities.add({ dataSource.entities.add({
id: 'bhote-koshi-trigger-record-halo', id: 'bhote-koshi-trigger-record-halo',
position, position,
show: new Cesium.CallbackProperty(isVisible, false), show: new Cesium.CallbackProperty(isVisible, false),
point: { point: {
pixelSize: new Cesium.CallbackProperty(() => ( pixelSize: new Cesium.CallbackProperty(
CALLOUT_RING_MIN_SIZE_PX + CALLOUT_RING_SIZE_RANGE_PX * pulseProgress() () =>
), false), CALLOUT_RING_MIN_SIZE_PX +
CALLOUT_RING_SIZE_RANGE_PX * pulseProgress(),
false,
),
color: new Cesium.CallbackProperty((_time, result) => { color: new Cesium.CallbackProperty((_time, result) => {
const color = Cesium.Color.clone(INCIDENT_COLOR, result); const color = Cesium.Color.clone(INCIDENT_COLOR, result);
color.alpha = 0.16 * (1 - pulseProgress()); color.alpha = 0.16 * (1 - pulseProgress());
@@ -463,9 +547,14 @@ export function createBhoteKoshiLocatorLayer({
protected: true, protected: true,
active: true, active: true,
interactive: 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: () => { 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; if (opened) opened.opener = null;
}, },
collisionGroup: 'ambient-card', collisionGroup: 'ambient-card',
@@ -484,8 +573,13 @@ export function createBhoteKoshiLocatorLayer({
} }
function startTriggerApproach(attempt) { function startTriggerApproach(attempt) {
if (!_enabled || _presentation !== BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION if (
|| _triggerApproachAttempt !== attempt || attempt.phase !== 'armed') return; !_enabled ||
_presentation !== BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION ||
_triggerApproachAttempt !== attempt ||
attempt.phase !== 'armed'
)
return;
attempt.phase = 'flying'; attempt.phase = 'flying';
if (_triggerApproachTimer !== null) cancelDelay(_triggerApproachTimer); if (_triggerApproachTimer !== null) cancelDelay(_triggerApproachTimer);
_triggerApproachTimer = null; _triggerApproachTimer = null;
@@ -518,9 +612,13 @@ export function createBhoteKoshiLocatorLayer({
duration: TRIGGER_APPROACH_DURATION_SEC, duration: TRIGGER_APPROACH_DURATION_SEC,
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
complete: () => { complete: () => {
if (_triggerApproachAttempt !== attempt || attempt.phase !== 'flying') return; if (_triggerApproachAttempt !== attempt || attempt.phase !== 'flying')
if (!_enabled || generation !== _generation return;
|| _presentation !== BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION) { if (
!_enabled ||
generation !== _generation ||
_presentation !== BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION
) {
finish(); finish();
return; return;
} }
@@ -555,12 +653,16 @@ export function createBhoteKoshiLocatorLayer({
_triggerApproachAttempt = attempt; _triggerApproachAttempt = attempt;
const start = () => startTriggerApproach(attempt); const start = () => startTriggerApproach(attempt);
const camera = _viewer?.camera; const camera = _viewer?.camera;
if (!camera?.moveStart?.addEventListener || !camera?.moveEnd?.addEventListener) { if (
!camera?.moveStart?.addEventListener ||
!camera?.moveEnd?.addEventListener
) {
start(); start();
return; return;
} }
_removeTriggerMoveStart = camera.moveStart.addEventListener(() => { _removeTriggerMoveStart = camera.moveStart.addEventListener(() => {
if (_triggerApproachAttempt !== attempt || attempt.phase !== 'armed') return; if (_triggerApproachAttempt !== attempt || attempt.phase !== 'armed')
return;
if (_triggerApproachTimer !== null) cancelDelay(_triggerApproachTimer); if (_triggerApproachTimer !== null) cancelDelay(_triggerApproachTimer);
_triggerApproachTimer = null; _triggerApproachTimer = null;
_removeTriggerMoveStart?.(); _removeTriggerMoveStart?.();
@@ -585,13 +687,19 @@ export function createBhoteKoshiLocatorLayer({
function addNearbyCityCallouts(dataSource, sequenceStartedAt) { function addNearbyCityCallouts(dataSource, sequenceStartedAt) {
NEARBY_CITIES.forEach((city, index) => { NEARBY_CITIES.forEach((city, index) => {
const visibleAt = sequenceStartedAt const visibleAt =
+ CITY_CALLOUT_START_DELAY_MS sequenceStartedAt +
+ index * CITY_CALLOUT_STAGGER_MS; CITY_CALLOUT_START_DELAY_MS +
const progress = () => smoothstep(0, 1, (now() - visibleAt) / CITY_CALLOUT_REVEAL_MS); index * CITY_CALLOUT_STAGGER_MS;
const progress = () =>
smoothstep(0, 1, (now() - visibleAt) / CITY_CALLOUT_REVEAL_MS);
const labelOnLeft = city.placement === 'left'; const labelOnLeft = city.placement === 'left';
const labelLower = city.placement === 'right-lower'; 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({ queueOverlayEntry({
id: `bhote-koshi-city-${city.id}`, id: `bhote-koshi-city-${city.id}`,
position, position,
@@ -612,7 +720,12 @@ export function createBhoteKoshiLocatorLayer({
minDistance: 0, minDistance: 0,
maxDistance: 3_000_000, maxDistance: 3_000_000,
distanceFadeStartRatio: 0.82, 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', edgeFade: 'keyhole',
horizonCull: true, horizonCull: true,
terrainOcclusion: false, terrainOcclusion: false,
@@ -622,32 +735,45 @@ export function createBhoteKoshiLocatorLayer({
}); });
} }
function addIncidentPlaceCallouts(dataSource, sequenceStartedAt, { function addIncidentPlaceCallouts(
pulseHalos = false, places = INCIDENT_OVERVIEW_PLACES, dataSource,
} = {}) { sequenceStartedAt,
{ pulseHalos = false, places = INCIDENT_OVERVIEW_PLACES } = {},
) {
places.forEach((place, index) => { places.forEach((place, index) => {
const visibleAt = sequenceStartedAt const visibleAt =
+ PLACE_CALLOUT_START_DELAY_MS sequenceStartedAt +
+ index * PLACE_CALLOUT_STAGGER_MS; PLACE_CALLOUT_START_DELAY_MS +
const progress = () => smoothstep(0, 1, (now() - visibleAt) / PLACE_CALLOUT_REVEAL_MS); index * PLACE_CALLOUT_STAGGER_MS;
const progress = () =>
smoothstep(0, 1, (now() - visibleAt) / PLACE_CALLOUT_REVEAL_MS);
const labelOnLeft = place.placement === 'left'; const labelOnLeft = place.placement === 'left';
const labelLower = place.placement === 'right-lower'; 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) { if (pulseHalos) {
const pulseSequenceDurationMs = PLACE_CALLOUT_STAGGER_MS * places.length; const pulseSequenceDurationMs =
PLACE_CALLOUT_STAGGER_MS * places.length;
const pulseWindowElapsed = () => { const pulseWindowElapsed = () => {
const sequenceElapsed = Math.max( const sequenceElapsed = Math.max(
0, 0,
now() - sequenceStartedAt - PLACE_CALLOUT_START_DELAY_MS, now() - sequenceStartedAt - PLACE_CALLOUT_START_DELAY_MS,
); );
return (sequenceElapsed % pulseSequenceDurationMs) return (
- index * PLACE_CALLOUT_STAGGER_MS; (sequenceElapsed % pulseSequenceDurationMs) -
index * PLACE_CALLOUT_STAGGER_MS
);
}; };
const pulseIsActive = () => { const pulseIsActive = () => {
const elapsed = pulseWindowElapsed(); const elapsed = pulseWindowElapsed();
return now() >= visibleAt return (
&& elapsed >= 0 now() >= visibleAt &&
&& elapsed < PLACE_CALLOUT_STAGGER_MS; elapsed >= 0 &&
elapsed < PLACE_CALLOUT_STAGGER_MS
);
}; };
const pulseProgress = () => { const pulseProgress = () => {
return Math.max( return Math.max(
@@ -660,9 +786,12 @@ export function createBhoteKoshiLocatorLayer({
position, position,
show: new Cesium.CallbackProperty(pulseIsActive, false), show: new Cesium.CallbackProperty(pulseIsActive, false),
point: { point: {
pixelSize: new Cesium.CallbackProperty(() => ( pixelSize: new Cesium.CallbackProperty(
CALLOUT_RING_MIN_SIZE_PX + CALLOUT_RING_SIZE_RANGE_PX * pulseProgress() () =>
), false), CALLOUT_RING_MIN_SIZE_PX +
CALLOUT_RING_SIZE_RANGE_PX * pulseProgress(),
false,
),
color: new Cesium.CallbackProperty((_time, result) => { color: new Cesium.CallbackProperty((_time, result) => {
const color = Cesium.Color.clone(INCIDENT_COLOR, result); const color = Cesium.Color.clone(INCIDENT_COLOR, result);
color.alpha = 0.16 * (1 - pulseProgress()); color.alpha = 0.16 * (1 - pulseProgress());
@@ -699,7 +828,12 @@ export function createBhoteKoshiLocatorLayer({
minDistance: 0, minDistance: 0,
maxDistance: 500_000, maxDistance: 500_000,
distanceFadeStartRatio: 0.82, 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', edgeFade: 'keyhole',
horizonCull: true, horizonCull: true,
terrainOcclusion: false, terrainOcclusion: false,
@@ -710,16 +844,18 @@ export function createBhoteKoshiLocatorLayer({
} }
function addIncidentFloodPath(dataSource, sequenceStartedAt) { function addIncidentFloodPath(dataSource, sequenceStartedAt) {
const positions = BHOTE_KOSHI_FLOOD_PATH.map(([lon, lat]) => ( const positions = BHOTE_KOSHI_FLOOD_PATH.map(([lon, lat]) =>
Cesium.Cartesian3.fromDegrees(lon, lat) Cesium.Cartesian3.fromDegrees(lon, lat),
)); );
const drawStartedAt = sequenceStartedAt + FLOOD_PATH_START_DELAY_MS; const drawStartedAt = sequenceStartedAt + FLOOD_PATH_START_DELAY_MS;
const animatedPositions = new Cesium.CallbackProperty(() => ( const animatedPositions = new Cesium.CallbackProperty(
revealPolylinePositions( () =>
positions, revealPolylinePositions(
(now() - drawStartedAt) / FLOOD_PATH_DRAW_DURATION_MS, positions,
) (now() - drawStartedAt) / FLOOD_PATH_DRAW_DURATION_MS,
), false); ),
false,
);
dataSource.entities.add({ dataSource.entities.add({
id: 'bhote-koshi-flood-path', id: 'bhote-koshi-flood-path',
show: new Cesium.CallbackProperty(() => now() >= drawStartedAt, false), show: new Cesium.CallbackProperty(() => now() >= drawStartedAt, false),
@@ -739,9 +875,14 @@ export function createBhoteKoshiLocatorLayer({
const positions = Cesium.Cartesian3.fromDegreesArray(closed.flat()); const positions = Cesium.Cartesian3.fromDegreesArray(closed.flat());
const hierarchy = new Cesium.PolygonHierarchy(positions.slice(0, -1)); const hierarchy = new Cesium.PolygonHierarchy(positions.slice(0, -1));
const drawStartedAt = sequenceStartedAt + BORDER_START_DELAY_MS; const drawStartedAt = sequenceStartedAt + BORDER_START_DELAY_MS;
const animatedPositions = new Cesium.CallbackProperty(() => ( const animatedPositions = new Cesium.CallbackProperty(
revealPolylinePositions(positions, (now() - drawStartedAt) / BORDER_DRAW_DURATION_MS) () =>
), false); revealPolylinePositions(
positions,
(now() - drawStartedAt) / BORDER_DRAW_DURATION_MS,
),
false,
);
dataSource.entities.add({ dataSource.entities.add({
id: 'nepal-border-fill', id: 'nepal-border-fill',
show: new Cesium.CallbackProperty(() => now() >= drawStartedAt, false), show: new Cesium.CallbackProperty(() => now() >= drawStartedAt, false),
@@ -773,8 +914,12 @@ export function createBhoteKoshiLocatorLayer({
}); });
const generation = _generation; const generation = _generation;
const drawFrame = () => { const drawFrame = () => {
if (!_enabled || generation !== _generation if (
|| !presentationAnimatesBoundary(_presentation)) return; !_enabled ||
generation !== _generation ||
!presentationAnimatesBoundary(_presentation)
)
return;
requestRender('bhote-koshi-locator-border-draw'); requestRender('bhote-koshi-locator-border-draw');
if (now() - drawStartedAt < BORDER_DRAW_DURATION_MS) { if (now() - drawStartedAt < BORDER_DRAW_DURATION_MS) {
_borderAnimationFrame = scheduleFrame(drawFrame); _borderAnimationFrame = scheduleFrame(drawFrame);
@@ -797,37 +942,43 @@ export function createBhoteKoshiLocatorLayer({
} else if (_presentation === BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION) { } else if (_presentation === BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION) {
const sequenceStartedAt = now(); const sequenceStartedAt = now();
addIncidentPlaceCallouts(_dataSource, sequenceStartedAt, { addIncidentPlaceCallouts(_dataSource, sequenceStartedAt, {
pulseHalos: true, places: INCIDENT_OVERVIEW_PLACES, pulseHalos: true,
places: INCIDENT_OVERVIEW_PLACES,
}); });
_placeRevealMoving = true; _placeRevealMoving = true;
_placeRevealSettledAt = sequenceStartedAt _placeRevealSettledAt =
+ PLACE_CALLOUT_START_DELAY_MS sequenceStartedAt +
+ (INCIDENT_OVERVIEW_PLACES.length - 1) * PLACE_CALLOUT_STAGGER_MS PLACE_CALLOUT_START_DELAY_MS +
+ PLACE_CALLOUT_REVEAL_MS; (INCIDENT_OVERVIEW_PLACES.length - 1) * PLACE_CALLOUT_STAGGER_MS +
PLACE_CALLOUT_REVEAL_MS;
} else if (_presentation === BHOTE_KOSHI_FLOOD_PATH_PRESENTATION) { } else if (_presentation === BHOTE_KOSHI_FLOOD_PATH_PRESENTATION) {
const sequenceStartedAt = now(); const sequenceStartedAt = now();
const settledPlacesAt = sequenceStartedAt const settledPlacesAt =
- PLACE_CALLOUT_START_DELAY_MS sequenceStartedAt -
- (INCIDENT_OVERVIEW_PLACES.length - 1) * PLACE_CALLOUT_STAGGER_MS PLACE_CALLOUT_START_DELAY_MS -
- PLACE_CALLOUT_REVEAL_MS; (INCIDENT_OVERVIEW_PLACES.length - 1) * PLACE_CALLOUT_STAGGER_MS -
PLACE_CALLOUT_REVEAL_MS;
addIncidentFloodPath(_dataSource, sequenceStartedAt); addIncidentFloodPath(_dataSource, sequenceStartedAt);
addIncidentPlaceCallouts(_dataSource, settledPlacesAt); addIncidentPlaceCallouts(_dataSource, settledPlacesAt);
} else if (_presentation === BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION) { } else if (_presentation === BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION) {
const sequenceStartedAt = now(); const sequenceStartedAt = now();
const settledPathAt = sequenceStartedAt const settledPathAt =
- FLOOD_PATH_START_DELAY_MS sequenceStartedAt -
- FLOOD_PATH_DRAW_DURATION_MS; FLOOD_PATH_START_DELAY_MS -
const settledPlacesAt = sequenceStartedAt FLOOD_PATH_DRAW_DURATION_MS;
- PLACE_CALLOUT_START_DELAY_MS const settledPlacesAt =
- (INCIDENT_OVERVIEW_PLACES.length - 1) * PLACE_CALLOUT_STAGGER_MS sequenceStartedAt -
- PLACE_CALLOUT_REVEAL_MS; PLACE_CALLOUT_START_DELAY_MS -
(INCIDENT_OVERVIEW_PLACES.length - 1) * PLACE_CALLOUT_STAGGER_MS -
PLACE_CALLOUT_REVEAL_MS;
addIncidentFloodPath(_dataSource, settledPathAt); addIncidentFloodPath(_dataSource, settledPathAt);
addIncidentPlaceCallouts(_dataSource, settledPlacesAt); addIncidentPlaceCallouts(_dataSource, settledPlacesAt);
} else if (_presentation === BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION) { } else if (_presentation === BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION) {
const sequenceStartedAt = now(); const sequenceStartedAt = now();
const settledPathAt = sequenceStartedAt const settledPathAt =
- FLOOD_PATH_START_DELAY_MS sequenceStartedAt -
- FLOOD_PATH_DRAW_DURATION_MS; FLOOD_PATH_START_DELAY_MS -
FLOOD_PATH_DRAW_DURATION_MS;
addIncidentFloodPath(_dataSource, settledPathAt); addIncidentFloodPath(_dataSource, settledPathAt);
addTriggerRecordCallout(_dataSource); addTriggerRecordCallout(_dataSource);
armTriggerApproach(); armTriggerApproach();
@@ -841,14 +992,22 @@ export function createBhoteKoshiLocatorLayer({
return; return;
} }
if (_presentation === BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION) { if (_presentation === BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION) {
const settledAt = sequenceStartedAt - BORDER_START_DELAY_MS - BORDER_DRAW_DURATION_MS; const settledAt =
if (_boundaryRing) addNepalBoundary(_dataSource, _boundaryRing, settledAt); sequenceStartedAt - BORDER_START_DELAY_MS - BORDER_DRAW_DURATION_MS;
addIncidentCallout(_dataSource, sequenceStartedAt - CALLOUT_REVEAL_DURATION_MS); if (_boundaryRing)
addNepalBoundary(_dataSource, _boundaryRing, settledAt);
addIncidentCallout(
_dataSource,
sequenceStartedAt - CALLOUT_REVEAL_DURATION_MS,
);
addNearbyCityCallouts(_dataSource, sequenceStartedAt); addNearbyCityCallouts(_dataSource, sequenceStartedAt);
} else { } else {
if (_boundaryRing) addNepalBoundary(_dataSource, _boundaryRing, sequenceStartedAt); if (_boundaryRing)
addNepalBoundary(_dataSource, _boundaryRing, sequenceStartedAt);
const incidentDelayMs = _boundaryRing 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; : BORDER_START_DELAY_MS;
addIncidentCallout(_dataSource, sequenceStartedAt, incidentDelayMs); addIncidentCallout(_dataSource, sequenceStartedAt, incidentDelayMs);
} }
@@ -880,7 +1039,8 @@ export function createBhoteKoshiLocatorLayer({
try { try {
const boundary = await boundaryResolver(signal); 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); _boundaryRing = closeRegionRing(boundary?.ring);
_boundaryReady = _boundaryRing.length >= 4; _boundaryReady = _boundaryRing.length >= 4;
_boundaryResolved = true; _boundaryResolved = true;
@@ -897,7 +1057,10 @@ export function createBhoteKoshiLocatorLayer({
if (generation === _generation) removeDataSource(); if (generation === _generation) removeDataSource();
throw error; throw error;
} }
console.warn('[Data:BhoteKoshiLocator] Nepal boundary lookup failed:', error); console.warn(
'[Data:BhoteKoshiLocator] Nepal boundary lookup failed:',
error,
);
_boundaryResolved = true; _boundaryResolved = true;
_boundaryRing = null; _boundaryRing = null;
if (presentationUsesBoundary(_presentation)) renderPresentation(); if (presentationUsesBoundary(_presentation)) renderPresentation();
@@ -940,8 +1103,9 @@ export function createBhoteKoshiLocatorLayer({
}, },
setParams(params = {}) { setParams(params = {}) {
_presentation = normalizePresentation(params.presentation); _presentation = normalizePresentation(params.presentation);
_approachEnabled = _presentation === BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION _approachEnabled =
&& params.preview !== true; _presentation === BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION &&
params.preview !== true;
if (_enabled) renderPresentation(); if (_enabled) renderPresentation();
return true; return true;
}, },
@@ -955,35 +1119,40 @@ export function createBhoteKoshiLocatorLayer({
id: 'nepal-context', id: 'nepal-context',
label: 'NEPAL', label: 'NEPAL',
active: _presentation === NEPAL_CONTEXT_PRESENTATION, 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 }, params: { presentation: NEPAL_CONTEXT_PRESENTATION },
}, },
{ {
id: 'bhote-koshi-regional', id: 'bhote-koshi-regional',
label: 'BHOTE', label: 'BHOTE',
active: _presentation === BHOTE_KOSHI_REGIONAL_PRESENTATION, 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 }, params: { presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION },
}, },
{ {
id: 'bhote-koshi-city-context', id: 'bhote-koshi-city-context',
label: 'CITIES', label: 'CITIES',
active: _presentation === BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION, 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 }, params: { presentation: BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION },
}, },
{ {
id: 'bhote-koshi-incident-places', id: 'bhote-koshi-incident-places',
label: 'PLACES', label: 'PLACES',
active: _presentation === BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION, 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 }, params: { presentation: BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION },
}, },
{ {
id: 'bhote-koshi-flood-path', id: 'bhote-koshi-flood-path',
label: 'PATH', label: 'PATH',
active: _presentation === BHOTE_KOSHI_FLOOD_PATH_PRESENTATION, 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 }, params: { presentation: BHOTE_KOSHI_FLOOD_PATH_PRESENTATION },
}, },
{ {
@@ -997,7 +1166,8 @@ export function createBhoteKoshiLocatorLayer({
id: 'bhote-koshi-trigger-record', id: 'bhote-koshi-trigger-record',
label: 'RECORD', label: 'RECORD',
active: _presentation === BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION, 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: { params: {
presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION, presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
preview: true, preview: true,
@@ -1012,11 +1182,11 @@ export function createBhoteKoshiLocatorLayer({
count: _enabled ? 1 : 0, count: _enabled ? 1 : 0,
status: !_enabled status: !_enabled
? 'idle' ? 'idle'
: (_presentation === BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION : _presentation === BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION ||
|| _presentation === BHOTE_KOSHI_FLOOD_PATH_PRESENTATION _presentation === BHOTE_KOSHI_FLOOD_PATH_PRESENTATION ||
|| _presentation === BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION _presentation === BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION ||
|| _presentation === BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION _presentation === BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION ||
|| _boundaryReady) _boundaryReady
? 'nominal' ? 'nominal'
: 'degraded', : 'degraded',
source: 'OpenStreetMap + GeoPera', source: 'OpenStreetMap + GeoPera',
+52 -20
View File
@@ -1,26 +1,40 @@
import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js'; import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js';
export const SOURCE_PATH_BEAT_IDS = Object.freeze([ export const SOURCE_PATH_BEAT_IDS = Object.freeze([
'debris-dammed-lake', 'second-landslide', 'dhunche', 'debris-dammed-lake',
'mailung-upper-trishuli', 'mailung-bazzar', 'dandagaun', 'second-landslide',
'dandagaun-viewpoint', 'betrawati-bazaar', 'bhainse', 'bidur-trishuli-bridge', 'dhunche',
'mailung-upper-trishuli',
'mailung-bazzar',
'dandagaun',
'dandagaun-viewpoint',
'betrawati-bazaar',
'bhainse',
'bidur-trishuli-bridge',
]); ]);
function nearestVertex(point, route) { function nearestVertex(point, route) {
if (!point || !Number.isFinite(point.lon) || !Number.isFinite(point.lat)) return -1; if (!point || !Number.isFinite(point.lon) || !Number.isFinite(point.lat))
const longitudeScale = Math.cos(point.lat * Math.PI / 180); return -1;
const longitudeScale = Math.cos((point.lat * Math.PI) / 180);
let nearest = -1; let nearest = -1;
let distance = 1000; // Witness viewpoints can sit beside the river, not on it. let distance = 1000; // Witness viewpoints can sit beside the river, not on it.
route.forEach(([lon, lat], index) => { route.forEach(([lon, lat], index) => {
const metres = Math.hypot((lon - point.lon) * longitudeScale, lat - point.lat) * 111195; const metres =
if (metres < distance) { distance = metres; nearest = index; } Math.hypot((lon - point.lon) * longitudeScale, lat - point.lat) * 111195;
if (metres < distance) {
distance = metres;
nearest = index;
}
}); });
return nearest; return nearest;
} }
/** Split the already-bundled GeoPera centreline; never connect witness pins. */ /** Split the already-bundled GeoPera centreline; never connect witness pins. */
export function buildSourceShotPaths(event, route = BHOTE_KOSHI_FLOOD_PATH) { 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 result = new Map();
const add = (id, from, to, { joinComparison = false } = {}) => { const add = (id, from, to, { joinComparison = false } = {}) => {
let start = nearestVertex(from, route); 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. // downstream of it, rather than replaying the upstream half-segment.
const [lon, lat] = route[start]; const [lon, lat] = route[start];
const [nextLon, nextLat] = route[start + 1]; const [nextLon, nextLat] = route[start + 1];
const scale = Math.cos(from.lat * Math.PI / 180); const scale = Math.cos((from.lat * Math.PI) / 180);
const along = (from.lon - lon) * (nextLon - lon) * scale * scale const along =
+ (from.lat - lat) * (nextLat - lat); (from.lon - lon) * (nextLon - lon) * scale * scale +
(from.lat - lat) * (nextLat - lat);
if (along > 0) start++; if (along > 0) start++;
} }
const coordinates = route.slice(start, end + 1); 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]); coordinates.unshift([from.lon, from.lat]);
} }
result.set(id, coordinates.map(([lon, lat], index) => ({ result.set(
lon, lat, id,
// Stable source profile for explicit source-elevation shots; otherwise a coordinates.map(([lon, lat], index) => ({
// fallback when neither rendered mesh nor elevation terrain can be sampled. lon,
fallbackElevationM: startHeight + (endHeight - startHeight) * index / (coordinates.length - 1), 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(
add('second-landslide', observations.get('second-landslide'), observations.get('gyirong-border-gate')); '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); let previous = event.reconstruction?.corridor?.at(-1);
for (const id of SOURCE_PATH_BEAT_IDS.slice(2)) { for (const id of SOURCE_PATH_BEAT_IDS.slice(2)) {
const point = observations.get(id); 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. * owns stable URL ordering.
*/ */
export const LAYER_STATE_REGISTRY = Object.freeze([ export const LAYER_STATE_REGISTRY = Object.freeze([
Object.freeze({ id: 'ais-live-vessels', token: 'a', disposition: 'enabled-only' }), Object.freeze({
Object.freeze({ id: 'alpr-cameras', token: 'p', disposition: 'enabled-only' }), id: 'ais-live-vessels',
Object.freeze({ id: 'bhote-koshi-2026', token: 'h', disposition: 'enabled-only' }), token: 'a',
Object.freeze({ id: 'bhote-koshi-locator', token: 'z', disposition: 'enabled-only' }), 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: 'bikeshare', token: 'b', disposition: 'enabled-only' }),
Object.freeze({ Object.freeze({
id: 'cctv', id: 'cctv',
+64 -31
View File
@@ -504,18 +504,22 @@ export function normalizeOverlayEntry(sourceId, entry) {
: 'keyhole', : 'keyhole',
horizonCull: entry.horizonCull !== false, horizonCull: entry.horizonCull !== false,
terrainOcclusion: entry.terrainOcclusion === true, terrainOcclusion: entry.terrainOcclusion === true,
sourceAlpha: typeof (entry.sourceAlpha ?? entry.alpha) === 'function' sourceAlpha:
? (entry.sourceAlpha ?? entry.alpha) typeof (entry.sourceAlpha ?? entry.alpha) === 'function'
: clamp01(entry.sourceAlpha ?? entry.alpha, 1), ? (entry.sourceAlpha ?? entry.alpha)
presentationScale: typeof entry.presentationScale === 'function' : clamp01(entry.sourceAlpha ?? entry.alpha, 1),
? entry.presentationScale presentationScale:
: Math.max(0, Number(entry.presentationScale) || 1), typeof entry.presentationScale === 'function'
leaderProgress: typeof entry.leaderProgress === 'function' ? entry.presentationScale
? entry.leaderProgress : Math.max(0, Number(entry.presentationScale) || 1),
: clamp01(entry.leaderProgress, 1), leaderProgress:
contentAlpha: typeof entry.contentAlpha === 'function' typeof entry.leaderProgress === 'function'
? entry.contentAlpha ? entry.leaderProgress
: clamp01(entry.contentAlpha, 1), : clamp01(entry.leaderProgress, 1),
contentAlpha:
typeof entry.contentAlpha === 'function'
? entry.contentAlpha
: clamp01(entry.contentAlpha, 1),
anchorDot: entry.anchorDot === true, anchorDot: entry.anchorDot === true,
leaderStyle: entry.leaderStyle === 'elbow' ? 'elbow' : 'straight', leaderStyle: entry.leaderStyle === 'elbow' ? 'elbow' : 'straight',
temporalAlpha: clamp01(entry.temporalAlpha, 1), temporalAlpha: clamp01(entry.temporalAlpha, 1),
@@ -1805,19 +1809,36 @@ function resetFrameDomains() {
// ordinary moving-source workload at a non-inlined call boundary. // ordinary moving-source workload at a non-inlined call boundary.
function applyPresentation(entry, source, record) { function applyPresentation(entry, source, record) {
try { try {
const scale = Number(typeof entry.presentationScale === 'function' const scale = Number(
? entry.presentationScale() : entry.presentationScale); typeof entry.presentationScale === 'function'
? entry.presentationScale()
: entry.presentationScale,
);
record.paintScale *= Number.isFinite(scale) ? Math.max(0, scale) : 1; record.paintScale *= Number.isFinite(scale) ? Math.max(0, scale) : 1;
const alpha = Number(typeof entry.sourceAlpha === 'function' const alpha = Number(
? entry.sourceAlpha() : entry.sourceAlpha); typeof entry.sourceAlpha === 'function'
record.sourceAlpha = source.options.alpha ? entry.sourceAlpha()
* (Number.isFinite(alpha) ? Math.max(0, Math.min(1, alpha)) : 1); : entry.sourceAlpha,
const leader = Number(typeof entry.leaderProgress === 'function' );
? entry.leaderProgress() : entry.leaderProgress); record.sourceAlpha =
record.leaderProgress = Number.isFinite(leader) ? Math.max(0, Math.min(1, leader)) : 1; source.options.alpha *
const content = Number(typeof entry.contentAlpha === 'function' (Number.isFinite(alpha) ? Math.max(0, Math.min(1, alpha)) : 1);
? entry.contentAlpha() : entry.contentAlpha); const leader = Number(
record.contentAlpha = Number.isFinite(content) ? Math.max(0, Math.min(1, content)) : 1; 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; return true;
} catch { } catch {
return false; return false;
@@ -1942,19 +1963,31 @@ function snapshotAndProject(entry, source, viewProjection, keyhole) {
} }
record.paintScale *= altitudeFactor; record.paintScale *= altitudeFactor;
} }
record.altitudeAlpha = entry.altitudeFadeEnd === Number.POSITIVE_INFINITY record.altitudeAlpha =
? (Number.isFinite(cameraAltitude) && cameraAltitude < entry.minAltitude ? 0 : 1) entry.altitudeFadeEnd === Number.POSITIVE_INFINITY
: altitudeFade(cameraAltitude, record.altitudeOptions); ? Number.isFinite(cameraAltitude) && cameraAltitude < entry.minAltitude
? 0
: 1
: altitudeFade(cameraAltitude, record.altitudeOptions);
record.leaderProgress = 1; record.leaderProgress = 1;
record.contentAlpha = 1; record.contentAlpha = 1;
if (entry.presentationScale !== 1 || entry.leaderProgress !== 1 if (
|| entry.contentAlpha !== 1 || typeof entry.sourceAlpha === 'function') { entry.presentationScale !== 1 ||
entry.leaderProgress !== 1 ||
entry.contentAlpha !== 1 ||
typeof entry.sourceAlpha === 'function'
) {
if (!applyPresentation(entry, source, record)) return null; if (!applyPresentation(entry, source, record)) return null;
} else { } else {
record.sourceAlpha = source.options.alpha * entry.sourceAlpha; record.sourceAlpha = source.options.alpha * entry.sourceAlpha;
} }
if (record.distanceAlpha <= 0 || record.paintScale <= 0 if (
|| record.altitudeAlpha <= 0 || record.sourceAlpha <= 0) return null; record.distanceAlpha <= 0 ||
record.paintScale <= 0 ||
record.altitudeAlpha <= 0 ||
record.sourceAlpha <= 0
)
return null;
measureOverlayEntry(_ctx, entry, record.layout); measureOverlayEntry(_ctx, entry, record.layout);
record.placementInput.anchorX = record.screen.x; record.placementInput.anchorX = record.screen.x;
+43 -16
View File
@@ -572,13 +572,11 @@ function drawLeader(
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(startX, startY); ctx.moveTo(startX, startY);
if ( if (
style === 'elbow' style === 'elbow' &&
&& (placement.corner === 'left' || placement.corner === 'right') (placement.corner === 'left' || placement.corner === 'right')
) { ) {
const elbowY = placement.rect.y + Math.min( const elbowY =
Math.max(12, placement.rect.h * 0.12), placement.rect.y + Math.min(Math.max(12, placement.rect.h * 0.12), 26);
26,
);
const verticalLength = Math.abs(elbowY - startY); const verticalLength = Math.abs(elbowY - startY);
const horizontalLength = Math.abs(placement.leadToX - startX); const horizontalLength = Math.abs(placement.leadToX - startX);
const totalLength = verticalLength + horizontalLength; const totalLength = verticalLength + horizontalLength;
@@ -590,7 +588,10 @@ function drawLeader(
ctx.lineTo(startX, elbowY); ctx.lineTo(startX, elbowY);
const remaining = Math.max(0, visibleLength - verticalLength); const remaining = Math.max(0, visibleLength - verticalLength);
const direction = Math.sign(placement.leadToX - startX); 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(); ctx.stroke();
return; return;
@@ -615,10 +616,17 @@ function drawAnchorDot(ctx, entry, placement) {
ctx.stroke(); 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 { x, y, w, h } = placement.rect;
const accent = entry.accent || WORLD_OVERLAY_STYLE.accent; 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(); ctx.beginPath();
roundedRectPath(ctx, x, y, w, h); roundedRectPath(ctx, x, y, w, h);
ctx.fillStyle = selected ctx.fillStyle = selected
@@ -893,7 +901,8 @@ export function paintCard(
entry.leaderStyle, entry.leaderStyle,
); );
drawAnchorDot(ctx, entry, placement); 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); drawCardChrome(ctx, entry, placement, false, false);
} else { } else {
drawCardChrome(ctx, entry, placement, false); drawCardChrome(ctx, entry, placement, false);
@@ -1112,15 +1121,33 @@ export function paintOverlayEntry(
leaderProgress = 1, leaderProgress = 1,
contentAlpha = 1, contentAlpha = 1,
) { ) {
if (entry.cardStyle === 'tactical') return paintTacticalCard(ctx, entry, placement, alpha); if (entry.cardStyle === 'tactical')
if (entry.variant === 'tracked') return paintTracked(ctx, entry, placement, alpha); return paintTacticalCard(ctx, entry, placement, alpha);
if (entry.selected || entry.variant === 'selected') return paintSelected(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') { if (entry.variant === 'thumbnail') {
return paintThumbnail(ctx, entry, placement, alpha, leaderProgress, contentAlpha); return paintThumbnail(
ctx,
entry,
placement,
alpha,
leaderProgress,
contentAlpha,
);
} }
if (entry.variant === 'card') { 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); 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, alt: 10145,
heading: 224, heading: 224,
pitch: -38, pitch: -38,
hold: 11 hold: 11,
}, },
{ {
id: 'dhunche', id: 'dhunche',
@@ -23,7 +23,7 @@ const ADDITIONAL_SHOTS = [
alt: 6212, alt: 6212,
heading: 208, heading: 208,
pitch: -39, pitch: -39,
hold: 16 hold: 16,
}, },
{ {
id: 'mailung-upper-trishuli', id: 'mailung-upper-trishuli',
@@ -33,7 +33,7 @@ const ADDITIONAL_SHOTS = [
alt: 6353, alt: 6353,
heading: 211, heading: 211,
pitch: -40, pitch: -40,
hold: 13 hold: 13,
}, },
{ {
id: 'mailung-bazzar', id: 'mailung-bazzar',
@@ -43,7 +43,7 @@ const ADDITIONAL_SHOTS = [
alt: 6331, alt: 6331,
heading: 212, heading: 212,
pitch: -40, pitch: -40,
hold: 15 hold: 15,
}, },
{ {
id: 'dandagaun', id: 'dandagaun',
@@ -53,7 +53,7 @@ const ADDITIONAL_SHOTS = [
alt: 6311, alt: 6311,
heading: 214, heading: 214,
pitch: -40, pitch: -40,
hold: 15 hold: 15,
}, },
{ {
id: 'dandagaun-viewpoint', id: 'dandagaun-viewpoint',
@@ -63,7 +63,7 @@ const ADDITIONAL_SHOTS = [
alt: 6740, alt: 6740,
heading: 215, heading: 215,
pitch: -41, pitch: -41,
hold: 13 hold: 13,
}, },
{ {
id: 'betrawati-bazaar', id: 'betrawati-bazaar',
@@ -73,7 +73,7 @@ const ADDITIONAL_SHOTS = [
alt: 7034, alt: 7034,
heading: 216, heading: 216,
pitch: -41, pitch: -41,
hold: 12 hold: 12,
}, },
{ {
id: 'bhainse', id: 'bhainse',
@@ -83,7 +83,7 @@ const ADDITIONAL_SHOTS = [
alt: 8051, alt: 8051,
heading: 219, heading: 219,
pitch: -41, pitch: -41,
hold: 13 hold: 13,
}, },
{ {
id: 'devighat-taadi-khola-bridge', id: 'devighat-taadi-khola-bridge',
@@ -93,7 +93,7 @@ const ADDITIONAL_SHOTS = [
alt: 9056, alt: 9056,
heading: 224, heading: 224,
pitch: -42, pitch: -42,
hold: 13 hold: 13,
}, },
{ {
id: 'charaudi', id: 'charaudi',
@@ -103,7 +103,7 @@ const ADDITIONAL_SHOTS = [
alt: 10347, alt: 10347,
heading: 232, heading: 232,
pitch: -42, pitch: -42,
hold: 11 hold: 11,
}, },
{ {
id: 'final-view', id: 'final-view',
@@ -116,8 +116,8 @@ const ADDITIONAL_SHOTS = [
roll: 174, roll: 174,
duration: 4, duration: 4,
hold: 11, hold: 11,
locatorPresentation: 'bhote-koshi-incident-places' locatorPresentation: 'bhote-koshi-incident-places',
} },
]; ];
const EVIDENCE_ORDER = [ const EVIDENCE_ORDER = [
'immediate-collapse-viewpoint', 'immediate-collapse-viewpoint',
@@ -136,7 +136,7 @@ const EVIDENCE_ORDER = [
'bidur-trishuli-bridge', 'bidur-trishuli-bridge',
'devighat-taadi-khola-bridge', 'devighat-taadi-khola-bridge',
'charaudi', 'charaudi',
'final-view' 'final-view',
]; ];
// Per-reach motion timing keeps the schematic front ahead of the fixed 4.2s // 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, 'second-landslide': 6.5,
'timure-cluster': 2.1, 'timure-cluster': 2.1,
'syabru-besi': 2.8, 'syabru-besi': 2.8,
'dhunche': 2.5, dhunche: 2.5,
'mailung-upper-trishuli': 2.9, 'mailung-upper-trishuli': 2.9,
'mailung-bazzar': 2.1, 'mailung-bazzar': 2.1,
'dandagaun': 1.2, dandagaun: 1.2,
'dandagaun-viewpoint': 2.5, 'dandagaun-viewpoint': 2.5,
'betrawati-bazaar': 2.9, 'betrawati-bazaar': 2.9,
'bhainse': 1.5, bhainse: 1.5,
'bidur-trishuli-bridge': 2.6, 'bidur-trishuli-bridge': 2.6,
}); });
/** Extend the six-beat Nepal pack without replacing existing authored shots. */ /** Extend the six-beat Nepal pack without replacing existing authored shots. */
export function expandNepalEvidencePack(base) { export function expandNepalEvidencePack(base) {
const layerId = base.requiredSourcePackLayerId; const layerId = base.requiredSourcePackLayerId;
const extraShots = ADDITIONAL_SHOTS.map(({ const extraShots = ADDITIONAL_SHOTS.map(
id, ({ id, hold, duration = 4.2, locatorPresentation, ...camera }) => ({
hold, ...camera,
duration = 4.2, roll: Number.isFinite(Number(camera.roll)) ? Number(camera.roll) : 0,
locatorPresentation, duration,
...camera hold,
}) => ({ mapStack: 'photoreal',
...camera, layers: {
roll: Number.isFinite(Number(camera.roll)) ? Number(camera.roll) : 0, [layerId]: {
duration,
hold,
mapStack: 'photoreal',
layers: {
[layerId]: { enabled: true, params: {
presentation: 'scene-beat',
beatId: id,
beatReveal: 0.36,
split: 0.5,
} },
...(locatorPresentation ? {
'bhote-koshi-locator': {
enabled: true, enabled: true,
params: { presentation: locatorPresentation }, params: {
presentation: 'scene-beat',
beatId: id,
beatReveal: 0.36,
split: 0.5,
},
}, },
} : {}), ...(locatorPresentation
}, ? {
})); 'bhote-koshi-locator': {
const byBeat = new Map([...base.cameraPath, ...extraShots].map((shot) => [ enabled: true,
shot.layers[layerId].params.beatId, shot, 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 cameraPath = EVIDENCE_ORDER.map((id) => byBeat.get(id));
const runtimeControlsByBeat = { ...base.runtimeControlsByBeat }; const runtimeControlsByBeat = { ...base.runtimeControlsByBeat };
const cameraLedPathBeats = new Set([ const cameraLedPathBeats = new Set([
'timure-cluster', 'syabru-besi', ...SOURCE_PATH_BEAT_IDS.slice(2), 'timure-cluster',
]); 'syabru-besi',
const mediaLedPathBeats = new Set([ ...SOURCE_PATH_BEAT_IDS.slice(2),
'debris-dammed-lake', 'second-landslide',
]); ]);
const mediaLedPathBeats = new Set(['debris-dammed-lake', 'second-landslide']);
const finalSourcedPathBeatId = SOURCE_PATH_BEAT_IDS.at(-1); const finalSourcedPathBeatId = SOURCE_PATH_BEAT_IDS.at(-1);
const postSourceHistoryBeats = new Set([ const postSourceHistoryBeats = new Set([
'devighat-taadi-khola-bridge', 'charaudi', 'final-view', 'devighat-taadi-khola-bridge',
'charaudi',
'final-view',
]); ]);
for (const shot of cameraPath) { for (const shot of cameraPath) {
const id = shot.layers[layerId].params.beatId; const id = shot.layers[layerId].params.beatId;
@@ -209,33 +215,43 @@ export function expandNepalEvidencePack(base) {
runtimeControlsByBeat[id] = { runtimeControlsByBeat[id] = {
...runtimeControlsByBeat[id], ...runtimeControlsByBeat[id],
evidenceMediaAutoplay: !isFinalView, evidenceMediaAutoplay: !isFinalView,
...(isFinalView ? {} : { ...(isFinalView
mediaPlaybackHoldSec: id === 'mailung-bazzar' ? 7 : 6, ? {}
mediaExitDurationSec: 0.65, : {
}), mediaPlaybackHoldSec: id === 'mailung-bazzar' ? 7 : 6,
mediaExitDurationSec: 0.65,
}),
deferEvidenceUntilCameraSettled: !isFinalView, deferEvidenceUntilCameraSettled: !isFinalView,
evidenceRevealDurationSec: 0.9, evidenceRevealDurationSec: 0.9,
evidencePath: SOURCE_PATH_BEAT_IDS.includes(id) ? 'source' evidencePath: SOURCE_PATH_BEAT_IDS.includes(id)
: ['gyirong-border-gate', 'timure-cluster', 'syabru-besi'].includes(id) ? 'comparison' : 'none', ? 'source'
...(cameraLedPathBeats.has(id) ? { : ['gyirong-border-gate', 'timure-cluster', 'syabru-besi'].includes(id)
evidencePathDuringCamera: true, ? 'comparison'
evidencePathPersistent: true, : 'none',
...(authoredPathDurationSec ...(cameraLedPathBeats.has(id)
? { evidencePathTravelDurationSec: authoredPathDurationSec } ? {
: {}), evidencePathDuringCamera: true,
} : {}), evidencePathPersistent: true,
...(mediaLedPathBeats.has(id) ? { ...(authoredPathDurationSec
evidencePathDuringMedia: true, ? { evidencePathTravelDurationSec: authoredPathDurationSec }
evidencePathPersistent: true, : {}),
evidencePathStartDelaySec: 0, }
} : {}), : {}),
...(mediaLedPathBeats.has(id)
? {
evidencePathDuringMedia: true,
evidencePathPersistent: true,
evidencePathStartDelaySec: 0,
}
: {}),
}; };
if (SOURCE_PATH_BEAT_IDS.includes(id)) { if (SOURCE_PATH_BEAT_IDS.includes(id)) {
runtimeControlsByBeat[id] = { runtimeControlsByBeat[id] = {
...runtimeControlsByBeat[id], ...runtimeControlsByBeat[id],
evidenceHoldCard: true, evidenceHoldCard: true,
evidencePathHistory: true, evidencePathHistory: true,
evidencePathDurationSec: authoredPathDurationSec || Math.max(4, shot.hold), evidencePathDurationSec:
authoredPathDurationSec || Math.max(4, shot.hold),
minimumHoldSec: Math.max(4, shot.hold), minimumHoldSec: Math.max(4, shot.hold),
}; };
} }
@@ -256,33 +272,40 @@ export function expandNepalEvidencePack(base) {
evidencePathElevation: 'source', evidencePathElevation: 'source',
}; };
} }
const pathDurationSec = Number(runtimeControlsByBeat[id].evidencePathDurationSec) const pathDurationSec =
|| (!cameraLedPathBeats.has(id) && runtimeControlsByBeat[id].evidenceSequence === true Number(runtimeControlsByBeat[id].evidencePathDurationSec) ||
(!cameraLedPathBeats.has(id) &&
runtimeControlsByBeat[id].evidenceSequence === true
? Number(runtimeControlsByBeat[id].evidenceSequenceDurationSec) || 0 ? Number(runtimeControlsByBeat[id].evidenceSequenceDurationSec) || 0
: 0); : 0);
const mediaPresentationSec = isFinalView const mediaPresentationSec = isFinalView
? 0 ? 0
: (id === 'mailung-bazzar' ? 7 : 6) + 0.65; : (id === 'mailung-bazzar' ? 7 : 6) + 0.65;
const mediaAndPathHoldSec = pathDurationSec > 0 const mediaAndPathHoldSec =
&& !cameraLedPathBeats.has(id) pathDurationSec > 0 && !cameraLedPathBeats.has(id)
? mediaLedPathBeats.has(id) ? mediaLedPathBeats.has(id)
? Math.max(mediaPresentationSec, pathDurationSec) ? Math.max(mediaPresentationSec, pathDurationSec)
: mediaPresentationSec + pathDurationSec : mediaPresentationSec + pathDurationSec
: mediaPresentationSec; : mediaPresentationSec;
runtimeControlsByBeat[id] = { runtimeControlsByBeat[id] = {
...runtimeControlsByBeat[id], ...runtimeControlsByBeat[id],
minimumHoldSec: id === 'mailung-bazzar' ? mediaAndPathHoldSec : Math.max( minimumHoldSec:
Number(runtimeControlsByBeat[id].minimumHoldSec) || 0, id === 'mailung-bazzar'
mediaAndPathHoldSec, ? mediaAndPathHoldSec
), : Math.max(
Number(runtimeControlsByBeat[id].minimumHoldSec) || 0,
mediaAndPathHoldSec,
),
}; };
} }
// Keep the downstream sequence on its sourced river profile. Coarse Google // Keep the downstream sequence on its sourced river profile. Coarse Google
// mesh samples can contain cliff-height spikes that reverse apparent motion. // mesh samples can contain cliff-height spikes that reverse apparent motion.
// Preserve each authored readable hold after the media-first timing floor. // Preserve each authored readable hold after the media-first timing floor.
for (const [id, seconds, sequenceSeconds = seconds] of [ for (const [id, seconds, sequenceSeconds = seconds] of [
['immediate-collapse-viewpoint', 10], ['gyirong-border-gate', 8, 2.5], ['immediate-collapse-viewpoint', 10],
['timure-cluster', 12], ['syabru-besi', 11], ['gyirong-border-gate', 8, 2.5],
['timure-cluster', 12],
['syabru-besi', 11],
]) { ]) {
runtimeControlsByBeat[id] = { runtimeControlsByBeat[id] = {
...runtimeControlsByBeat[id], ...runtimeControlsByBeat[id],
@@ -296,7 +319,10 @@ export function expandNepalEvidencePack(base) {
} }
const finalView = byBeat.get('final-view'); const finalView = byBeat.get('final-view');
return Object.freeze({ 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. // Clip trims must also replace older saved holds, not just lower a floor.
runtimeHoldSecByBeat: { 'mailung-bazzar': 7 + 0.65 }, runtimeHoldSecByBeat: { 'mailung-bazzar': 7 + 0.65 },
expansionFromVersion: 17, expansionFromVersion: 17,
+492 -492
View File
File diff suppressed because it is too large. Load diff