mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
feat: add Nepal flood incident scene
This commit is contained in:
1 parent
76c20be277
commit
60768ccc0b
48 files changed
+15431
-161
No files matched your search
@@ -0,0 +1,912 @@
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
const YOUTUBE_HOSTS = new Set([
|
||||
'youtube.com',
|
||||
'www.youtube.com',
|
||||
'm.youtube.com',
|
||||
'youtube-nocookie.com',
|
||||
'www.youtube-nocookie.com',
|
||||
'youtu.be',
|
||||
]);
|
||||
const FACEBOOK_HOSTS = new Set([
|
||||
'facebook.com',
|
||||
'www.facebook.com',
|
||||
'm.facebook.com',
|
||||
'web.facebook.com',
|
||||
'fb.watch',
|
||||
'www.fb.watch',
|
||||
]);
|
||||
const X_HOSTS = new Set([
|
||||
'x.com',
|
||||
'www.x.com',
|
||||
'mobile.x.com',
|
||||
'twitter.com',
|
||||
'www.twitter.com',
|
||||
'mobile.twitter.com',
|
||||
]);
|
||||
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 EMBED_TIMEOUT_MS = 15000;
|
||||
const CARD_EXIT_DURATION_MS = 620;
|
||||
const MEDIA_PRECONNECT_ORIGINS = Object.freeze({
|
||||
youtube: Object.freeze([
|
||||
'https://www.youtube-nocookie.com',
|
||||
'https://www.youtube.com',
|
||||
'https://i.ytimg.com',
|
||||
]),
|
||||
facebook: Object.freeze(['https://www.facebook.com']),
|
||||
});
|
||||
|
||||
let _xWidgetsPromise = null;
|
||||
let _facebookSdkPromise = null;
|
||||
let _facebookEmbedSequence = 0;
|
||||
const youtubeApis = new WeakMap();
|
||||
|
||||
function loadYouTubeApi(documentRef, globalRef) {
|
||||
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;
|
||||
let timer;
|
||||
let settled = false;
|
||||
const script = documentRef.createElement('script');
|
||||
const finish = (error) => {
|
||||
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);
|
||||
};
|
||||
const ready = () => {
|
||||
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);
|
||||
documentRef.head.append(script);
|
||||
}).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 = () => {},
|
||||
}) {
|
||||
let player = null;
|
||||
let phase = 'starting';
|
||||
let active = true;
|
||||
let started = false;
|
||||
let currentTime = startAtSec;
|
||||
let poll = null;
|
||||
let deadline = null;
|
||||
const live = () => active && isCurrent() && ['starting', 'playing'].includes(phase);
|
||||
const clear = () => {
|
||||
globalRef.clearTimeout(deadline);
|
||||
globalRef.clearTimeout(poll);
|
||||
deadline = poll = null;
|
||||
};
|
||||
const finish = (outcome) => {
|
||||
if (!live()) return;
|
||||
phase = outcome;
|
||||
clear();
|
||||
try { player?.pauseVideo?.(); } catch { /* Removed provider frame. */ }
|
||||
onStatus(outcome);
|
||||
};
|
||||
const sample = (stateHint) => {
|
||||
if (!live()) return;
|
||||
globalRef.clearTimeout(poll);
|
||||
poll = null;
|
||||
try {
|
||||
const previousTime = currentTime;
|
||||
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 (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);
|
||||
}
|
||||
} 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'));
|
||||
return {
|
||||
getState: () => ({ phase, currentTime }),
|
||||
destroy() {
|
||||
active = false;
|
||||
phase = 'cancelled';
|
||||
clear();
|
||||
try { player?.destroy?.(); } catch { /* Detached frame already released. */ }
|
||||
player = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function facebookSdkReady(api) {
|
||||
return typeof api?.XFBML?.parse === 'function'
|
||||
&& typeof api?.Event?.subscribe === 'function';
|
||||
}
|
||||
|
||||
function loadFacebookSdk(documentRef, globalRef = globalThis) {
|
||||
if (facebookSdkReady(globalRef.FB)) return Promise.resolve(globalRef.FB);
|
||||
if (_facebookSdkPromise) return _facebookSdkPromise;
|
||||
_facebookSdkPromise = new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
let timer = null;
|
||||
const prior = globalRef.fbAsyncInit;
|
||||
const finish = (error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
globalRef.clearTimeout?.(timer);
|
||||
if (globalRef.fbAsyncInit === ready) globalRef.fbAsyncInit = prior;
|
||||
if (error) reject(error);
|
||||
else resolve(globalRef.FB);
|
||||
};
|
||||
const ready = () => {
|
||||
try {
|
||||
prior?.();
|
||||
} finally {
|
||||
if (facebookSdkReady(globalRef.FB)) finish();
|
||||
else finish(new Error('Facebook player API did not initialize.'));
|
||||
}
|
||||
};
|
||||
globalRef.fbAsyncInit = ready;
|
||||
let script = documentRef.getElementById?.(FACEBOOK_SDK_ID);
|
||||
if (!script) {
|
||||
script = documentRef.createElement('script');
|
||||
script.id = FACEBOOK_SDK_ID;
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.crossOrigin = 'anonymous';
|
||||
script.src = FACEBOOK_SDK_URL;
|
||||
documentRef.head?.append(script);
|
||||
}
|
||||
script.addEventListener?.(
|
||||
'error',
|
||||
() => 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;
|
||||
}).catch((error) => {
|
||||
_facebookSdkPromise = null;
|
||||
throw error;
|
||||
});
|
||||
return _facebookSdkPromise;
|
||||
}
|
||||
|
||||
/** Accept a public HTTPS media URL without allowing iframe markup or credentials. */
|
||||
export function publicEmbeddedMediaUrl(value) {
|
||||
if (typeof value !== 'string' || value.length > 4096) return null;
|
||||
let url;
|
||||
try {
|
||||
url = new URL(value.trim());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
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;
|
||||
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 (url.pathname === '/watch') return url.searchParams.get('v');
|
||||
return /^\/(?:embed|shorts)\/([\w-]{11})\/?$/.exec(url.pathname)?.[1] || null;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** Resolve only providers that can render inside a Bhote Koshi callout. */
|
||||
export function resolveEmbeddedMediaSource(value) {
|
||||
const url = publicEmbeddedMediaUrl(value);
|
||||
if (!url) return null;
|
||||
const youtubeId = youtubeVideoId(url);
|
||||
if (youtubeId && /^[\w-]{11}$/.test(youtubeId)) {
|
||||
return {
|
||||
provider: 'youtube',
|
||||
id: youtubeId,
|
||||
url: `https://www.youtube.com/watch?v=${youtubeId}`,
|
||||
};
|
||||
}
|
||||
if (isFacebookVideoUrl(url)) {
|
||||
return { provider: 'facebook', url: url.href };
|
||||
}
|
||||
const postId = xPostId(url);
|
||||
if (postId) {
|
||||
return { provider: 'x', id: postId, url: url.href };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Provider-owned iframe URL for YouTube or Facebook callouts. */
|
||||
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');
|
||||
url.searchParams.set('rel', '0');
|
||||
const startSeconds = Math.max(0, Math.floor(Number(startAtSec) || 0));
|
||||
if (startSeconds > 0) url.searchParams.set('start', String(startSeconds));
|
||||
const endSeconds = Math.floor(Number(endAtSec));
|
||||
if (Number.isFinite(endSeconds) && endSeconds > startSeconds) {
|
||||
url.searchParams.set('end', String(endSeconds));
|
||||
}
|
||||
if (autoplay) {
|
||||
url.searchParams.set('autoplay', '1');
|
||||
url.searchParams.set('mute', '1');
|
||||
}
|
||||
return url.href;
|
||||
}
|
||||
if (source?.provider === 'facebook') {
|
||||
const url = new URL('https://www.facebook.com/plugins/video.php');
|
||||
url.searchParams.set('href', source.url);
|
||||
url.searchParams.set('show_text', 'false');
|
||||
url.searchParams.set('width', '500');
|
||||
url.searchParams.set('autoplay', autoplay ? 'true' : 'false');
|
||||
url.searchParams.set('mute', autoplay ? 'true' : 'false');
|
||||
return url.href;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function xWidgetsReady(api) {
|
||||
return typeof api?.widgets?.createTweet === 'function';
|
||||
}
|
||||
|
||||
function loadXWidgets(documentRef, globalRef = globalThis) {
|
||||
if (xWidgetsReady(globalRef.twttr)) return Promise.resolve(globalRef.twttr);
|
||||
if (_xWidgetsPromise) return _xWidgetsPromise;
|
||||
_xWidgetsPromise = new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
let timer = null;
|
||||
const finish = (error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (error) reject(error);
|
||||
else resolve(globalRef.twttr);
|
||||
};
|
||||
const ready = () => {
|
||||
if (xWidgetsReady(globalRef.twttr)) finish();
|
||||
else finish(new Error('X embed API did not initialize.'));
|
||||
};
|
||||
let script = documentRef.querySelector?.(`script[src="${X_WIDGET_URL}"]`);
|
||||
if (!script) {
|
||||
script = documentRef.createElement('script');
|
||||
script.src = X_WIDGET_URL;
|
||||
script.async = true;
|
||||
script.charset = 'utf-8';
|
||||
script.dataset.gevXWidgets = 'true';
|
||||
documentRef.head?.append(script);
|
||||
}
|
||||
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);
|
||||
else ready();
|
||||
};
|
||||
script.addEventListener?.('load', waitForApi, { once: true });
|
||||
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);
|
||||
}).catch((error) => {
|
||||
_xWidgetsPromise = null;
|
||||
throw error;
|
||||
});
|
||||
return _xWidgetsPromise;
|
||||
}
|
||||
|
||||
function providerLabel(provider) {
|
||||
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);
|
||||
}
|
||||
|
||||
function ensureProviderConnections(documentRef, provider) {
|
||||
for (const origin of MEDIA_PRECONNECT_ORIGINS[provider] || []) {
|
||||
if (documentRef.querySelector?.(`link[data-gev-media-preconnect="${origin}"]`)) continue;
|
||||
const link = documentRef.createElement('link');
|
||||
link.rel = 'preconnect';
|
||||
link.href = origin;
|
||||
link.crossOrigin = 'anonymous';
|
||||
link.dataset.gevMediaPreconnect = origin;
|
||||
documentRef.head?.append(link);
|
||||
}
|
||||
}
|
||||
|
||||
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());
|
||||
if (Cesium.Cartesian3.dot(delta, camera.directionWC) <= 0) return null;
|
||||
}
|
||||
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;
|
||||
return { x, y, width: rootBounds.width, height: rootBounds.height };
|
||||
}
|
||||
|
||||
function placeCard(card, leader, anchor) {
|
||||
const margin = 18;
|
||||
const gap = 42;
|
||||
const width = card.offsetWidth || 392;
|
||||
const height = card.offsetHeight || 360;
|
||||
const rootBounds = card.parentElement?.getBoundingClientRect?.();
|
||||
const documentRef = card.ownerDocument;
|
||||
let laneLeft = margin;
|
||||
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,
|
||||
);
|
||||
if (laneRight - laneLeft < width) {
|
||||
laneLeft = margin;
|
||||
laneRight = anchor.width - margin;
|
||||
}
|
||||
}
|
||||
let left = anchor.x + gap;
|
||||
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 top = Math.max(margin, Math.min(laneBottom - height, desiredTop));
|
||||
card.style.left = `${left}px`;
|
||||
card.style.top = `${top}px`;
|
||||
const localX = anchor.x - left;
|
||||
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}`);
|
||||
const dot = leader.querySelector('circle');
|
||||
dot?.setAttribute('cx', String(localX));
|
||||
dot?.setAttribute('cy', String(localY));
|
||||
}
|
||||
|
||||
function createRoot(documentRef, viewer) {
|
||||
const root = documentRef.createElement('div');
|
||||
root.id = 'bhote-koshi-embedded-media-root';
|
||||
root.setAttribute('aria-live', 'polite');
|
||||
(viewer?.container || documentRef.body)?.append(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one provider-owned player/post in a geographically anchored DOM card.
|
||||
* YouTube and Facebook sources use their provider player; approved local clips
|
||||
* remain available to bhoteKoshiEvent as a fallback when no embed is supported.
|
||||
*/
|
||||
export function createBhoteKoshiEmbeddedMedia({
|
||||
viewer,
|
||||
documentRef = globalThis.document,
|
||||
globalRef = globalThis,
|
||||
xLoader = loadXWidgets,
|
||||
facebookLoader = loadFacebookSdk,
|
||||
youtubeLoader = loadYouTubeApi,
|
||||
} = {}) {
|
||||
if (!documentRef?.createElement || !viewer) {
|
||||
return {
|
||||
warm: () => false,
|
||||
show: () => false,
|
||||
play: () => false,
|
||||
pause: () => false,
|
||||
hide: () => {},
|
||||
destroy: () => {},
|
||||
};
|
||||
}
|
||||
const root = createRoot(documentRef, viewer);
|
||||
let record = null;
|
||||
let warmRecord = null;
|
||||
let generation = 0;
|
||||
const leavingRecords = new Set();
|
||||
|
||||
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) };
|
||||
}
|
||||
if (typeof globalRef.setTimeout === 'function') {
|
||||
return { kind: 'timer', id: globalRef.setTimeout(callback, 0) };
|
||||
}
|
||||
callback();
|
||||
return null;
|
||||
};
|
||||
const cancelScheduledFrame = (scheduled) => {
|
||||
if (!scheduled) return;
|
||||
if (scheduled.kind === 'frame') globalRef.cancelAnimationFrame?.(scheduled.id);
|
||||
else globalRef.clearTimeout?.(scheduled.id);
|
||||
};
|
||||
|
||||
function unsubscribeFacebook(session) {
|
||||
if (!session?.readyListener) return;
|
||||
try {
|
||||
session.api?.Event?.unsubscribe?.('xfbml.ready', session.readyListener);
|
||||
} catch {
|
||||
// Provider teardown is best effort.
|
||||
}
|
||||
session.readyListener = null;
|
||||
}
|
||||
|
||||
function setFacebookSessionPlayback(session, playing) {
|
||||
if (!session?.active) return false;
|
||||
session.shouldPlay = playing === true;
|
||||
if (!session.player) return false;
|
||||
try {
|
||||
if (session.shouldPlay) {
|
||||
session.player.mute?.();
|
||||
const result = session.player.play?.();
|
||||
result?.catch?.(() => {
|
||||
if (!session.active || !session.shouldPlay) return;
|
||||
session.onPlaybackBlocked?.();
|
||||
});
|
||||
} else {
|
||||
session.player.pause?.();
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
if (session.shouldPlay) session.onPlaybackBlocked?.();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function destroyFacebookSession(session) {
|
||||
if (!session) return;
|
||||
session.shouldPlay = false;
|
||||
globalRef.clearTimeout?.(session.timer);
|
||||
session.timer = null;
|
||||
try {
|
||||
session.player?.pause?.();
|
||||
} catch {
|
||||
// Provider teardown is best effort.
|
||||
}
|
||||
session.active = false;
|
||||
unsubscribeFacebook(session);
|
||||
session.host?.remove?.();
|
||||
session.player = null;
|
||||
}
|
||||
|
||||
function createFacebookSession(source, host) {
|
||||
const session = {
|
||||
active: true,
|
||||
api: null,
|
||||
embed: null,
|
||||
host,
|
||||
player: null,
|
||||
ready: false,
|
||||
error: null,
|
||||
readyListener: null,
|
||||
shouldPlay: false,
|
||||
timer: null,
|
||||
onReady: null,
|
||||
onError: null,
|
||||
onPlaybackBlocked: null,
|
||||
};
|
||||
const embed = documentRef.createElement('div');
|
||||
embed.id = `bhote-koshi-facebook-${++_facebookEmbedSequence}`;
|
||||
embed.className = 'fb-video';
|
||||
embed.dataset.href = source.url;
|
||||
embed.dataset.width = '500';
|
||||
embed.dataset.showText = 'false';
|
||||
embed.dataset.allowfullscreen = 'true';
|
||||
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);
|
||||
});
|
||||
return session;
|
||||
}
|
||||
|
||||
function removeRecord(current) {
|
||||
if (!current) return;
|
||||
cancelScheduledFrame(current.enterFrame);
|
||||
if (current.exitTimer != null) globalRef.clearTimeout?.(current.exitTimer);
|
||||
destroyFacebookSession(current.facebookSession);
|
||||
current.youtubeSession?.destroy();
|
||||
current.card?.remove?.();
|
||||
leavingRecords.delete(current);
|
||||
}
|
||||
|
||||
const position = () => {
|
||||
if (!record) return;
|
||||
const projected = anchorOnScreen(viewer, record.anchor, root);
|
||||
record.card.hidden = !projected;
|
||||
if (projected) {
|
||||
placeCard(record.card, record.leader, projected);
|
||||
if (!record.entered) {
|
||||
record.entered = true;
|
||||
const current = record;
|
||||
const token = generation;
|
||||
current.enterFrame = scheduleFrame(() => {
|
||||
current.enterFrame = null;
|
||||
if (token !== generation || record !== current) return;
|
||||
current.card.classList.add('is-entering');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
const removePostRender = viewer.scene?.postRender?.addEventListener?.(position);
|
||||
|
||||
function hide({ immediate = false } = {}) {
|
||||
generation += 1;
|
||||
const current = record;
|
||||
record = null;
|
||||
if (!current) return;
|
||||
current.youtubeSession?.destroy();
|
||||
setFacebookSessionPlayback(current.facebookSession, false);
|
||||
cancelScheduledFrame(current.enterFrame);
|
||||
current.enterFrame = null;
|
||||
if (immediate || current.card.hidden || prefersReducedMotion()) {
|
||||
removeRecord(current);
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
function clearWarm(key = null) {
|
||||
if (!warmRecord || (key && warmRecord.key !== key)) return;
|
||||
if (warmRecord.facebookSession) destroyFacebookSession(warmRecord.facebookSession);
|
||||
else warmRecord.iframe?.remove?.();
|
||||
warmRecord = null;
|
||||
}
|
||||
|
||||
function takeWarmFacebook(key, host) {
|
||||
if (warmRecord?.key !== key || !warmRecord.facebookSession) return null;
|
||||
const session = warmRecord.facebookSession;
|
||||
warmRecord = null;
|
||||
const warmHost = session.host;
|
||||
warmHost.classList.remove('bhote-embedded-media-warmup');
|
||||
warmHost.classList.add('bhote-facebook-player-host');
|
||||
warmHost.removeAttribute?.('aria-hidden');
|
||||
host.append(warmHost);
|
||||
return session;
|
||||
}
|
||||
|
||||
function warm({ observation, sourceUrl } = {}) {
|
||||
const media = observation?.media || {};
|
||||
const source = resolveEmbeddedMediaSource(sourceUrl || media.sourceUrl);
|
||||
if (!source || source.provider === 'x') {
|
||||
clearWarm();
|
||||
return false;
|
||||
}
|
||||
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 ?? ''}`;
|
||||
if (warmRecord?.key === key) return true;
|
||||
clearWarm();
|
||||
if (source.provider === 'facebook') {
|
||||
const host = documentRef.createElement('div');
|
||||
host.className = 'bhote-embedded-media-warmup';
|
||||
host.setAttribute('aria-hidden', 'true');
|
||||
root.append(host);
|
||||
const facebookSession = createFacebookSession(source, host);
|
||||
warmRecord = { key, facebookSession };
|
||||
return true;
|
||||
}
|
||||
const iframe = documentRef.createElement('iframe');
|
||||
iframe.className = 'bhote-embedded-media-warmup';
|
||||
iframe.src = embeddedMediaFrameUrl(source, { autoplay: false, startAtSec, endAtSec });
|
||||
iframe.title = `Preloading ${providerLabel(source.provider)} media`;
|
||||
iframe.tabIndex = -1;
|
||||
iframe.setAttribute('aria-hidden', 'true');
|
||||
iframe.loading = 'eager';
|
||||
iframe.referrerPolicy = 'strict-origin-when-cross-origin';
|
||||
root.append(iframe);
|
||||
warmRecord = { key, iframe };
|
||||
return true;
|
||||
}
|
||||
|
||||
async function mountX(current, token) {
|
||||
current.status.textContent = 'LOADING X POST…';
|
||||
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,
|
||||
});
|
||||
if (token !== generation || record !== current) return;
|
||||
if (!widget) throw new Error('The X post is unavailable or cannot be embedded.');
|
||||
current.status.textContent = 'X POST · PROVIDER CONTROLS';
|
||||
position();
|
||||
} catch (error) {
|
||||
if (token !== generation || record !== current) return;
|
||||
current.card.classList.add('is-unavailable');
|
||||
current.status.textContent = `${String(error?.message || 'X embed unavailable')} OPEN ORIGINAL.`;
|
||||
position();
|
||||
}
|
||||
}
|
||||
|
||||
function bindFacebook(current, token, warmKey, autoplay) {
|
||||
const session = takeWarmFacebook(warmKey, current.player)
|
||||
|| createFacebookSession(current.source, current.player);
|
||||
current.facebookSession = session;
|
||||
session.shouldPlay = autoplay === true;
|
||||
session.onReady = () => {
|
||||
if (token !== generation || record !== current) return;
|
||||
current.status.textContent = 'FACEBOOK · PROVIDER CONTROLS';
|
||||
current.card.classList.remove('is-unavailable');
|
||||
position();
|
||||
};
|
||||
session.onError = (error) => {
|
||||
if (token !== generation || record !== current) return;
|
||||
current.card.classList.add('is-unavailable');
|
||||
current.status.textContent = `${String(error?.message || 'Facebook embed unavailable')} OPEN ORIGINAL.`;
|
||||
position();
|
||||
};
|
||||
session.onPlaybackBlocked = () => {
|
||||
if (token !== generation || record !== current) return;
|
||||
current.status.textContent = 'AUTOPLAY BLOCKED · USE PROVIDER CONTROLS';
|
||||
};
|
||||
if (session.ready) session.onReady();
|
||||
else if (session.error) session.onError(session.error);
|
||||
setFacebookSessionPlayback(session, autoplay);
|
||||
}
|
||||
|
||||
function setPlayback(playing) {
|
||||
// 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) {
|
||||
hide({ immediate: true });
|
||||
return true;
|
||||
}
|
||||
if (!record?.facebookSession) return false;
|
||||
return setFacebookSessionPlayback(record.facebookSession, playing);
|
||||
}
|
||||
|
||||
function show({ observation, anchor, autoplay = false, sourceUrl } = {}) {
|
||||
const media = observation?.media || {};
|
||||
const source = resolveEmbeddedMediaSource(sourceUrl || media.sourceUrl);
|
||||
if (!source || !anchor) {
|
||||
hide();
|
||||
return false;
|
||||
}
|
||||
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}`;
|
||||
if (record?.key === key) {
|
||||
record.anchor = anchor;
|
||||
if (source.provider === 'facebook') setPlayback(autoplay);
|
||||
position();
|
||||
return true;
|
||||
}
|
||||
hide();
|
||||
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');
|
||||
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"/>';
|
||||
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();
|
||||
const status = documentRef.createElement('small');
|
||||
status.setAttribute('role', 'status');
|
||||
status.textContent = `${providerLabel(source.provider)} · EMBEDDED SOURCE`;
|
||||
const link = documentRef.createElement('a');
|
||||
link.href = source.url;
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener noreferrer';
|
||||
link.textContent = 'OPEN ORIGINAL ↗';
|
||||
footer.append(title, status, link);
|
||||
const body = documentRef.createElement('div');
|
||||
body.className = 'bhote-embedded-callout-body';
|
||||
body.append(player, footer);
|
||||
card.append(leader, body);
|
||||
root.append(card);
|
||||
record = {
|
||||
key,
|
||||
source,
|
||||
anchor,
|
||||
card,
|
||||
leader,
|
||||
player,
|
||||
status,
|
||||
entered: false,
|
||||
};
|
||||
|
||||
if (source.provider === 'x') {
|
||||
void mountX(record, token);
|
||||
} else if (source.provider === 'facebook') {
|
||||
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;
|
||||
if (controlled) {
|
||||
const url = new URL(iframe.src);
|
||||
url.searchParams.set('enablejsapi', '1');
|
||||
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.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 });
|
||||
player.append(iframe);
|
||||
if (controlled) {
|
||||
const current = record;
|
||||
current.youtubeSession = createTrimmedYouTubePlayback({
|
||||
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`;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
position();
|
||||
return true;
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
hide({ immediate: true });
|
||||
for (const current of [...leavingRecords]) removeRecord(current);
|
||||
clearWarm();
|
||||
removePostRender?.();
|
||||
root.remove();
|
||||
}
|
||||
|
||||
return {
|
||||
warm,
|
||||
show,
|
||||
getPlaybackState: () => record?.youtubeSession?.getState() || null,
|
||||
play: () => setPlayback(true),
|
||||
pause: () => setPlayback(false),
|
||||
hide,
|
||||
destroy,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import {
|
||||
createBhoteKoshiEmbeddedMedia,
|
||||
createTrimmedYouTubePlayback,
|
||||
embeddedMediaFrameUrl,
|
||||
publicEmbeddedMediaUrl,
|
||||
resolveEmbeddedMediaSource,
|
||||
} from './bhoteKoshiEmbeddedMedia.js';
|
||||
|
||||
function trimmedPlaybackFixture() {
|
||||
let now = 0;
|
||||
let nextId = 0;
|
||||
const timers = new Map();
|
||||
const globalRef = {
|
||||
setTimeout(fn, delay) { const id = ++nextId; timers.set(id, { at: now + delay, fn }); return id; },
|
||||
clearTimeout(id) { timers.delete(id); },
|
||||
};
|
||||
const advance = (ms) => {
|
||||
const until = now + ms;
|
||||
while (true) {
|
||||
const entry = [...timers].filter(([, item]) => item.at <= until).sort((a, b) => a[1].at - b[1].at)[0];
|
||||
if (!entry) break;
|
||||
const [id, item] = entry; timers.delete(id); now = item.at; item.fn();
|
||||
}
|
||||
now = until;
|
||||
};
|
||||
let events;
|
||||
const calls = [];
|
||||
let time = 0;
|
||||
let state = -1;
|
||||
const player = {
|
||||
mute: () => calls.push('mute'), playVideo: () => calls.push('play'),
|
||||
pauseVideo: () => calls.push('pause'), destroy: () => calls.push('destroy'),
|
||||
getCurrentTime: () => time,
|
||||
getPlayerState: () => state,
|
||||
};
|
||||
const api = { Player: class { constructor(_iframe, options) { events = options.events; return player; } } };
|
||||
const statuses = [];
|
||||
const session = createTrimmedYouTubePlayback({ iframe: {}, endAtSec: 7, globalRef,
|
||||
loadApi: () => api, onStatus: (phase) => statuses.push(phase) });
|
||||
return { session, advance, calls, timers, statuses, player, get events() { return events; }, setTime(value) { time = value; }, setState(value) { state = value; } };
|
||||
}
|
||||
|
||||
test('trimmed YouTube reconciles already-playing READY and missed PLAYING callbacks', async () => {
|
||||
for (const alreadyPlaying of [true, false]) {
|
||||
const f = trimmedPlaybackFixture();
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
if (alreadyPlaying) { f.setState(1); f.setTime(1); }
|
||||
f.events.onReady({ target: f.player });
|
||||
if (!alreadyPlaying) { f.setState(1); f.setTime(1); f.advance(100); }
|
||||
f.setTime(6); f.advance(5000);
|
||||
assert.deepEqual(f.session.getState(), { phase: 'playing', currentTime: 6 });
|
||||
f.setTime(7); f.advance(100);
|
||||
assert.deepEqual(f.session.getState(), { phase: 'completed', currentTime: 7 });
|
||||
assert.deepEqual(f.statuses, ['completed']);
|
||||
assert.equal(f.timers.size, 0);
|
||||
f.session.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('trimmed YouTube accepts ENDED before PLAYING and never replays a completed READY', async () => {
|
||||
for (const endedCallback of [true, false]) {
|
||||
const f = trimmedPlaybackFixture();
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
f.setState(0); f.setTime(7);
|
||||
if (endedCallback) f.events.onStateChange({ data: 0, target: f.player });
|
||||
f.events.onReady({ target: f.player });
|
||||
assert.deepEqual(f.session.getState(), { phase: 'completed', currentTime: 7 });
|
||||
assert.deepEqual(f.calls, ['pause']);
|
||||
assert.deepEqual(f.statuses, ['completed']);
|
||||
assert.equal(f.timers.size, 0);
|
||||
f.session.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('trimmed YouTube waits through delayed startup, stops at source 0:07, and releases its timers', async () => {
|
||||
const f = trimmedPlaybackFixture();
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
f.advance(3000);
|
||||
assert.equal(f.session.getState().phase, 'starting');
|
||||
f.events.onReady({ target: f.player });
|
||||
f.events.onStateChange({ data: 1 });
|
||||
f.setTime(4); f.advance(4000);
|
||||
assert.equal(f.session.getState().phase, 'playing', 'seven wall-clock seconds do not truncate a delayed clip');
|
||||
f.setTime(7); f.advance(100);
|
||||
assert.deepEqual(f.session.getState(), { phase: 'completed', currentTime: 7 });
|
||||
assert.deepEqual(f.statuses, ['completed']);
|
||||
assert.equal(f.timers.size, 0);
|
||||
assert.equal(f.calls.at(-1), 'pause');
|
||||
f.session.destroy();
|
||||
});
|
||||
|
||||
test('trimmed YouTube bounds blocked, failed, stalled and never-started playback', async () => {
|
||||
for (const outcome of ['blocked', 'unavailable', 'timeout', 'stalled']) {
|
||||
const f = trimmedPlaybackFixture();
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
if (outcome === 'blocked') f.events.onAutoplayBlocked();
|
||||
else if (outcome === 'unavailable') f.events.onError();
|
||||
else if (outcome === 'stalled') { f.events.onStateChange({ data: 1 }); f.advance(12000); }
|
||||
else f.advance(5000);
|
||||
assert.equal(f.session.getState().phase, outcome === 'stalled' ? 'timeout' : outcome);
|
||||
assert.equal(f.timers.size, 0);
|
||||
const calls = [...f.calls];
|
||||
f.events.onReady({ target: f.player });
|
||||
f.events.onStateChange({ data: 1 });
|
||||
assert.deepEqual(f.calls, calls, 'late provider callbacks cannot restart a terminal attempt');
|
||||
f.session.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('trimmed YouTube teardown revokes pending SDK and player callbacks', async () => {
|
||||
const f = trimmedPlaybackFixture();
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
f.session.destroy();
|
||||
f.events.onReady({ target: f.player });
|
||||
f.events.onStateChange({ data: 1 });
|
||||
f.events.onError(); f.advance(20000);
|
||||
assert.deepEqual(f.calls, ['destroy']);
|
||||
assert.deepEqual(f.statuses, []);
|
||||
assert.equal(f.timers.size, 0);
|
||||
let constructCount = 0;
|
||||
let resolveApi;
|
||||
const session = createTrimmedYouTubePlayback({ iframe: {}, endAtSec: 7,
|
||||
loadApi: () => new Promise((resolve) => { resolveApi = resolve; }) });
|
||||
await Promise.resolve();
|
||||
session.destroy();
|
||||
resolveApi({ Player: class { constructor() { constructCount++; } } });
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
assert.equal(constructCount, 0);
|
||||
});
|
||||
|
||||
test('pausing YouTube removes its frame and rejects a late load from the stopped generation', () => {
|
||||
const nodes = [];
|
||||
class Element extends EventTarget {
|
||||
constructor(tag) { super(); this.tag = tag; this.children = []; this.dataset = {}; this.classList = { add() {}, remove() {} }; nodes.push(this); }
|
||||
setAttribute() {}
|
||||
append(...children) { for (const child of children) { 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: {} });
|
||||
const options = { observation: { title: 'Clip', media: { sourceUrl: 'https://youtu.be/DbqRexFxv3k' } }, anchor: {}, autoplay: true };
|
||||
assert.equal(media.show(options), true);
|
||||
const frame = nodes.find((node) => node.tag === 'iframe');
|
||||
const card = nodes.find((node) => node.tag === 'section');
|
||||
assert.ok(card.parent);
|
||||
assert.equal(media.pause(), true);
|
||||
assert.equal(card.parent, null);
|
||||
frame.dispatchEvent(new Event('load'));
|
||||
assert.equal(card.parent, null);
|
||||
assert.equal(media.show(options), true, 'an explicit replay creates a fresh player');
|
||||
media.destroy();
|
||||
assert.equal(documentRef.body.children.length, 0);
|
||||
});
|
||||
|
||||
test('resolves supported embedded callout providers from canonical public URLs', () => {
|
||||
assert.deepEqual(
|
||||
resolveEmbeddedMediaSource('https://www.youtube.com/watch?v=DbqRexFxv3k'),
|
||||
{
|
||||
provider: 'youtube',
|
||||
id: 'DbqRexFxv3k',
|
||||
url: 'https://www.youtube.com/watch?v=DbqRexFxv3k',
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
resolveEmbeddedMediaSource('https://www.facebook.com/reel/1571491657757829').provider,
|
||||
'facebook',
|
||||
);
|
||||
assert.deepEqual(
|
||||
resolveEmbeddedMediaSource('https://x.com/Indowatchosint/status/2092640554568007958'),
|
||||
{
|
||||
provider: 'x',
|
||||
id: '2092640554568007958',
|
||||
url: 'https://x.com/Indowatchosint/status/2092640554568007958',
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
resolveEmbeddedMediaSource('https://twitter.com/Indowatchosint/status/2092640554568007958').provider,
|
||||
'x',
|
||||
);
|
||||
assert.equal(
|
||||
resolveEmbeddedMediaSource('https://www.youtube.com/shorts/DbqRexFxv3k').id,
|
||||
'DbqRexFxv3k',
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects deceptive hosts, non-post X links, private targets, and iframe markup', () => {
|
||||
for (const value of [
|
||||
'https://x.com.evil.example/user/status/2092640554568007958',
|
||||
'https://x.com/Indowatchosint',
|
||||
'https://youtube.com.evil.example/watch?v=DbqRexFxv3k',
|
||||
'http://www.youtube.com/watch?v=DbqRexFxv3k',
|
||||
'https://localhost/video.mp4',
|
||||
'<iframe src="https://x.com/user/status/2092640554568007958"></iframe>',
|
||||
]) {
|
||||
assert.equal(resolveEmbeddedMediaSource(value), null, value);
|
||||
}
|
||||
assert.equal(publicEmbeddedMediaUrl('https://user:[email protected]/name/status/123'), null);
|
||||
});
|
||||
|
||||
test('builds privacy-aware provider frames and keeps X on its widget runtime', () => {
|
||||
const youtube = resolveEmbeddedMediaSource('https://youtu.be/DbqRexFxv3k');
|
||||
const youtubeFrame = new URL(embeddedMediaFrameUrl(youtube, { autoplay: true }));
|
||||
assert.equal(youtubeFrame.hostname, 'www.youtube-nocookie.com');
|
||||
assert.equal(youtubeFrame.pathname, '/embed/DbqRexFxv3k');
|
||||
assert.equal(youtubeFrame.searchParams.get('autoplay'), '1');
|
||||
assert.equal(youtubeFrame.searchParams.get('mute'), '1');
|
||||
|
||||
const timestampedFrame = new URL(embeddedMediaFrameUrl(youtube, {
|
||||
autoplay: true,
|
||||
startAtSec: 26,
|
||||
}));
|
||||
assert.equal(timestampedFrame.searchParams.get('start'), '26');
|
||||
|
||||
const facebook = resolveEmbeddedMediaSource('https://www.facebook.com/watch/?v=456');
|
||||
const facebookFrame = new URL(embeddedMediaFrameUrl(facebook, { autoplay: false }));
|
||||
assert.equal(facebookFrame.hostname, 'www.facebook.com');
|
||||
assert.equal(facebookFrame.pathname, '/plugins/video.php');
|
||||
assert.equal(facebookFrame.searchParams.get('href'), facebook.url);
|
||||
assert.equal(facebookFrame.searchParams.get('autoplay'), 'false');
|
||||
|
||||
const x = resolveEmbeddedMediaSource('https://x.com/Indowatchosint/status/2092640554568007958');
|
||||
assert.equal(embeddedMediaFrameUrl(x), null);
|
||||
});
|
||||
|
||||
test('Mailung playback stops at source timestamp 0:07 without changing other clips', async () => {
|
||||
const event = JSON.parse(await readFile(new URL('../../public/events/bhote-koshi-2026/event.json', import.meta.url), 'utf8'));
|
||||
const bounded = event.evidenceSpine.filter(point => point.media?.embedEndAtSec != null);
|
||||
assert.deepEqual(bounded.map(point => point.id), ['mailung-bazzar']);
|
||||
const media = bounded[0].media;
|
||||
assert.equal(media.embedEndAtSec, 7);
|
||||
const source = resolveEmbeddedMediaSource(media.sourceUrl);
|
||||
for (const autoplay of [false, true]) {
|
||||
const url = new URL(embeddedMediaFrameUrl(source, {
|
||||
autoplay, startAtSec: media.clipInSec, endAtSec: media.embedEndAtSec,
|
||||
}));
|
||||
assert.equal(url.searchParams.get('end'), '7');
|
||||
}
|
||||
for (const endAtSec of [undefined, null, NaN, Infinity, -1, 0, 3]) {
|
||||
const url = new URL(embeddedMediaFrameUrl(source, {startAtSec: 3, endAtSec}));
|
||||
assert.equal(url.searchParams.has('end'), false);
|
||||
}
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,499 @@
|
||||
/**
|
||||
* Simplified GeoPera river centerline from the point nearest the owner-provided
|
||||
* Debris-Dammed Lake observation through Trishuli Bazaar. Every fourth published
|
||||
* vertex is retained, plus the exact endpoint nearest the verified bazaar pin.
|
||||
*
|
||||
* Source: https://github.com/geo-pera/bhotekoshi-2026-reconstruction/blob/main/vectors/river_centerline.geojson
|
||||
* License: CC BY-NC 4.0 (derived data; Planet Labs PBC / Vantor open data)
|
||||
*/
|
||||
export const BHOTE_KOSHI_FLOOD_PATH = Object.freeze([
|
||||
Object.freeze([85.48476, 28.33255]),
|
||||
Object.freeze([85.48353, 28.33308]),
|
||||
Object.freeze([85.48231, 28.33279]),
|
||||
Object.freeze([85.48108, 28.33278]),
|
||||
Object.freeze([85.47985, 28.33304]),
|
||||
Object.freeze([85.47863, 28.33275]),
|
||||
Object.freeze([85.47741, 28.33301]),
|
||||
Object.freeze([85.47618, 28.33299]),
|
||||
Object.freeze([85.47496, 28.33325]),
|
||||
Object.freeze([85.47373, 28.33351]),
|
||||
Object.freeze([85.4725, 28.33377]),
|
||||
Object.freeze([85.47128, 28.33375]),
|
||||
Object.freeze([85.47005, 28.33401]),
|
||||
Object.freeze([85.46881, 28.33481]),
|
||||
Object.freeze([85.46759, 28.33506]),
|
||||
Object.freeze([85.46636, 28.33532]),
|
||||
Object.freeze([85.46513, 28.33585]),
|
||||
Object.freeze([85.4639, 28.33584]),
|
||||
Object.freeze([85.46267, 28.33609]),
|
||||
Object.freeze([85.46145, 28.33635]),
|
||||
Object.freeze([85.46022, 28.33661]),
|
||||
Object.freeze([85.45899, 28.33659]),
|
||||
Object.freeze([85.45777, 28.33685]),
|
||||
Object.freeze([85.45655, 28.33629]),
|
||||
Object.freeze([85.45533, 28.33628]),
|
||||
Object.freeze([85.45411, 28.336]),
|
||||
Object.freeze([85.45289, 28.33544]),
|
||||
Object.freeze([85.45167, 28.33543]),
|
||||
Object.freeze([85.45045, 28.33514]),
|
||||
Object.freeze([85.44922, 28.33513]),
|
||||
Object.freeze([85.448, 28.33511]),
|
||||
Object.freeze([85.44677, 28.33537]),
|
||||
Object.freeze([85.44554, 28.33563]),
|
||||
Object.freeze([85.44432, 28.33534]),
|
||||
Object.freeze([85.4431, 28.33533]),
|
||||
Object.freeze([85.44188, 28.33504]),
|
||||
Object.freeze([85.44065, 28.3353]),
|
||||
Object.freeze([85.43943, 28.33529]),
|
||||
Object.freeze([85.43821, 28.335]),
|
||||
Object.freeze([85.43699, 28.33472]),
|
||||
Object.freeze([85.43577, 28.33416]),
|
||||
Object.freeze([85.43456, 28.33334]),
|
||||
Object.freeze([85.43335, 28.33251]),
|
||||
Object.freeze([85.43214, 28.33141]),
|
||||
Object.freeze([85.43093, 28.33059]),
|
||||
Object.freeze([85.42971, 28.33057]),
|
||||
Object.freeze([85.42848, 28.33056]),
|
||||
Object.freeze([85.42726, 28.33027]),
|
||||
Object.freeze([85.42604, 28.33026]),
|
||||
Object.freeze([85.42482, 28.33025]),
|
||||
Object.freeze([85.4236, 28.32996]),
|
||||
Object.freeze([85.42238, 28.32968]),
|
||||
Object.freeze([85.42116, 28.32939]),
|
||||
Object.freeze([85.41995, 28.32829]),
|
||||
Object.freeze([85.41873, 28.32774]),
|
||||
Object.freeze([85.41752, 28.32718]),
|
||||
Object.freeze([85.41629, 28.32744]),
|
||||
Object.freeze([85.41508, 28.32634]),
|
||||
Object.freeze([85.41387, 28.32524]),
|
||||
Object.freeze([85.41297, 28.32415]),
|
||||
Object.freeze([85.41268, 28.32306]),
|
||||
Object.freeze([85.41239, 28.32198]),
|
||||
Object.freeze([85.41241, 28.3209]),
|
||||
Object.freeze([85.41273, 28.31982]),
|
||||
Object.freeze([85.41275, 28.31873]),
|
||||
Object.freeze([85.41276, 28.31765]),
|
||||
Object.freeze([85.41308, 28.31657]),
|
||||
Object.freeze([85.41249, 28.31548]),
|
||||
Object.freeze([85.4122, 28.3144]),
|
||||
Object.freeze([85.41252, 28.31332]),
|
||||
Object.freeze([85.41254, 28.31223]),
|
||||
Object.freeze([85.41194, 28.31114]),
|
||||
Object.freeze([85.41196, 28.31006]),
|
||||
Object.freeze([85.41167, 28.30897]),
|
||||
Object.freeze([85.41077, 28.30788]),
|
||||
Object.freeze([85.40955, 28.30705]),
|
||||
Object.freeze([85.40865, 28.30596]),
|
||||
Object.freeze([85.40744, 28.30513]),
|
||||
Object.freeze([85.40623, 28.30404]),
|
||||
Object.freeze([85.40503, 28.30294]),
|
||||
Object.freeze([85.40381, 28.30238]),
|
||||
Object.freeze([85.4026, 28.30156]),
|
||||
Object.freeze([85.40139, 28.30046]),
|
||||
Object.freeze([85.40019, 28.29936]),
|
||||
Object.freeze([85.39898, 28.29827]),
|
||||
Object.freeze([85.39777, 28.29717]),
|
||||
Object.freeze([85.39748, 28.29608]),
|
||||
Object.freeze([85.39658, 28.29499]),
|
||||
Object.freeze([85.39629, 28.2939]),
|
||||
Object.freeze([85.3957, 28.29281]),
|
||||
Object.freeze([85.39449, 28.29172]),
|
||||
Object.freeze([85.39328, 28.29062]),
|
||||
Object.freeze([85.39238, 28.28953]),
|
||||
Object.freeze([85.39117, 28.28897]),
|
||||
Object.freeze([85.38996, 28.28787]),
|
||||
Object.freeze([85.38875, 28.28678]),
|
||||
Object.freeze([85.38754, 28.28622]),
|
||||
Object.freeze([85.38632, 28.28593]),
|
||||
Object.freeze([85.38511, 28.28511]),
|
||||
Object.freeze([85.3839, 28.28401]),
|
||||
Object.freeze([85.383, 28.28292]),
|
||||
Object.freeze([85.38179, 28.28182]),
|
||||
Object.freeze([85.38089, 28.28073]),
|
||||
Object.freeze([85.37968, 28.28017]),
|
||||
Object.freeze([85.37846, 28.27934]),
|
||||
Object.freeze([85.37725, 28.27852]),
|
||||
Object.freeze([85.37666, 28.27743]),
|
||||
Object.freeze([85.37668, 28.27634]),
|
||||
Object.freeze([85.37669, 28.27526]),
|
||||
Object.freeze([85.37701, 28.27418]),
|
||||
Object.freeze([85.37642, 28.27309]),
|
||||
Object.freeze([85.37644, 28.27201]),
|
||||
Object.freeze([85.37615, 28.27092]),
|
||||
Object.freeze([85.37616, 28.26984]),
|
||||
Object.freeze([85.37648, 28.26876]),
|
||||
Object.freeze([85.37558, 28.26767]),
|
||||
Object.freeze([85.37499, 28.26658]),
|
||||
Object.freeze([85.37378, 28.26548]),
|
||||
Object.freeze([85.37288, 28.26439]),
|
||||
Object.freeze([85.37198, 28.26329]),
|
||||
Object.freeze([85.37169, 28.26221]),
|
||||
Object.freeze([85.37048, 28.26111]),
|
||||
Object.freeze([85.36928, 28.26001]),
|
||||
Object.freeze([85.36806, 28.25973]),
|
||||
Object.freeze([85.36685, 28.25863]),
|
||||
Object.freeze([85.36656, 28.25754]),
|
||||
Object.freeze([85.36566, 28.25645]),
|
||||
Object.freeze([85.36507, 28.25536]),
|
||||
Object.freeze([85.36478, 28.25427]),
|
||||
Object.freeze([85.36449, 28.25318]),
|
||||
Object.freeze([85.36512, 28.25211]),
|
||||
Object.freeze([85.36452, 28.25102]),
|
||||
Object.freeze([85.36423, 28.24993]),
|
||||
Object.freeze([85.36425, 28.24885]),
|
||||
Object.freeze([85.36335, 28.24776]),
|
||||
Object.freeze([85.36245, 28.24666]),
|
||||
Object.freeze([85.36124, 28.24556]),
|
||||
Object.freeze([85.36004, 28.24447]),
|
||||
Object.freeze([85.35882, 28.24391]),
|
||||
Object.freeze([85.35823, 28.24282]),
|
||||
Object.freeze([85.35733, 28.24173]),
|
||||
Object.freeze([85.35734, 28.24064]),
|
||||
Object.freeze([85.35797, 28.23957]),
|
||||
Object.freeze([85.35799, 28.23849]),
|
||||
Object.freeze([85.3577, 28.2374]),
|
||||
Object.freeze([85.35802, 28.23632]),
|
||||
Object.freeze([85.35834, 28.23524]),
|
||||
Object.freeze([85.35928, 28.23417]),
|
||||
Object.freeze([85.3596, 28.23309]),
|
||||
Object.freeze([85.36023, 28.23202]),
|
||||
Object.freeze([85.36024, 28.23093]),
|
||||
Object.freeze([85.36057, 28.22985]),
|
||||
Object.freeze([85.36058, 28.22877]),
|
||||
Object.freeze([85.3609, 28.22769]),
|
||||
Object.freeze([85.36092, 28.22661]),
|
||||
Object.freeze([85.36094, 28.22553]),
|
||||
Object.freeze([85.36126, 28.22445]),
|
||||
Object.freeze([85.36067, 28.22336]),
|
||||
Object.freeze([85.36007, 28.22227]),
|
||||
Object.freeze([85.35886, 28.22117]),
|
||||
Object.freeze([85.35796, 28.22008]),
|
||||
Object.freeze([85.35706, 28.21898]),
|
||||
Object.freeze([85.35616, 28.21789]),
|
||||
Object.freeze([85.35526, 28.21679]),
|
||||
Object.freeze([85.35467, 28.2157]),
|
||||
Object.freeze([85.35469, 28.21462]),
|
||||
Object.freeze([85.3547, 28.21354]),
|
||||
Object.freeze([85.35472, 28.21246]),
|
||||
Object.freeze([85.35443, 28.21137]),
|
||||
Object.freeze([85.35414, 28.21028]),
|
||||
Object.freeze([85.35416, 28.2092]),
|
||||
Object.freeze([85.35448, 28.20812]),
|
||||
Object.freeze([85.3548, 28.20704]),
|
||||
Object.freeze([85.35482, 28.20596]),
|
||||
Object.freeze([85.35392, 28.20486]),
|
||||
Object.freeze([85.35332, 28.20377]),
|
||||
Object.freeze([85.35303, 28.20269]),
|
||||
Object.freeze([85.35275, 28.2016]),
|
||||
Object.freeze([85.35246, 28.20052]),
|
||||
Object.freeze([85.35247, 28.19943]),
|
||||
Object.freeze([85.35188, 28.19834]),
|
||||
Object.freeze([85.35128, 28.19725]),
|
||||
Object.freeze([85.35038, 28.19616]),
|
||||
Object.freeze([85.3501, 28.19507]),
|
||||
Object.freeze([85.3495, 28.19398]),
|
||||
Object.freeze([85.34891, 28.19289]),
|
||||
Object.freeze([85.34862, 28.1918]),
|
||||
Object.freeze([85.34802, 28.19071]),
|
||||
Object.freeze([85.34743, 28.18962]),
|
||||
Object.freeze([85.34622, 28.18853]),
|
||||
Object.freeze([85.34593, 28.18744]),
|
||||
Object.freeze([85.34565, 28.18635]),
|
||||
Object.freeze([85.34475, 28.18526]),
|
||||
Object.freeze([85.34385, 28.18417]),
|
||||
Object.freeze([85.34325, 28.18308]),
|
||||
Object.freeze([85.34266, 28.18199]),
|
||||
Object.freeze([85.34237, 28.1809]),
|
||||
Object.freeze([85.34239, 28.17982]),
|
||||
Object.freeze([85.34271, 28.17874]),
|
||||
Object.freeze([85.34272, 28.17765]),
|
||||
Object.freeze([85.34274, 28.17657]),
|
||||
Object.freeze([85.34215, 28.17548]),
|
||||
Object.freeze([85.34216, 28.1744]),
|
||||
Object.freeze([85.34249, 28.17332]),
|
||||
Object.freeze([85.34189, 28.17223]),
|
||||
Object.freeze([85.34191, 28.17115]),
|
||||
Object.freeze([85.34192, 28.17006]),
|
||||
Object.freeze([85.34255, 28.16899]),
|
||||
Object.freeze([85.34226, 28.1679]),
|
||||
Object.freeze([85.34289, 28.16683]),
|
||||
Object.freeze([85.3423, 28.16574]),
|
||||
Object.freeze([85.3417, 28.16465]),
|
||||
Object.freeze([85.34048, 28.16436]),
|
||||
Object.freeze([85.33928, 28.16353]),
|
||||
Object.freeze([85.33807, 28.16271]),
|
||||
Object.freeze([85.33747, 28.16162]),
|
||||
Object.freeze([85.33718, 28.16053]),
|
||||
Object.freeze([85.33628, 28.15971]),
|
||||
Object.freeze([85.33507, 28.15888]),
|
||||
Object.freeze([85.33385, 28.15859]),
|
||||
Object.freeze([85.33295, 28.15777]),
|
||||
Object.freeze([85.33174, 28.15694]),
|
||||
Object.freeze([85.33053, 28.15639]),
|
||||
Object.freeze([85.32932, 28.15529]),
|
||||
Object.freeze([85.32842, 28.1542]),
|
||||
Object.freeze([85.32721, 28.15337]),
|
||||
Object.freeze([85.32631, 28.15228]),
|
||||
Object.freeze([85.3251, 28.15145]),
|
||||
Object.freeze([85.32389, 28.15089]),
|
||||
Object.freeze([85.32267, 28.15061]),
|
||||
Object.freeze([85.32147, 28.14978]),
|
||||
Object.freeze([85.32026, 28.14895]),
|
||||
Object.freeze([85.31904, 28.1484]),
|
||||
Object.freeze([85.31783, 28.14784]),
|
||||
Object.freeze([85.31663, 28.14674]),
|
||||
Object.freeze([85.31541, 28.14619]),
|
||||
Object.freeze([85.3142, 28.14536]),
|
||||
Object.freeze([85.313, 28.14426]),
|
||||
Object.freeze([85.3124, 28.14317]),
|
||||
Object.freeze([85.31181, 28.14208]),
|
||||
Object.freeze([85.31058, 28.14261]),
|
||||
Object.freeze([85.30936, 28.14232]),
|
||||
Object.freeze([85.30816, 28.14122]),
|
||||
Object.freeze([85.30695, 28.14012]),
|
||||
Object.freeze([85.30575, 28.13903]),
|
||||
Object.freeze([85.30485, 28.13793]),
|
||||
Object.freeze([85.30456, 28.13685]),
|
||||
Object.freeze([85.30366, 28.13575]),
|
||||
Object.freeze([85.30307, 28.13466]),
|
||||
Object.freeze([85.30248, 28.13357]),
|
||||
Object.freeze([85.30127, 28.13274]),
|
||||
Object.freeze([85.30067, 28.13165]),
|
||||
Object.freeze([85.30039, 28.13057]),
|
||||
Object.freeze([85.3001, 28.12948]),
|
||||
Object.freeze([85.29889, 28.12838]),
|
||||
Object.freeze([85.29799, 28.12729]),
|
||||
Object.freeze([85.29709, 28.12647]),
|
||||
Object.freeze([85.29588, 28.12591]),
|
||||
Object.freeze([85.29467, 28.12508]),
|
||||
Object.freeze([85.29347, 28.12398]),
|
||||
Object.freeze([85.29226, 28.12316]),
|
||||
Object.freeze([85.29105, 28.12206]),
|
||||
Object.freeze([85.28985, 28.12096]),
|
||||
Object.freeze([85.28864, 28.11986]),
|
||||
Object.freeze([85.28775, 28.11877]),
|
||||
Object.freeze([85.28653, 28.11821]),
|
||||
Object.freeze([85.28532, 28.11765]),
|
||||
Object.freeze([85.28411, 28.11683]),
|
||||
Object.freeze([85.2829, 28.116]),
|
||||
Object.freeze([85.28231, 28.11491]),
|
||||
Object.freeze([85.28172, 28.11382]),
|
||||
Object.freeze([85.28082, 28.11272]),
|
||||
Object.freeze([85.27961, 28.11217]),
|
||||
Object.freeze([85.2787, 28.11134]),
|
||||
Object.freeze([85.27749, 28.11079]),
|
||||
Object.freeze([85.27629, 28.10969]),
|
||||
Object.freeze([85.27508, 28.10859]),
|
||||
Object.freeze([85.27388, 28.10749]),
|
||||
Object.freeze([85.27267, 28.10667]),
|
||||
Object.freeze([85.27177, 28.10584]),
|
||||
Object.freeze([85.27056, 28.10474]),
|
||||
Object.freeze([85.26936, 28.10392]),
|
||||
Object.freeze([85.26815, 28.10309]),
|
||||
Object.freeze([85.26695, 28.10199]),
|
||||
Object.freeze([85.26574, 28.10089]),
|
||||
Object.freeze([85.26454, 28.09979]),
|
||||
Object.freeze([85.26364, 28.0987]),
|
||||
Object.freeze([85.26274, 28.09761]),
|
||||
Object.freeze([85.26153, 28.09678]),
|
||||
Object.freeze([85.26033, 28.09568]),
|
||||
Object.freeze([85.25911, 28.09539]),
|
||||
Object.freeze([85.2579, 28.09511]),
|
||||
Object.freeze([85.25668, 28.09509]),
|
||||
Object.freeze([85.25546, 28.09481]),
|
||||
Object.freeze([85.25425, 28.09398]),
|
||||
Object.freeze([85.25304, 28.09342]),
|
||||
Object.freeze([85.25183, 28.09286]),
|
||||
Object.freeze([85.25061, 28.09285]),
|
||||
Object.freeze([85.24939, 28.09256]),
|
||||
Object.freeze([85.24818, 28.09201]),
|
||||
Object.freeze([85.24696, 28.09199]),
|
||||
Object.freeze([85.24573, 28.09252]),
|
||||
Object.freeze([85.24451, 28.0925]),
|
||||
Object.freeze([85.24328, 28.09248]),
|
||||
Object.freeze([85.24207, 28.0922]),
|
||||
Object.freeze([85.24085, 28.09191]),
|
||||
Object.freeze([85.23963, 28.0919]),
|
||||
Object.freeze([85.23841, 28.09161]),
|
||||
Object.freeze([85.2372, 28.09132]),
|
||||
Object.freeze([85.23598, 28.09104]),
|
||||
Object.freeze([85.23476, 28.09102]),
|
||||
Object.freeze([85.23355, 28.09019]),
|
||||
Object.freeze([85.23234, 28.08964]),
|
||||
Object.freeze([85.23113, 28.08881]),
|
||||
Object.freeze([85.22993, 28.08771]),
|
||||
Object.freeze([85.22872, 28.08688]),
|
||||
Object.freeze([85.22782, 28.08579]),
|
||||
Object.freeze([85.22692, 28.08496]),
|
||||
Object.freeze([85.22571, 28.08414]),
|
||||
Object.freeze([85.2245, 28.08358]),
|
||||
Object.freeze([85.22329, 28.08302]),
|
||||
Object.freeze([85.22208, 28.08219]),
|
||||
Object.freeze([85.22087, 28.08164]),
|
||||
Object.freeze([85.21966, 28.08108]),
|
||||
Object.freeze([85.21876, 28.08026]),
|
||||
Object.freeze([85.21755, 28.07943]),
|
||||
Object.freeze([85.21635, 28.07833]),
|
||||
Object.freeze([85.21514, 28.07723]),
|
||||
Object.freeze([85.21394, 28.0764]),
|
||||
Object.freeze([85.21272, 28.07585]),
|
||||
Object.freeze([85.21151, 28.07529]),
|
||||
Object.freeze([85.2103, 28.075]),
|
||||
Object.freeze([85.2094, 28.07391]),
|
||||
Object.freeze([85.20881, 28.07282]),
|
||||
Object.freeze([85.20791, 28.07172]),
|
||||
Object.freeze([85.20762, 28.07063]),
|
||||
Object.freeze([85.20672, 28.06954]),
|
||||
Object.freeze([85.20552, 28.06844]),
|
||||
Object.freeze([85.20523, 28.06735]),
|
||||
Object.freeze([85.20495, 28.06627]),
|
||||
Object.freeze([85.20588, 28.0652]),
|
||||
Object.freeze([85.2062, 28.06412]),
|
||||
Object.freeze([85.20683, 28.06304]),
|
||||
Object.freeze([85.20716, 28.06196]),
|
||||
Object.freeze([85.20717, 28.06088]),
|
||||
Object.freeze([85.2078, 28.05981]),
|
||||
Object.freeze([85.20751, 28.05872]),
|
||||
Object.freeze([85.20814, 28.05765]),
|
||||
Object.freeze([85.20847, 28.05657]),
|
||||
Object.freeze([85.20787, 28.05548]),
|
||||
Object.freeze([85.20759, 28.05439]),
|
||||
Object.freeze([85.20699, 28.0533]),
|
||||
Object.freeze([85.2061, 28.0522]),
|
||||
Object.freeze([85.20489, 28.05138]),
|
||||
Object.freeze([85.20369, 28.05028]),
|
||||
Object.freeze([85.2031, 28.04919]),
|
||||
Object.freeze([85.2022, 28.04809]),
|
||||
Object.freeze([85.20191, 28.04701]),
|
||||
Object.freeze([85.20101, 28.04591]),
|
||||
Object.freeze([85.19981, 28.04508]),
|
||||
Object.freeze([85.1986, 28.04398]),
|
||||
Object.freeze([85.19739, 28.04343]),
|
||||
Object.freeze([85.1965, 28.04233]),
|
||||
Object.freeze([85.19529, 28.0415]),
|
||||
Object.freeze([85.19439, 28.04041]),
|
||||
Object.freeze([85.1938, 28.03932]),
|
||||
Object.freeze([85.19382, 28.03824]),
|
||||
Object.freeze([85.19384, 28.03715]),
|
||||
Object.freeze([85.19355, 28.03607]),
|
||||
Object.freeze([85.19357, 28.03498]),
|
||||
Object.freeze([85.19328, 28.0339]),
|
||||
Object.freeze([85.1936, 28.03282]),
|
||||
Object.freeze([85.19362, 28.03174]),
|
||||
Object.freeze([85.19364, 28.03065]),
|
||||
Object.freeze([85.19305, 28.02956]),
|
||||
Object.freeze([85.19215, 28.02847]),
|
||||
Object.freeze([85.19156, 28.02738]),
|
||||
Object.freeze([85.19097, 28.02629]),
|
||||
Object.freeze([85.18976, 28.02573]),
|
||||
Object.freeze([85.18854, 28.02571]),
|
||||
Object.freeze([85.18732, 28.02515]),
|
||||
Object.freeze([85.18611, 28.0246]),
|
||||
Object.freeze([85.18522, 28.0235]),
|
||||
Object.freeze([85.18493, 28.02242]),
|
||||
Object.freeze([85.18556, 28.02134]),
|
||||
Object.freeze([85.1868, 28.02027]),
|
||||
Object.freeze([85.18742, 28.0192]),
|
||||
Object.freeze([85.18714, 28.01811]),
|
||||
Object.freeze([85.18746, 28.01703]),
|
||||
Object.freeze([85.18656, 28.01594]),
|
||||
Object.freeze([85.18536, 28.01484]),
|
||||
Object.freeze([85.18446, 28.01375]),
|
||||
Object.freeze([85.18418, 28.01266]),
|
||||
Object.freeze([85.18389, 28.01157]),
|
||||
Object.freeze([85.1836, 28.01049]),
|
||||
Object.freeze([85.18362, 28.0094]),
|
||||
Object.freeze([85.18364, 28.00832]),
|
||||
Object.freeze([85.18366, 28.00724]),
|
||||
Object.freeze([85.18368, 28.00615]),
|
||||
Object.freeze([85.1843, 28.00508]),
|
||||
Object.freeze([85.18432, 28.004]),
|
||||
Object.freeze([85.18404, 28.00291]),
|
||||
Object.freeze([85.18375, 28.00182]),
|
||||
Object.freeze([85.18438, 28.00075]),
|
||||
Object.freeze([85.18501, 27.99967]),
|
||||
Object.freeze([85.18533, 27.9986]),
|
||||
Object.freeze([85.18596, 27.99752]),
|
||||
Object.freeze([85.18567, 27.99643]),
|
||||
Object.freeze([85.18477, 27.99534]),
|
||||
Object.freeze([85.18357, 27.99424]),
|
||||
Object.freeze([85.18237, 27.99314]),
|
||||
Object.freeze([85.18239, 27.99206]),
|
||||
Object.freeze([85.18302, 27.99098]),
|
||||
Object.freeze([85.18273, 27.9899]),
|
||||
Object.freeze([85.18152, 27.98907]),
|
||||
Object.freeze([85.18062, 27.98824]),
|
||||
Object.freeze([85.18034, 27.98716]),
|
||||
Object.freeze([85.18066, 27.98608]),
|
||||
Object.freeze([85.18037, 27.98499]),
|
||||
Object.freeze([85.18039, 27.98391]),
|
||||
Object.freeze([85.18041, 27.98283]),
|
||||
Object.freeze([85.18043, 27.98174]),
|
||||
Object.freeze([85.18075, 27.98067]),
|
||||
Object.freeze([85.18138, 27.97959]),
|
||||
Object.freeze([85.1814, 27.97851]),
|
||||
Object.freeze([85.18172, 27.97743]),
|
||||
Object.freeze([85.18204, 27.97635]),
|
||||
Object.freeze([85.18236, 27.97527]),
|
||||
Object.freeze([85.18299, 27.9742]),
|
||||
Object.freeze([85.18362, 27.97312]),
|
||||
Object.freeze([85.18425, 27.97205]),
|
||||
Object.freeze([85.18396, 27.97096]),
|
||||
Object.freeze([85.18275, 27.9704]),
|
||||
Object.freeze([85.18154, 27.97012]),
|
||||
Object.freeze([85.18033, 27.96956]),
|
||||
Object.freeze([85.17911, 27.96927]),
|
||||
Object.freeze([85.17821, 27.96818]),
|
||||
Object.freeze([85.17762, 27.96709]),
|
||||
Object.freeze([85.17703, 27.966]),
|
||||
Object.freeze([85.17613, 27.9649]),
|
||||
Object.freeze([85.17492, 27.96434]),
|
||||
Object.freeze([85.17371, 27.96406]),
|
||||
Object.freeze([85.1725, 27.9635]),
|
||||
Object.freeze([85.17128, 27.96321]),
|
||||
Object.freeze([85.17008, 27.96238]),
|
||||
Object.freeze([85.16888, 27.96129]),
|
||||
Object.freeze([85.16859, 27.9602]),
|
||||
Object.freeze([85.168, 27.95911]),
|
||||
Object.freeze([85.16771, 27.95802]),
|
||||
Object.freeze([85.16682, 27.95693]),
|
||||
Object.freeze([85.16561, 27.95637]),
|
||||
Object.freeze([85.1644, 27.95581]),
|
||||
Object.freeze([85.16318, 27.95552]),
|
||||
Object.freeze([85.16198, 27.95442]),
|
||||
Object.freeze([85.1623, 27.95335]),
|
||||
Object.freeze([85.16232, 27.95226]),
|
||||
Object.freeze([85.16234, 27.95118]),
|
||||
Object.freeze([85.16236, 27.9501]),
|
||||
Object.freeze([85.16238, 27.94902]),
|
||||
Object.freeze([85.1627, 27.94794]),
|
||||
Object.freeze([85.16241, 27.94685]),
|
||||
Object.freeze([85.16152, 27.94576]),
|
||||
Object.freeze([85.16093, 27.94466]),
|
||||
Object.freeze([85.16034, 27.94357]),
|
||||
Object.freeze([85.15974, 27.94248]),
|
||||
Object.freeze([85.15915, 27.94139]),
|
||||
Object.freeze([85.15887, 27.9403]),
|
||||
Object.freeze([85.15797, 27.93921]),
|
||||
Object.freeze([85.15738, 27.93812]),
|
||||
Object.freeze([85.15679, 27.93703]),
|
||||
Object.freeze([85.1562, 27.93594]),
|
||||
Object.freeze([85.15561, 27.93485]),
|
||||
Object.freeze([85.15532, 27.93376]),
|
||||
Object.freeze([85.15473, 27.93267]),
|
||||
Object.freeze([85.15352, 27.93184]),
|
||||
Object.freeze([85.15229, 27.93291]),
|
||||
Object.freeze([85.15105, 27.9337]),
|
||||
Object.freeze([85.15015, 27.93288]),
|
||||
Object.freeze([85.14987, 27.93179]),
|
||||
Object.freeze([85.14988, 27.93071]),
|
||||
Object.freeze([85.14929, 27.92962]),
|
||||
Object.freeze([85.14901, 27.92853]),
|
||||
Object.freeze([85.14994, 27.92746]),
|
||||
Object.freeze([85.15057, 27.92639]),
|
||||
Object.freeze([85.15089, 27.92531]),
|
||||
Object.freeze([85.15121, 27.92423]),
|
||||
Object.freeze([85.15123, 27.92315]),
|
||||
Object.freeze([85.15034, 27.92205]),
|
||||
Object.freeze([85.14943, 27.92177]),
|
||||
]);
|
||||
@@ -0,0 +1,22 @@
|
||||
// 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_HOLD_SEC = 11;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,671 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { INCIDENT_OVERVIEW_HOLD_SEC, INCIDENT_OVERVIEW_PLACES } from './bhoteKoshiIncidentPlaces.js';
|
||||
import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { SceneDirector } from '../scenes/director.js';
|
||||
import {
|
||||
anchoredCalloutPresentation,
|
||||
BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION,
|
||||
BHOTE_KOSHI_FLOOD_PATH_PRESENTATION,
|
||||
BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION,
|
||||
BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID,
|
||||
BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION,
|
||||
BHOTE_KOSHI_REGIONAL_PRESENTATION,
|
||||
BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
|
||||
closeRegionRing,
|
||||
createBhoteKoshiLocatorLayer,
|
||||
largestGeoJsonRing,
|
||||
NEPAL_CONTEXT_PRESENTATION,
|
||||
revealPolylinePositions,
|
||||
} from './bhoteKoshiLocator.js';
|
||||
|
||||
function viewerFixture() {
|
||||
const added = [];
|
||||
const removed = [];
|
||||
return {
|
||||
creditDisplay: { addStaticCredit() {} },
|
||||
dataSources: {
|
||||
add(source) { added.push(source); },
|
||||
remove(source) { removed.push(source); },
|
||||
},
|
||||
_test: { added, removed },
|
||||
};
|
||||
}
|
||||
|
||||
function nonRunningAnimation() {
|
||||
return {
|
||||
scheduleFrame() { return 1; },
|
||||
cancelFrame() {},
|
||||
};
|
||||
}
|
||||
|
||||
function overlayHostFixture() {
|
||||
const publications = [];
|
||||
const clears = [];
|
||||
return {
|
||||
setEntries(sourceId, entries, options) {
|
||||
publications.push({ sourceId, entries, options });
|
||||
},
|
||||
clearSource(sourceId) {
|
||||
clears.push(sourceId);
|
||||
},
|
||||
_test: { publications, clears },
|
||||
};
|
||||
}
|
||||
|
||||
test('Bhote Koshi locator closes valid region rings once', () => {
|
||||
assert.deepEqual(closeRegionRing([[80, 26], [88, 26], [88, 30]]), [
|
||||
[80, 26], [88, 26], [88, 30], [80, 26],
|
||||
]);
|
||||
assert.deepEqual(closeRegionRing([[80, 26], [88, 26], [80, 26]]), [
|
||||
[80, 26], [88, 26], [80, 26],
|
||||
]);
|
||||
assert.deepEqual(closeRegionRing([[80, 26], ['bad', 27]]), []);
|
||||
});
|
||||
|
||||
test('Bhote Koshi locator selects the largest GeoJSON country exterior', () => {
|
||||
const main = [[80, 26], [88, 26], [88, 30], [80, 30], [80, 26]];
|
||||
const island = [[82, 25], [83, 25], [82, 25]];
|
||||
assert.equal(largestGeoJsonRing({ type: 'Polygon', coordinates: [main] }), main);
|
||||
assert.equal(largestGeoJsonRing({
|
||||
type: 'MultiPolygon',
|
||||
coordinates: [[island], [main]],
|
||||
}), main);
|
||||
assert.deepEqual(largestGeoJsonRing({ type: 'Point', coordinates: [84, 28] }), []);
|
||||
});
|
||||
|
||||
test('Bhote Koshi locator reveals a border progressively', () => {
|
||||
const positions = [
|
||||
new Cesium.Cartesian3(0, 0, 0),
|
||||
new Cesium.Cartesian3(10, 0, 0),
|
||||
new Cesium.Cartesian3(20, 0, 0),
|
||||
];
|
||||
assert.deepEqual(revealPolylinePositions(positions, 0), [positions[0], positions[0]]);
|
||||
assert.deepEqual(revealPolylinePositions(positions, 0.25), [
|
||||
positions[0],
|
||||
new Cesium.Cartesian3(5, 0, 0),
|
||||
]);
|
||||
assert.equal(revealPolylinePositions(positions, 1), positions);
|
||||
});
|
||||
|
||||
test('Bhote Koshi locator stages its anchored callout from dot to leader to text', () => {
|
||||
assert.deepEqual(anchoredCalloutPresentation(0), {
|
||||
alpha: 0,
|
||||
leaderProgress: 0,
|
||||
contentAlpha: 0,
|
||||
scale: 0.96,
|
||||
});
|
||||
assert.deepEqual(anchoredCalloutPresentation(1200), {
|
||||
alpha: 1,
|
||||
leaderProgress: 1,
|
||||
contentAlpha: 1,
|
||||
scale: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test('Bhote Koshi regional locator sequences Nepal, border, then the incident', async () => {
|
||||
const viewer = viewerFixture();
|
||||
const overlayHost = overlayHostFixture();
|
||||
const renders = [];
|
||||
let nowMs = 0;
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
boundaryResolver: async () => ({
|
||||
name: 'Nepal',
|
||||
ring: [[80, 26], [88, 26], [88, 30], [80, 30]],
|
||||
}),
|
||||
requestRender: (reason) => renders.push(reason),
|
||||
overlayHost,
|
||||
now: () => nowMs,
|
||||
...nonRunningAnimation(),
|
||||
});
|
||||
|
||||
assert.equal(await layer.init(viewer), true);
|
||||
assert.equal(await layer.enable(viewer, { origin: 'scene' }), true);
|
||||
assert.equal(viewer._test.added.length, 1);
|
||||
const source = viewer._test.added[0];
|
||||
const ids = source.entities.values.map((entity) => entity.id);
|
||||
for (const id of [
|
||||
'nepal-context-halo',
|
||||
'nepal-border-fill',
|
||||
'nepal-border-highlight',
|
||||
'bhote-koshi-incident-halo',
|
||||
]) {
|
||||
assert.ok(ids.includes(id), `${id} belongs to the regional sequence`);
|
||||
}
|
||||
const incidentHalo = source.entities.getById('bhote-koshi-incident-halo');
|
||||
const border = source.entities.getById('nepal-border-highlight');
|
||||
const publication = overlayHost._test.publications.at(-1);
|
||||
assert.equal(publication.sourceId, BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID);
|
||||
assert.equal(publication.entries.length, 2);
|
||||
const nepal = publication.entries.find(({ id }) => id === 'nepal-context-callout');
|
||||
const incident = publication.entries.find(({ id }) => id === 'bhote-koshi-incident-callout');
|
||||
assert.equal(nepal.variant, 'label');
|
||||
assert.equal(nepal.title, 'NEPAL');
|
||||
assert.equal(nepal.anchorDot, true);
|
||||
assert.equal(incident.variant, 'card');
|
||||
assert.equal(incident.paintLane, 'thumbnail');
|
||||
assert.equal(incident.title, 'BHOTE KOSHI FLOOD INCIDENT');
|
||||
assert.deepEqual(incident.details, ['NEPAL · 26 AUG 2026']);
|
||||
assert.equal(incident.anchorDot, true);
|
||||
assert.equal(incident.leaderStyle, 'elbow');
|
||||
assert.equal('image' in incident, false);
|
||||
assert.equal('requireImage' in incident, false);
|
||||
assert.equal(incident.sourceAlpha(), 0);
|
||||
assert.equal(incident.leaderProgress(), 0);
|
||||
assert.equal(incident.contentAlpha(), 0);
|
||||
assert.equal(incident.presentationScale(), 0.96);
|
||||
assert.equal(nepal.sourceAlpha(), 0);
|
||||
assert.ok(border.polyline.positions.getValue().length < 5);
|
||||
assert.equal(incidentHalo.show.getValue(), false);
|
||||
assert.equal(incidentHalo.point.pixelSize.getValue(), 16);
|
||||
nowMs = 3100;
|
||||
assert.equal(nepal.sourceAlpha(), 1);
|
||||
assert.equal(border.polyline.positions.getValue().length, 5);
|
||||
assert.equal(incidentHalo.show.getValue(), false);
|
||||
nowMs = 3300;
|
||||
assert.equal(incidentHalo.show.getValue(), true);
|
||||
assert.equal(incident.sourceAlpha(), 0);
|
||||
nowMs = 4500;
|
||||
assert.equal(incident.sourceAlpha(), 1);
|
||||
assert.equal(incident.leaderProgress(), 1);
|
||||
assert.equal(incident.contentAlpha(), 1);
|
||||
assert.equal(incident.presentationScale(), 1);
|
||||
assert.equal(layer.getStats().status, 'nominal');
|
||||
assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION });
|
||||
assert.ok(renders.includes('bhote-koshi-locator-boundary'));
|
||||
|
||||
assert.equal(await layer.disable(), true);
|
||||
assert.equal(viewer._test.removed.length, 1);
|
||||
assert.equal(overlayHost._test.clears.at(-1), BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID);
|
||||
assert.equal(layer.getStats().status, 'idle');
|
||||
});
|
||||
|
||||
test('Bhote Koshi locator switches between Nepal context and the regional incident treatment', async () => {
|
||||
const viewer = viewerFixture();
|
||||
const overlayHost = overlayHostFixture();
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
boundaryResolver: async () => ({
|
||||
name: 'Nepal',
|
||||
ring: [[80, 26], [88, 26], [88, 30], [80, 30]],
|
||||
}),
|
||||
requestRender() {},
|
||||
overlayHost,
|
||||
...nonRunningAnimation(),
|
||||
});
|
||||
|
||||
layer.setParams({ presentation: NEPAL_CONTEXT_PRESENTATION });
|
||||
await layer.enable(viewer);
|
||||
assert.deepEqual(viewer._test.added[0].entities.values.map((entity) => entity.id), [
|
||||
'nepal-context-halo',
|
||||
]);
|
||||
const nepalLabel = overlayHost._test.publications.at(-1).entries[0];
|
||||
assert.equal(nepalLabel.variant, 'label');
|
||||
assert.equal(nepalLabel.placement, 'right');
|
||||
assert.equal(nepalLabel.anchorDot, true);
|
||||
assert.equal(layer.getStats().coverage, 'Nepal context');
|
||||
assert.deepEqual(layer.getRowControls().chips.map(({ id, active }) => ({ id, active })), [
|
||||
{ id: 'nepal-context', active: true },
|
||||
{ id: 'bhote-koshi-regional', active: false },
|
||||
{ id: 'bhote-koshi-city-context', active: false },
|
||||
{ id: 'bhote-koshi-incident-places', active: false },
|
||||
{ id: 'bhote-koshi-flood-path', active: false },
|
||||
{ id: 'bhote-koshi-path-overview', active: false },
|
||||
{ id: 'bhote-koshi-trigger-record', active: false },
|
||||
]);
|
||||
|
||||
layer.setParams({ presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION });
|
||||
assert.ok(viewer._test.added[0].entities.getById('nepal-border-highlight'));
|
||||
assert.equal(viewer._test.added[0].entities.getById('bhote-koshi-city-kathmandu'), undefined);
|
||||
assert.equal(
|
||||
overlayHost._test.publications.at(-1).entries
|
||||
.find(({ id }) => id === 'bhote-koshi-incident-callout').variant,
|
||||
'card',
|
||||
);
|
||||
|
||||
layer.setParams({ presentation: NEPAL_CONTEXT_PRESENTATION });
|
||||
assert.deepEqual(viewer._test.added[0].entities.values.map((entity) => entity.id), [
|
||||
'nepal-context-halo',
|
||||
]);
|
||||
await layer.destroy();
|
||||
});
|
||||
|
||||
test('Incident Corridor reveals all fifteen overview pins and keeps layout active through the sequence', async () => {
|
||||
const viewer = viewerFixture();
|
||||
const overlayHost = overlayHostFixture();
|
||||
let nowMs = 0;
|
||||
let pulseFrame = null;
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
boundaryResolver: async () => null, requestRender() {},
|
||||
overlayHost,
|
||||
now: () => nowMs,
|
||||
scheduleFrame(callback) { pulseFrame = callback; return 1; },
|
||||
cancelFrame() {},
|
||||
});
|
||||
layer.setParams({ presentation: BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION });
|
||||
await layer.enable(viewer);
|
||||
const source = viewer._test.added[0];
|
||||
const event = JSON.parse(await readFile(new URL('../../public/events/bhote-koshi-2026/event.json', import.meta.url), 'utf8'));
|
||||
assert.equal(event.evidenceSpine.length, 16);
|
||||
assert.equal(event.evidenceSpine.at(-1).id, 'charaudi');
|
||||
assert.equal(source.entities.values.length, 15);
|
||||
const labels = overlayHost._test.publications.at(-1).entries;
|
||||
assert.equal(labels.length, 15);
|
||||
assert.equal(overlayHost._test.publications.at(-1).options.moving, true);
|
||||
for (const [index, point] of INCIDENT_OVERVIEW_PLACES.entries()) {
|
||||
const id = `bhote-koshi-place-${point.id}`;
|
||||
const pin = labels.find((entry) => entry.id === id);
|
||||
assert.ok(pin, point.id);
|
||||
assert.equal(pin.title, `${String(index + 1).padStart(2, '0')} · ${point.title}`);
|
||||
assert.equal(pin.variant, 'label');
|
||||
const location = Cesium.Cartographic.fromCartesian(pin.position);
|
||||
assert.ok(Math.abs(Cesium.Math.toDegrees(location.latitude) - point.lat) < 1e-6);
|
||||
assert.ok(Math.abs(Cesium.Math.toDegrees(location.longitude) - point.lon) < 1e-6);
|
||||
nowMs = 349 + index * 650;
|
||||
assert.equal(pin.sourceAlpha(), 0);
|
||||
nowMs += 1;
|
||||
assert.equal(pin.sourceAlpha(), 0);
|
||||
assert.equal(source.entities.values.filter(entity => entity.id.endsWith('-halo') && entity.show.getValue()).length, 1);
|
||||
nowMs += 520;
|
||||
assert.equal(pin.sourceAlpha(), 1);
|
||||
}
|
||||
assert.ok(nowMs < INCIDENT_OVERVIEW_HOLD_SEC * 1000);
|
||||
// A repeat halo cycle must not erase already revealed labels.
|
||||
nowMs = 350 + 15 * 650;
|
||||
assert.equal(source.entities.getById('bhote-koshi-place-immediate-collapse-viewpoint-halo').show.getValue(), true);
|
||||
assert.equal(labels.find(({ id }) => id === 'bhote-koshi-place-devighat-taadi-khola-bridge').sourceAlpha(), 1);
|
||||
assert.equal(labels.some(({ id }) => id === 'bhote-koshi-place-charaudi'), false);
|
||||
assert.equal(typeof pulseFrame, 'function');
|
||||
pulseFrame();
|
||||
assert.equal(overlayHost._test.publications.at(-1).options.moving, false);
|
||||
layer.setParams({ presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION });
|
||||
assert.equal(source.entities.values.some(entity => entity.id.startsWith('bhote-koshi-place-')), false);
|
||||
await layer.destroy();
|
||||
});
|
||||
|
||||
test('Bhote Koshi flood path draws a solid cyan sourced route after the place shot', async () => {
|
||||
const viewer = viewerFixture();
|
||||
const overlayHost = overlayHostFixture();
|
||||
let nowMs = 0;
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
boundaryResolver: async () => null,
|
||||
requestRender() {},
|
||||
overlayHost,
|
||||
now: () => nowMs,
|
||||
...nonRunningAnimation(),
|
||||
});
|
||||
|
||||
layer.setParams({ presentation: BHOTE_KOSHI_FLOOD_PATH_PRESENTATION });
|
||||
await layer.enable(viewer);
|
||||
const source = viewer._test.added[0];
|
||||
const path = source.entities.getById('bhote-koshi-flood-path');
|
||||
const labels = overlayHost._test.publications.at(-1).entries;
|
||||
const trigger = labels.find(({ id }) => id === 'bhote-koshi-place-immediate-collapse-viewpoint');
|
||||
const bidur = labels.find(({ id }) => id === 'bhote-koshi-place-bidur-trishuli-bridge');
|
||||
|
||||
assert.equal(source.entities.values.length, 1);
|
||||
assert.equal(labels.length, 15);
|
||||
assert.equal(bidur.title, '14 · BIDUR BRIDGE');
|
||||
for (const place of INCIDENT_OVERVIEW_PLACES) {
|
||||
const pin = labels.find(({ id }) => id === `bhote-koshi-place-${place.id}`);
|
||||
assert.equal(pin.sourceAlpha(), 1, place.id);
|
||||
}
|
||||
assert.equal(path.polyline.clampToGround.getValue(), true);
|
||||
assert.ok(path.polyline.material instanceof Cesium.ColorMaterialProperty);
|
||||
assert.ok(path.polyline.material.color.getValue().equalsEpsilon(
|
||||
Cesium.Color.fromCssColorString('#20e7f2').withAlpha(0.96),
|
||||
Cesium.Math.EPSILON7,
|
||||
));
|
||||
assert.equal(path.show.getValue(), false);
|
||||
assert.equal(trigger.sourceAlpha(), 1);
|
||||
assert.equal(bidur.sourceAlpha(), 1);
|
||||
|
||||
nowMs = 250;
|
||||
assert.equal(path.show.getValue(), true);
|
||||
assert.equal(path.polyline.positions.getValue().length, 2);
|
||||
nowMs = 2250;
|
||||
const halfwayCount = path.polyline.positions.getValue().length;
|
||||
assert.ok(Math.abs(halfwayCount - BHOTE_KOSHI_FLOOD_PATH.length / 2) <= 2);
|
||||
nowMs = 4250;
|
||||
assert.equal(path.polyline.positions.getValue().length, BHOTE_KOSHI_FLOOD_PATH.length);
|
||||
assert.deepEqual(BHOTE_KOSHI_FLOOD_PATH[0], [85.48476, 28.33255]);
|
||||
// More observation pins must not extend or straighten the sourced route.
|
||||
assert.deepEqual(path.polyline.positions.getValue(), BHOTE_KOSHI_FLOOD_PATH.map(([lon, lat]) => (
|
||||
Cesium.Cartesian3.fromDegrees(lon, lat)
|
||||
)));
|
||||
assert.deepEqual(BHOTE_KOSHI_FLOOD_PATH.at(-1), [85.14943, 27.92177]);
|
||||
assert.equal(layer.getStats().coverage, 'Bhote Koshi · Flood path');
|
||||
assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_FLOOD_PATH_PRESENTATION });
|
||||
await layer.destroy();
|
||||
});
|
||||
|
||||
test('Bhote Koshi path-overview presentation keeps the route settled without Nepal border', async () => {
|
||||
const viewer = viewerFixture();
|
||||
const overlayHost = overlayHostFixture();
|
||||
let nowMs = 0;
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
boundaryResolver: async () => ({
|
||||
name: 'Nepal',
|
||||
ring: [[80, 26], [88, 26], [88, 30], [80, 30]],
|
||||
}),
|
||||
requestRender() {},
|
||||
overlayHost,
|
||||
now: () => nowMs,
|
||||
...nonRunningAnimation(),
|
||||
});
|
||||
|
||||
layer.setParams({ presentation: BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION });
|
||||
await layer.enable(viewer);
|
||||
const source = viewer._test.added[0];
|
||||
const path = source.entities.getById('bhote-koshi-flood-path');
|
||||
const labels = overlayHost._test.publications.at(-1).entries;
|
||||
|
||||
assert.equal(source.entities.values.length, 1);
|
||||
assert.equal(labels.length, 15);
|
||||
for (const [index, place] of INCIDENT_OVERVIEW_PLACES.entries()) {
|
||||
const pin = labels.find(({ id }) => id === `bhote-koshi-place-${place.id}`);
|
||||
assert.equal(pin.title, `${String(index + 1).padStart(2, '0')} · ${place.title}`);
|
||||
assert.equal(pin.sourceAlpha(), 1);
|
||||
}
|
||||
assert.equal(path.show.getValue(), true);
|
||||
assert.equal(path.polyline.positions.getValue().length, BHOTE_KOSHI_FLOOD_PATH.length);
|
||||
assert.ok(Cesium.Cartesian3.equals(path.polyline.positions.getValue()[0],
|
||||
Cesium.Cartesian3.fromDegrees(85.48476, 28.33255)));
|
||||
assert.equal(source.entities.getById('nepal-border-fill'), undefined);
|
||||
assert.equal(source.entities.getById('nepal-border-highlight'), undefined);
|
||||
assert.equal(
|
||||
source.entities.getById('bhote-koshi-place-immediate-collapse-viewpoint-halo'),
|
||||
undefined,
|
||||
);
|
||||
assert.equal(layer.getStats().coverage, 'Bhote Koshi · Path overview');
|
||||
assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION });
|
||||
await layer.destroy();
|
||||
});
|
||||
|
||||
test('Bhote Koshi trigger-record presentation reveals sourced incident details', async () => {
|
||||
const viewer = viewerFixture();
|
||||
const overlayHost = overlayHostFixture();
|
||||
let nowMs = 0;
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
boundaryResolver: async () => null,
|
||||
requestRender() {},
|
||||
overlayHost,
|
||||
now: () => nowMs,
|
||||
...nonRunningAnimation(),
|
||||
});
|
||||
|
||||
layer.setParams({
|
||||
presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
|
||||
preview: true,
|
||||
});
|
||||
await layer.enable(viewer);
|
||||
const source = viewer._test.added[0];
|
||||
const publication = overlayHost._test.publications.at(-1);
|
||||
const record = publication.entries[0];
|
||||
|
||||
assert.deepEqual(source.entities.values.map(({ id }) => id), [
|
||||
'bhote-koshi-flood-path',
|
||||
'bhote-koshi-trigger-record-halo',
|
||||
]);
|
||||
assert.equal(record.title, 'USGS INITIAL REPORT');
|
||||
assert.deepEqual(record.details, [
|
||||
'26 AUG 2026 · 08:37 NPT',
|
||||
'28.2710° N · 85.5150° E',
|
||||
'INITIAL M4.4 · RECLASSIFIED M5.2 LANDSLIDE',
|
||||
'USGS EVENT us7000tbwb · OPEN SOURCE',
|
||||
]);
|
||||
assert.equal(record.interactive, true);
|
||||
assert.equal(typeof record.activate, 'function');
|
||||
assert.equal(record.sourceAlpha(), 0);
|
||||
nowMs = 1200;
|
||||
assert.equal(record.sourceAlpha(), 1);
|
||||
assert.equal(source.entities.getById('nepal-border-highlight'), undefined);
|
||||
assert.equal(layer.getStats().coverage, 'Bhote Koshi · Trigger record');
|
||||
assert.deepEqual(layer.getParams(), {
|
||||
presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
|
||||
});
|
||||
await layer.destroy();
|
||||
});
|
||||
|
||||
test('Bhote Koshi trigger-record waits for the scene camera, zooms, then orbits', async () => {
|
||||
const viewer = viewerFixture();
|
||||
const overlayHost = overlayHostFixture();
|
||||
const moveStart = new Cesium.Event();
|
||||
const moveEnd = new Cesium.Event();
|
||||
const flights = [];
|
||||
let armedCallback = null;
|
||||
viewer.camera = {
|
||||
moveStart,
|
||||
moveEnd,
|
||||
flyTo(options) { flights.push(options); },
|
||||
cancelFlight() {},
|
||||
};
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
boundaryResolver: async () => null,
|
||||
requestRender() {},
|
||||
overlayHost,
|
||||
scheduleDelay(callback) {
|
||||
armedCallback = callback;
|
||||
return 1;
|
||||
},
|
||||
cancelDelay() {},
|
||||
...nonRunningAnimation(),
|
||||
});
|
||||
|
||||
layer.setParams({ presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION });
|
||||
await layer.enable(viewer);
|
||||
assert.equal(flights.length, 0);
|
||||
assert.equal(typeof armedCallback, 'function');
|
||||
moveStart.raiseEvent();
|
||||
assert.equal(flights.length, 0);
|
||||
moveEnd.raiseEvent();
|
||||
assert.equal(flights.length, 1);
|
||||
assert.equal(flights[0].duration, 4.6);
|
||||
const destination = Cesium.Cartographic.fromCartesian(flights[0].destination);
|
||||
assert.ok(Math.abs(Cesium.Math.toDegrees(destination.latitude) - 28.368) < 1e-6);
|
||||
assert.ok(Math.abs(Cesium.Math.toDegrees(destination.longitude) - 85.473) < 1e-6);
|
||||
assert.ok(Math.abs(destination.height - 10526) < 1e-4);
|
||||
flights[0].complete();
|
||||
assert.equal(flights.length, 2);
|
||||
assert.equal(flights[1].duration, 2.8);
|
||||
const orbit = Cesium.Cartographic.fromCartesian(flights[1].destination);
|
||||
assert.ok(Math.abs(Cesium.Math.toDegrees(orbit.latitude) - 28.3769) < 1e-6);
|
||||
assert.ok(Math.abs(Cesium.Math.toDegrees(orbit.longitude) - 85.4923) < 1e-6);
|
||||
assert.ok(Math.abs(orbit.height - 10526) < 1e-4);
|
||||
await layer.destroy();
|
||||
});
|
||||
|
||||
for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
|
||||
test(`Scene Stop revokes locator ${phase} and stale callbacks cannot own a successor`, async () => {
|
||||
const viewer = viewerFixture();
|
||||
const timers = new Map();
|
||||
const callbacks = [];
|
||||
const flights = [];
|
||||
let nextTimer = 0;
|
||||
const moveStart = new Cesium.Event();
|
||||
const moveEnd = new Cesium.Event();
|
||||
viewer.camera = {
|
||||
moveStart, moveEnd,
|
||||
flyTo(options) { flights.push(options); },
|
||||
cancelFlight() {},
|
||||
};
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
boundaryResolver: async () => null,
|
||||
overlayHost: overlayHostFixture(),
|
||||
requestRender() {},
|
||||
scheduleDelay(callback) {
|
||||
callbacks.push(callback);
|
||||
timers.set(++nextTimer, callback);
|
||||
return nextTimer;
|
||||
},
|
||||
cancelDelay(id) { timers.delete(id); },
|
||||
...nonRunningAnimation(),
|
||||
});
|
||||
const director = {
|
||||
dataManager: { layers: new Map([[layer.id, { module: layer }]]) },
|
||||
viewer,
|
||||
_sceneSeekGeneration: 0,
|
||||
_loadGeneration: 0,
|
||||
_cancelActiveSceneTravel: SceneDirector.prototype._cancelActiveSceneTravel,
|
||||
};
|
||||
try {
|
||||
layer.setParams({ presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION });
|
||||
await layer.enable(viewer);
|
||||
if (phase === 'move-end') moveStart.raiseEvent();
|
||||
if (phase === 'approach' || phase === 'orbit') callbacks[0]();
|
||||
if (phase === 'orbit') flights[0].complete();
|
||||
const oldFlights = [...flights];
|
||||
SceneDirector.prototype.stopScene.call(director);
|
||||
assert.equal(timers.size, 0, 'Stop cancels the actual delayed work');
|
||||
assert.equal(moveStart.numberOfListeners, 0);
|
||||
assert.equal(moveEnd.numberOfListeners, 0);
|
||||
callbacks[0]();
|
||||
moveEnd.raiseEvent();
|
||||
for (const flight of oldFlights) flight.complete();
|
||||
assert.equal(flights.length, oldFlights.length, 'no post-Stop approach or orbit');
|
||||
assert.equal(layer.getStats().count, 1, 'Stop preserves the visible locator');
|
||||
|
||||
layer.setParams({ presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION });
|
||||
callbacks.at(-1)();
|
||||
const successor = flights.at(-1);
|
||||
const count = flights.length;
|
||||
callbacks[0]();
|
||||
for (const flight of oldFlights) { flight.cancel(); flight.complete(); }
|
||||
assert.equal(flights.length, count, 'old callbacks cannot add a flight');
|
||||
successor.complete();
|
||||
assert.equal(flights.length, count + 1, 'successor retains its own orbit');
|
||||
} finally { await layer.destroy(); }
|
||||
});
|
||||
}
|
||||
|
||||
test('Bhote Koshi locator keeps the regional context settled while nearby cities stagger in', async () => {
|
||||
const viewer = viewerFixture();
|
||||
const overlayHost = overlayHostFixture();
|
||||
let nowMs = 0;
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
boundaryResolver: async () => ({
|
||||
name: 'Nepal',
|
||||
ring: [[80, 26], [88, 26], [88, 30], [80, 30]],
|
||||
}),
|
||||
requestRender() {},
|
||||
overlayHost,
|
||||
now: () => nowMs,
|
||||
...nonRunningAnimation(),
|
||||
});
|
||||
|
||||
layer.setParams({ presentation: BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION });
|
||||
await layer.enable(viewer);
|
||||
const source = viewer._test.added[0];
|
||||
const borderFill = source.entities.getById('nepal-border-fill');
|
||||
const entries = overlayHost._test.publications.at(-1).entries;
|
||||
const incident = entries.find(({ id }) => id === 'bhote-koshi-incident-callout');
|
||||
const kathmandu = entries.find(({ id }) => id === 'bhote-koshi-city-kathmandu');
|
||||
const bhaktapur = entries.find(({ id }) => id === 'bhote-koshi-city-bhaktapur');
|
||||
const dhulikhel = entries.find(({ id }) => id === 'bhote-koshi-city-dhulikhel');
|
||||
|
||||
assert.equal(borderFill.show.getValue(), true);
|
||||
assert.equal(incident.contentAlpha(), 1);
|
||||
assert.equal(kathmandu.title, 'KATHMANDU');
|
||||
assert.equal(bhaktapur.title, 'BHAKTAPUR');
|
||||
assert.equal(dhulikhel.title, 'DHULIKHEL');
|
||||
assert.ok([kathmandu, bhaktapur, dhulikhel].every(({ variant, anchorDot }) => (
|
||||
variant === 'label' && anchorDot === true
|
||||
)));
|
||||
assert.equal(kathmandu.sourceAlpha(), 0);
|
||||
assert.equal(bhaktapur.sourceAlpha(), 0);
|
||||
assert.equal(dhulikhel.sourceAlpha(), 0);
|
||||
|
||||
nowMs = 350;
|
||||
assert.equal(kathmandu.sourceAlpha(), 0);
|
||||
assert.equal(bhaktapur.sourceAlpha(), 0);
|
||||
nowMs = 850;
|
||||
assert.equal(kathmandu.sourceAlpha(), 1);
|
||||
assert.ok(bhaktapur.sourceAlpha() > 0);
|
||||
assert.ok(dhulikhel.sourceAlpha() > 0);
|
||||
nowMs = 1300;
|
||||
assert.equal(dhulikhel.sourceAlpha(), 1);
|
||||
assert.equal(layer.getStats().coverage, 'Bhote Koshi · Nearby cities');
|
||||
assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION });
|
||||
await layer.destroy();
|
||||
});
|
||||
|
||||
test('Bhote Koshi locator degrades to labels when the regional boundary is unavailable', async () => {
|
||||
const viewer = viewerFixture();
|
||||
const overlayHost = overlayHostFixture();
|
||||
const originalWarn = console.warn;
|
||||
console.warn = () => {};
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
boundaryResolver: async () => null,
|
||||
requestRender() {},
|
||||
overlayHost,
|
||||
...nonRunningAnimation(),
|
||||
});
|
||||
try {
|
||||
await layer.enable(viewer);
|
||||
const ids = viewer._test.added[0].entities.values.map((entity) => entity.id);
|
||||
assert.ok(ids.includes('nepal-context-halo'));
|
||||
assert.ok(ids.includes('bhote-koshi-incident-halo'));
|
||||
assert.equal(ids.includes('bhote-koshi-city-kathmandu'), false);
|
||||
assert.equal(ids.includes('nepal-border-highlight'), false);
|
||||
assert.deepEqual(overlayHost._test.publications.at(-1).entries.map(({ id }) => id), [
|
||||
'nepal-context-callout',
|
||||
'bhote-koshi-incident-callout',
|
||||
]);
|
||||
assert.equal(layer.getStats().status, 'degraded');
|
||||
} finally {
|
||||
console.warn = originalWarn;
|
||||
await layer.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('Bhote Koshi regional locator waits for its boundary before starting the full sequence', async () => {
|
||||
const viewer = viewerFixture();
|
||||
const overlayHost = overlayHostFixture();
|
||||
let releaseBoundary;
|
||||
const boundary = new Promise((resolve) => { releaseBoundary = resolve; });
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
boundaryResolver: async () => boundary,
|
||||
requestRender() {},
|
||||
overlayHost,
|
||||
...nonRunningAnimation(),
|
||||
});
|
||||
|
||||
const enabling = layer.enable(viewer);
|
||||
assert.ok(viewer._test.added[0].entities.getById('nepal-context-halo'));
|
||||
assert.equal(viewer._test.added[0].entities.getById('nepal-border-highlight'), undefined);
|
||||
assert.equal(viewer._test.added[0].entities.getById('bhote-koshi-incident-halo'), undefined);
|
||||
assert.equal(overlayHost._test.publications.length, 1);
|
||||
assert.deepEqual(overlayHost._test.publications[0].entries.map(({ id }) => id), [
|
||||
'nepal-context-callout',
|
||||
]);
|
||||
|
||||
releaseBoundary({ ring: [[80, 26], [88, 26], [88, 30], [80, 30]] });
|
||||
await enabling;
|
||||
assert.ok(viewer._test.added[0].entities.getById('bhote-koshi-incident-halo'));
|
||||
assert.ok(viewer._test.added[0].entities.getById('nepal-border-highlight'));
|
||||
assert.equal(viewer._test.added[0].entities.getById('bhote-koshi-city-kathmandu'), undefined);
|
||||
assert.equal(overlayHost._test.publications.length, 2);
|
||||
assert.deepEqual(overlayHost._test.publications.at(-1).entries.map(({ id }) => id), [
|
||||
'nepal-context-callout',
|
||||
'bhote-koshi-incident-callout',
|
||||
]);
|
||||
await layer.destroy();
|
||||
});
|
||||
|
||||
test('Bhote Koshi locator cancels a stale boundary resolution and removes its surface', async () => {
|
||||
const viewer = viewerFixture();
|
||||
const controller = new AbortController();
|
||||
let releaseBoundary;
|
||||
const boundary = new Promise((resolve) => { releaseBoundary = resolve; });
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
boundaryResolver: async () => boundary,
|
||||
requestRender() {},
|
||||
...nonRunningAnimation(),
|
||||
});
|
||||
const enabling = layer.enable(viewer, { signal: controller.signal });
|
||||
controller.abort();
|
||||
releaseBoundary({ ring: [[80, 26], [88, 26], [88, 30]] });
|
||||
await assert.rejects(enabling, (error) => error?.name === 'AbortError');
|
||||
assert.equal(viewer._test.removed.length, 1);
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
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',
|
||||
]);
|
||||
|
||||
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);
|
||||
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; }
|
||||
});
|
||||
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 result = new Map();
|
||||
const add = (id, from, to, { joinComparison = false } = {}) => {
|
||||
let start = nearestVertex(from, route);
|
||||
const end = nearestVertex(to, route);
|
||||
if (start < 0 || end <= start) return;
|
||||
const startHeight = from.fallbackElevationM ?? from.elevationM;
|
||||
const endHeight = to.fallbackElevationM ?? to.elevationM;
|
||||
if (!Number.isFinite(startHeight) || !Number.isFinite(endHeight)) return;
|
||||
if (joinComparison) {
|
||||
// The raster corridor ends between simplified centreline vertices.
|
||||
// Snapping to its nearest vertex can move the head upstream on entry.
|
||||
// Keep that already-sourced endpoint and continue to the next vertex
|
||||
// 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);
|
||||
if (along > 0) start++;
|
||||
}
|
||||
const coordinates = route.slice(start, end + 1);
|
||||
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),
|
||||
})));
|
||||
};
|
||||
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);
|
||||
add(id, previous, point, { joinComparison: id === 'dhunche' });
|
||||
// Missing coverage breaks continuity rather than inventing a long connector.
|
||||
previous = point;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { buildSourceShotPaths, SOURCE_PATH_BEAT_IDS } from './bhoteKoshiShotPaths.js';
|
||||
import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js';
|
||||
|
||||
const event = JSON.parse(await readFile(new URL('../../public/events/bhote-koshi-2026/event.json', import.meta.url)));
|
||||
|
||||
test('overview and evidence paths start at the lake reach, never the earlier trigger reach', () => {
|
||||
const lakeStart = [85.48476, 28.33255];
|
||||
assert.deepEqual(BHOTE_KOSHI_FLOOD_PATH[0], lakeStart);
|
||||
assert.equal(BHOTE_KOSHI_FLOOD_PATH.some(([lon, lat]) => lon === 85.5306 && lat === 28.2916), false);
|
||||
const paths = buildSourceShotPaths(event);
|
||||
const first = paths.get('debris-dammed-lake')[0];
|
||||
assert.deepEqual([first.lon, first.lat], lakeStart);
|
||||
assert.deepEqual(BHOTE_KOSHI_FLOOD_PATH.slice(0, paths.get('debris-dammed-lake').length),
|
||||
paths.get('debris-dammed-lake').map(({lon, lat}) => [lon, lat]));
|
||||
});
|
||||
|
||||
test('ten shot paths use only the existing centreline and join at shared endpoints', () => {
|
||||
const paths = buildSourceShotPaths(event);
|
||||
assert.deepEqual([...paths.keys()], SOURCE_PATH_BEAT_IDS);
|
||||
const vertices = new Set(BHOTE_KOSHI_FLOOD_PATH.map(([lon, lat]) => `${lon},${lat}`));
|
||||
const boundary = event.reconstruction.corridor.at(-1);
|
||||
vertices.add(`${boundary.lon},${boundary.lat}`); // Exact sourced comparison handoff.
|
||||
for (const points of paths.values()) {
|
||||
assert.ok(points.length >= 2);
|
||||
assert.ok(points.every(({ lon, lat, fallbackElevationM }) => (
|
||||
vertices.has(`${lon},${lat}`) && Number.isFinite(fallbackElevationM)
|
||||
)));
|
||||
}
|
||||
const coordinate = (point) => [point.lon, point.lat];
|
||||
for (const ids of [SOURCE_PATH_BEAT_IDS.slice(0, 2), SOURCE_PATH_BEAT_IDS.slice(2)]) {
|
||||
for (let index = 1; index < ids.length; index++) {
|
||||
assert.deepEqual(coordinate(paths.get(ids[index - 1]).at(-1)), coordinate(paths.get(ids[index])[0]));
|
||||
}
|
||||
}
|
||||
assert.deepEqual(coordinate(paths.get('bidur-trishuli-bridge').at(-1)), BHOTE_KOSHI_FLOOD_PATH.at(-1));
|
||||
for (const id of ['immediate-collapse-viewpoint', 'devighat-taadi-khola-bridge', 'charaudi']) {
|
||||
assert.equal(paths.has(id), false, 'out-of-coverage media must not gain a fabricated river route');
|
||||
}
|
||||
});
|
||||
|
||||
test('missing and distant observations fail closed without connecting witness coordinates', () => {
|
||||
const broken = structuredClone(event);
|
||||
broken.evidenceSpine.find(({ id }) => id === 'dhunche').lon = 0;
|
||||
const paths = buildSourceShotPaths(broken);
|
||||
assert.equal(paths.has('dhunche'), false);
|
||||
assert.equal(paths.has('mailung-upper-trishuli'), false);
|
||||
assert.equal(buildSourceShotPaths({}).size, 0);
|
||||
});
|
||||
|
||||
test('Dhunche starts at the exact comparison endpoint without stepping upstream first', () => {
|
||||
const boundary = event.reconstruction.corridor.at(-1);
|
||||
const path = buildSourceShotPaths(event).get('dhunche');
|
||||
assert.deepEqual([path[0].lon, path[0].lat], [boundary.lon, boundary.lat]);
|
||||
assert.ok(path[1].lat < boundary.lat, 'the first new vertex is downstream of the comparison endpoint');
|
||||
});
|
||||
@@ -312,6 +312,28 @@ export function transitFeedCredit(feed) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Registered when the Bhote Koshi event reconstruction activates. */
|
||||
export const BHOTE_KOSHI_CREDIT = {
|
||||
key: 'bhote-koshi-2026',
|
||||
html:
|
||||
'Bhote Koshi 2026 event imagery and derived reconstruction: ' +
|
||||
'<a href="https://vantor.com/company/open-data-program" target="_blank" rel="noopener">Vantor Open Data</a> ' +
|
||||
'and <a href="https://github.com/geo-pera/bhotekoshi-2026-reconstruction" target="_blank" rel="noopener">GeoPera</a> ' +
|
||||
'(CC BY-NC 4.0); terrain © Re:Earth / Mapterhorn (CC BY 4.0)',
|
||||
};
|
||||
|
||||
/** Registered when the scene-friendly Nepal incident locator activates. */
|
||||
export const BHOTE_KOSHI_LOCATOR_CREDIT = {
|
||||
key: 'bhote-koshi-locator',
|
||||
html:
|
||||
'Nepal administrative boundary and nearby-city locations: ' +
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
|
||||
'(ODbL 1.0); incident-place anchors use an owner-curated, source-verified geolocation union; ' +
|
||||
'flood corridor derived from the ' +
|
||||
'<a href="https://github.com/geo-pera/bhotekoshi-2026-reconstruction/blob/main/vectors/river_centerline.geojson" target="_blank" rel="noopener">GeoPera river centerline</a> ' +
|
||||
'(CC BY-NC 4.0)',
|
||||
};
|
||||
|
||||
/** @type {Set<string>} Keys of dynamic credits already registered this session. */
|
||||
const _dynamicCreditKeys = new Set();
|
||||
|
||||
|
||||
@@ -277,6 +277,8 @@ export const SHARE_TRACKING_RESTORE_POLICIES = Object.freeze({
|
||||
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: 'bikeshare', token: 'b', disposition: 'enabled-only' }),
|
||||
Object.freeze({ id: 'cctv', token: 'c', disposition: 'enabled+options', optionOwner: 'cctv' }),
|
||||
Object.freeze({ id: 'directions', token: 'n', disposition: 'enabled-only' }),
|
||||
|
||||
@@ -158,8 +158,9 @@ function encode(state) {
|
||||
|
||||
test('production registry is exact, canonical, and rejects incomplete contracts', async () => {
|
||||
assert.equal(validateLayerStateRegistry(), true);
|
||||
assert.equal(REGISTERED_LAYER_IDS.length, 19);
|
||||
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 19);
|
||||
assert.equal(REGISTERED_LAYER_IDS.length, 21);
|
||||
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 21);
|
||||
assert.ok(REGISTERED_LAYER_IDS.includes('transit'));
|
||||
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
|
||||
assert.throws(
|
||||
() => validateLayerStateRegistry([...LAYER_STATE_REGISTRY, LAYER_STATE_REGISTRY[0]]),
|
||||
@@ -226,8 +227,14 @@ test('v2 codec distinguishes absent from empty and keeps canonical deterministic
|
||||
});
|
||||
|
||||
test('unknown enabled-layer tokens reject the payload instead of becoming an empty set', () => {
|
||||
assert.equal(decodeLayerStateParams(new URLSearchParams('v=2&l=z')), null);
|
||||
assert.equal(decodeLayerStateParams(new URLSearchParams('v=2&l=c.z')), null);
|
||||
assert.equal(decodeLayerStateParams(new URLSearchParams('v=2&l=unknown')), null);
|
||||
assert.equal(decodeLayerStateParams(new URLSearchParams('v=2&l=c.unknown')), null);
|
||||
});
|
||||
|
||||
test('Nepal event and locator have distinct enabled-only share tokens', () => {
|
||||
const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=h.z'));
|
||||
assert.deepEqual(decoded.enabledLayerIds, ['bhote-koshi-2026', 'bhote-koshi-locator']);
|
||||
assert.ok(encode(decoded).includes('l=h.z'));
|
||||
});
|
||||
|
||||
test('unknown and forbidden option fields are ignored while missing options use codec defaults', () => {
|
||||
|
||||
Reference in new issue
Block a user