feat: add Nepal flood incident scene

This commit is contained in:
manjunath22466 committed 2026-09-15 19:48:11 +05:30
1 parent 76c20be277
commit 60768ccc0b
48 files changed
+15431 -161

No files matched your search

+912
View File
@@ -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,
};
}
+250
View File
@@ -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
+499
View File
@@ -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]),
]);
+22
View File
@@ -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
+671
View File
@@ -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);
});
+65
View File
@@ -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;
}
+58
View File
@@ -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');
});
+22
View File
@@ -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();
+2
View File
@@ -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' }),
+11 -4
View File
@@ -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', () => {