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