import * as Cesium from 'cesium'; import { isPointerFree } from './inputOwnership.js'; import { clearOverlaySource, hitTestWorldOverlay, setOverlayEntries, setOverlaySourceVisible, } from '../overlays/worldOverlay.js'; import { WORLD_OVERLAY_STYLE } from '../overlays/worldOverlayTokens.js'; import { governorRequestRender, holdContinuousRender, releaseContinuousRender, } from '../renderGovernor.js'; import { announceNavigationAuthority } from '../navigationPolicy.js'; import { BHOTE_KOSHI_CREDIT, registerDynamicCredit } from './dataCredits.js'; import { buildSourceShotPaths, SOURCE_PATH_BEAT_IDS, } from './bhoteKoshiShotPaths.js'; import { createBhoteKoshiEmbeddedMedia, resolveEmbeddedMediaSource, } from './bhoteKoshiEmbeddedMedia.js'; import { acquireImageryComparison } from '../maps/imageryComparison.js'; import { createImagerySplit } from '../ui/imagerySplit.js'; export const BHOTE_KOSHI_LAYER_ID = 'bhote-koshi-2026'; export const BHOTE_KOSHI_OVERLAY_SOURCE_ID = 'bhote-koshi-witnesses'; export const BHOTE_KOSHI_PLAYBACK_SECONDS = 90; export const BHOTE_KOSHI_PROLOGUE_END = 0.22; export const BHOTE_KOSHI_CORRIDOR_END = 0.84; export const BHOTE_KOSHI_CAMERA_HOLD_RATIO = 0.62; export const BHOTE_KOSHI_SCENE_PRESENTATION = 'scene-beat'; const BHOTE_KOSHI_SCENE_PANEL_ONLY = 'panel-only'; const BHOTE_KOSHI_SCENE_EVIDENCE_BEAT = 'evidence-beat'; const EVENT_ASSET_ROOT = `${import.meta.env?.BASE_URL || '/'}events/bhote-koshi-2026/`; const FLOOD_RENDER_HOLD = 'bhote-koshi-playback'; const SCENE_REVEAL_RENDER_HOLD = 'bhote-koshi-scene-beat-reveal'; const SCENE_PATH_RENDER_HOLD = 'bhote-koshi-scene-path-travel'; const SCENE_MEDIA_MIN_PLAYBACK_SECONDS = 6; const SCENE_MEDIA_EXIT_SECONDS = 0.65; const BHOTE_EVENT_ACCENT = WORLD_OVERLAY_STYLE.accent; const FLOOD_COLOR = Cesium.Color.fromCssColorString('#d8954e').withAlpha(0.68); const SURGE_COLOR = Cesium.Color.fromCssColorString('#e8b47c'); const CINEMATIC_INPUT_EVENTS = Object.freeze(['pointerdown', 'wheel']); const CINEMATIC_VIEW_SPECS = Object.freeze([ { id: 'immediate-collapse-viewpoint', headingDeg: 224, pitchDeg: -38, rangeM: 15000, }, { id: 'debris-dammed-lake', headingDeg: 232, pitchDeg: -37, rangeM: 13200 }, { id: 'second-landslide', headingDeg: 224, pitchDeg: -38, rangeM: 12000 }, { id: 'gyirong-border-gate', headingDeg: 208, pitchDeg: -42, rangeM: 6100 }, { id: 'timure-cluster', headingDeg: 198, pitchDeg: -40, rangeM: 5200 }, { id: 'syabru-besi', headingDeg: 205, pitchDeg: -38, rangeM: 7200 }, { id: 'dhunche', headingDeg: 208, pitchDeg: -39, rangeM: 7800 }, { id: 'mailung-upper-trishuli', headingDeg: 211, pitchDeg: -40, rangeM: 8400, }, { id: 'mailung-bazzar', headingDeg: 212, pitchDeg: -40, rangeM: 8600 }, { id: 'dandagaun', headingDeg: 214, pitchDeg: -40, rangeM: 8600 }, { id: 'dandagaun-viewpoint', headingDeg: 215, pitchDeg: -41, rangeM: 9200 }, { id: 'betrawati-bazaar', headingDeg: 216, pitchDeg: -41, rangeM: 9800 }, { id: 'bhainse', headingDeg: 219, pitchDeg: -41, rangeM: 11500 }, { id: 'bidur-trishuli-bridge', headingDeg: 248, pitchDeg: -46, rangeM: 15500, }, { id: 'devighat-taadi-khola-bridge', headingDeg: 224, pitchDeg: -42, rangeM: 13000, }, { id: 'charaudi', headingDeg: 232, pitchDeg: -42, rangeM: 15000 }, { id: 'charaudi', progress: 1, headingDeg: 232, pitchDeg: -42, rangeM: 15000, }, ]); const PASSIVE_ENABLE_ORIGINS = new Set([ 'scene', 'share-restore', 'local-restore', 'context-restore', 'dependency-restore', ]); const DEFAULT_OVERLAY_HOST = Object.freeze({ setEntries: setOverlayEntries, setVisible: setOverlaySourceVisible, clearSource: clearOverlaySource, hitTest: hitTestWorldOverlay, }); const DEFAULT_RENDER_HOST = Object.freeze({ request: governorRequestRender, hold: holdContinuousRender, release: releaseContinuousRender, }); const DEFAULT_EVENT_LOADER = async () => { const response = await fetch(eventAsset('event.json'), { cache: 'no-store' }); if (!response.ok) throw new Error(`Bhote Koshi event pack unavailable (${response.status})`); return response.json(); }; const DEFAULT_IMAGERY_PROVIDER_FACTORY = (url, options) => Cesium.SingleTileImageryProvider.fromUrl(url, options); const DEFAULT_TERRAIN_SAMPLER = async (viewer, observations) => { if ( !viewer?.terrainProvider || !viewer.terrainProvider.availability || typeof Cesium.sampleTerrainMostDetailed !== 'function' ) { return null; } const cartographics = observations.map((observation) => Cesium.Cartographic.fromDegrees(observation.lon, observation.lat), ); return Cesium.sampleTerrainMostDetailed( viewer.terrainProvider, cartographics, ); }; const DEFAULT_MEDIA_LOADER = async (url, { signal } = {}) => { if (typeof createImageBitmap !== 'function') { throw new Error('Evidence poster decoding is unavailable'); } const response = await fetch(url, { cache: 'force-cache', credentials: 'omit', mode: 'cors', signal, }); if (!response.ok) throw new Error(`Evidence poster unavailable (${response.status})`); return createImageBitmap(await response.blob()); }; const DEFAULT_VIDEO_FACTORY = () => { const video = globalThis.document?.createElement?.('video'); if ( !video || typeof video.play !== 'function' || typeof video.pause !== 'function' ) return null; video.muted = true; video.playsInline = true; video.preload = 'auto'; return video; }; export function clampUnit(value) { return Math.max(0, Math.min(1, Number(value) || 0)); } export function elapsedLabel(progress, totalSeconds) { const elapsed = Math.round( clampUnit(progress) * Math.max(0, Number(totalSeconds) || 0), ); const minutes = Math.floor(elapsed / 60); const seconds = elapsed % 60; return `${minutes}:${String(seconds).padStart(2, '0')}`; } export function visibleSegmentCount(progress, segmentCount) { const count = Math.max(0, Math.floor(Number(segmentCount) || 0)); const normalizedProgress = clampUnit(progress); if (count === 0 || normalizedProgress <= 0) return 0; return Math.min(count, Math.max(1, Math.ceil(normalizedProgress * count))); } /** * Build a corridor whose final vertex is the exact animated surge position. * Returns true only when the Cesium polyline assignments need refreshing. */ export function updateCorridorPositionCache(progress, terrainPositions, cache) { const positions = Array.isArray(terrainPositions) ? terrainPositions : []; const normalizedProgress = clampUnit(progress); const segmentCount = Math.max(0, positions.length - 1); const visibleCount = visibleSegmentCount(normalizedProgress, segmentCount); if (cache.progress === normalizedProgress && cache.source === positions) return false; cache.progress = normalizedProgress; cache.source = positions; cache.visibleCount = visibleCount; if (segmentCount === 0 || normalizedProgress <= 0) { cache.positions = []; cache.headPosition = null; return true; } const scaledProgress = normalizedProgress * segmentCount; const startIndex = Math.min(segmentCount - 1, Math.floor(scaledProgress)); const segmentProgress = normalizedProgress >= 1 ? 1 : scaledProgress - startIndex; cache.headPosition = Cesium.Cartesian3.lerp( positions[startIndex], positions[startIndex + 1], segmentProgress, cache.headPosition || new Cesium.Cartesian3(), ); cache.positions = positions.slice(0, startIndex + 1); if (segmentProgress > 0) { cache.positions.push(Cesium.Cartesian3.clone(cache.headPosition)); } return true; } /** Map the editorial story clock onto the existing flood-front corridor clock. */ export function floodProgressForStoryProgress(progress, window = null) { const storyProgress = clampUnit(progress); const start = window?.start ?? BHOTE_KOSHI_PROLOGUE_END; const end = window?.end ?? BHOTE_KOSHI_CORRIDOR_END; if (storyProgress <= start) return 0; if (storyProgress >= end) return 1; return (storyProgress - start) / (end - start); } /** * Resolve curated evidence triggers onto the one event clock. Manual-phase * beats are explicit editorial ordering; corridor beats inherit chainage. */ export function buildEvidenceTimeline(observations, corridor) { const beats = Array.isArray(observations) ? observations : []; const points = Array.isArray(corridor) ? corridor : []; const firstChainage = Number(points[0]?.chainageM); const lastChainage = Number(points[points.length - 1]?.chainageM); const span = Math.max(1, lastChainage - firstChainage); const timeline = beats .map((observation, sourceIndex) => { const trigger = observation?.trigger || {}; let activationProgress; if ( trigger.mode === 'corridor' && Number.isFinite(Number(trigger.chainageM)) ) { const corridorProgress = clampUnit( (Number(trigger.chainageM) - firstChainage) / span, ); activationProgress = BHOTE_KOSHI_PROLOGUE_END + corridorProgress * (BHOTE_KOSHI_CORRIDOR_END - BHOTE_KOSHI_PROLOGUE_END); } else { activationProgress = clampUnit(trigger.storyProgress); } return { observation, sourceIndex, activationProgress }; }) .sort( (a, b) => a.activationProgress - b.activationProgress || a.sourceIndex - b.sourceIndex, ); for (let index = 0; index < timeline.length; index += 1) { timeline[index].deactivationProgress = timeline[index + 1]?.activationProgress ?? 1.000001; } return timeline; } /** Return the one active evidence beat for deterministic replay and scrubbing. */ export function activeEvidenceIndex(progress, timeline) { const normalized = clampUnit(progress); let activeIndex = -1; for (let index = 0; index < timeline.length; index += 1) { if (normalized < timeline[index].activationProgress) break; if ( normalized < timeline[index].deactivationProgress || index === timeline.length - 1 ) { activeIndex = index; } } return activeIndex; } /** Resolve a stable evidence id to a visible point inside that beat's window. */ export function evidenceBeatProgress(beatId, timeline, reveal = 0.36) { const beat = (Array.isArray(timeline) ? timeline : []).find( ({ observation }) => observation?.id === beatId, ); if (!beat) return null; const start = clampUnit(beat.activationProgress); const end = Math.max(start, Math.min(1, beat.deactivationProgress)); return clampUnit(start + (end - start) * clampUnit(reveal)); } /** Resolve the exact standalone story-clock span owned by one embedded shot. */ export function evidenceSequenceWindow(beatId, timeline) { const beats = Array.isArray(timeline) ? timeline : []; const beatIndex = beats.findIndex( ({ observation }) => observation?.id === beatId, ); if (beatIndex < 0) return null; const beat = beats[beatIndex]; const startProgress = clampUnit(beat.activationProgress); const targetProgress = clampUnit(beat.deactivationProgress); return { startProgress: Math.min(startProgress, targetProgress), targetProgress, }; } function smoothstep(from, to, value) { if (to <= from) return value >= to ? 1 : 0; const amount = clampUnit((value - from) / (to - from)); return amount * amount * (3 - 2 * amount); } /** * Resolve a card's reveal and recession from the one normalized story clock. * The stable hold matches the camera hold; no wall-clock TTL can drift from it. */ export function evidenceBeatPresentation(progress, beat) { if (!beat) { return { alpha: 0, scale: 0.96, leaderProgress: 0, contentAlpha: 0, localProgress: 0, }; } const start = clampUnit(beat.activationProgress); const end = Math.max( start + 0.000001, Math.min(1.000001, beat.deactivationProgress), ); const localProgress = clampUnit( (clampUnit(progress) - start) / (end - start), ); const beatDurationSeconds = Math.max( 0.25, (end - start) * BHOTE_KOSHI_PLAYBACK_SECONDS, ); const dotEnd = Math.min(0.12, 0.25 / beatDurationSeconds); const leaderStart = dotEnd * 0.35; const leaderEnd = Math.min(0.24, leaderStart + 0.35 / beatDurationSeconds); const contentStart = Math.min( leaderEnd, leaderStart + 0.12 / beatDurationSeconds, ); const contentEnd = Math.min(0.34, contentStart + 0.6 / beatDurationSeconds); const dotReveal = smoothstep(0, dotEnd, localProgress); const leaderReveal = smoothstep(leaderStart, leaderEnd, localProgress); const contentReveal = smoothstep(contentStart, contentEnd, localProgress); const contentHold = 1 - smoothstep(BHOTE_KOSHI_CAMERA_HOLD_RATIO, 0.78, localProgress); const leaderHold = 1 - smoothstep(0.74, 0.9, localProgress); const dotHold = 1 - smoothstep(0.86, 1, localProgress); const alpha = Math.min(dotReveal, dotHold); const leaderProgress = Math.min(leaderReveal, leaderHold); const revealScale = 0.96 + contentReveal * 0.04; const recedeScale = 1 - (1 - contentHold) * 0.03; return { alpha, scale: revealScale * recedeScale, leaderProgress, contentAlpha: Math.min(contentReveal, contentHold), localProgress, }; } /** Resolve a deterministic previous/next beat without creating another clock. */ export function adjacentEvidenceIndex(progress, timeline, direction) { const beats = Array.isArray(timeline) ? timeline : []; if (beats.length === 0) return -1; const current = Math.max(0, activeEvidenceIndex(progress, beats)); const delta = Number(direction) < 0 ? -1 : Number(direction) > 0 ? 1 : 0; return Math.max(0, Math.min(beats.length - 1, current + delta)); } /** Compact, source-bounded disclosure for multi-source geolocation beats. */ export function evidenceCorroborationLabel(observation) { const count = Math.max( 0, Math.floor(Number(observation?.corroboration?.sourceCount) || 0), ); return count > 1 ? `${count} GEOLOCATED SOURCE-MAP PLACEMENTS` : ''; } /** Interpolate headings across north using the shortest angular path. */ export function interpolateHeadingDegrees(start, end, amount) { const from = Number(start) || 0; const to = Number(end) || 0; const delta = ((to - from + 540) % 360) - 180; return (((from + delta * clampUnit(amount)) % 360) + 360) % 360; } /** Resolve the editorial camera beats against the terrain-sampled evidence anchors. */ export function buildCinematicKeyframes(timeline, anchors) { const beats = Array.isArray(timeline) ? timeline : []; const positions = Array.isArray(anchors) ? anchors : []; const resolved = new Map(); for (let index = 0; index < beats.length; index += 1) { const id = beats[index]?.observation?.id; if (id && positions[index]) resolved.set(id, { beat: beats[index], target: positions[index] }); } return CINEMATIC_VIEW_SPECS.flatMap((spec) => { const entry = resolved.get(spec.id); if (!entry) return []; return [ { ...spec, progress: Number.isFinite(spec.progress) ? spec.progress : entry.beat.activationProgress, target: entry.target, }, ]; }).sort((a, b) => a.progress - b.progress); } /** Sample a camera pose directly from the event clock. */ export function sampleCinematicPose(progress, keyframes, result = {}) { const frames = Array.isArray(keyframes) ? keyframes : []; if (frames.length === 0) return null; const normalized = clampUnit(progress); let from = frames[0]; let to = frames[frames.length - 1]; for (let index = 1; index < frames.length; index += 1) { if (normalized <= frames[index].progress) { to = frames[index]; from = frames[index - 1]; break; } } if (normalized <= frames[0].progress) from = to = frames[0]; if (normalized >= frames[frames.length - 1].progress) from = to = frames[frames.length - 1]; const span = Math.max(0.000001, to.progress - from.progress); const linearAmount = from === to ? 0 : clampUnit((normalized - from.progress) / span); const travelAmount = linearAmount <= BHOTE_KOSHI_CAMERA_HOLD_RATIO ? 0 : (linearAmount - BHOTE_KOSHI_CAMERA_HOLD_RATIO) / (1 - BHOTE_KOSHI_CAMERA_HOLD_RATIO); const amount = travelAmount * travelAmount * (3 - 2 * travelAmount); result.target = Cesium.Cartesian3.lerp( from.target, to.target, amount, result.target || new Cesium.Cartesian3(), ); result.headingDeg = interpolateHeadingDegrees( from.headingDeg, to.headingDeg, amount, ); result.pitchDeg = Cesium.Math.lerp(from.pitchDeg, to.pitchDeg, amount); result.rangeM = Cesium.Math.lerp(from.rangeM, to.rangeM, amount); result.fromId = from.id; result.toId = to.id; return result; } /** Wrap a compact card summary without clipping a word at the canvas edge. */ export function evidenceSummaryLines(value, maxChars = 52, maxLines = 2) { const words = String(value || '') .trim() .split(/\s+/) .filter(Boolean); const limit = Math.max(8, Math.floor(Number(maxChars) || 52)); const lineLimit = Math.max(1, Math.floor(Number(maxLines) || 2)); const lines = []; let current = ''; let consumed = 0; for (let index = 0; index < words.length; index += 1) { const word = words[index]; const candidate = current ? `${current} ${word}` : word; if (candidate.length <= limit) { current = candidate; consumed = index + 1; continue; } if (current) { lines.push(current); if (lines.length >= lineLimit) break; } current = word; consumed = index + 1; } if (current && lines.length < lineLimit) lines.push(current); if (consumed < words.length && lines.length > 0) { let tail = lines.at(-1); while (tail.length > limit - 1) tail = tail.slice(0, -1); lines[lines.length - 1] = `${tail}…`; } return lines; } function closeFrame(frame) { try { frame?.close?.(); } catch { // A browser may already have released a decoded frame during teardown. } } const EVIDENCE_CARD_LAYOUTS = Object.freeze({ landscape: Object.freeze({ kind: 'landscape', canvasWidth: 472, canvasHeight: 352, mediaHeight: 284, thumbnailWidth: 236, thumbnailHeight: 176, titleChars: 28, }), portrait: Object.freeze({ kind: 'portrait', canvasWidth: 336, canvasHeight: 520, mediaHeight: 450, thumbnailWidth: 168, thumbnailHeight: 260, titleChars: 20, }), link: Object.freeze({ kind: 'link', canvasWidth: 440, canvasHeight: 184, mediaHeight: 116, thumbnailWidth: 220, thumbnailHeight: 92, titleChars: 28, }), }); /** Resolve one stable card footprint before the entry enters the shared host. */ export function evidenceCardLayout(observation) { const media = observation?.media || {}; if (!media.videoPath && !media.posterPath && !media.posterUrl) return EVIDENCE_CARD_LAYOUTS.link; const width = Number(media.width); const height = Number(media.height); const orientation = String(media.orientation || '').toLowerCase(); if (orientation === 'portrait' || (width > 0 && height > width * 1.12)) { return EVIDENCE_CARD_LAYOUTS.portrait; } return EVIDENCE_CARD_LAYOUTS.landscape; } /** Prefer official YouTube/Facebook players while retaining local clips as fallback media. */ export function usesProviderEmbeddedEvidence(observation) { const source = resolveEmbeddedMediaSource(observation?.media?.sourceUrl); if (!source) return false; return ( source.provider === 'youtube' || source.provider === 'facebook' || !observation?.media?.videoPath ); } function evidenceFrameCanvas(observation) { const canvas = globalThis.document?.createElement?.('canvas'); if (!canvas || typeof canvas.getContext !== 'function') return null; const layout = evidenceCardLayout(observation); canvas.width = layout.canvasWidth; canvas.height = layout.canvasHeight; canvas._bhoteEvidenceLayout = layout; return canvas; } function drawCover(ctx, image, x, y, width, height) { const sourceWidth = Number(image?.videoWidth || image?.width || image?.naturalWidth) || width; const sourceHeight = Number(image?.videoHeight || image?.height || image?.naturalHeight) || height; const scale = Math.max(width / sourceWidth, height / sourceHeight); const cropWidth = width / scale; const cropHeight = height / scale; const sourceX = Math.max(0, (sourceWidth - cropWidth) / 2); const sourceY = Math.max(0, (sourceHeight - cropHeight) / 2); ctx.drawImage( image, sourceX, sourceY, cropWidth, cropHeight, x, y, width, height, ); } function drawContain(ctx, image, x, y, width, height) { const sourceWidth = Number(image?.videoWidth || image?.width || image?.naturalWidth) || width; const sourceHeight = Number(image?.videoHeight || image?.height || image?.naturalHeight) || height; const scale = Math.min(width / sourceWidth, height / sourceHeight); const drawWidth = sourceWidth * scale; const drawHeight = sourceHeight * scale; ctx.drawImage( image, x + (width - drawWidth) / 2, y + (height - drawHeight) / 2, drawWidth, drawHeight, ); } function scaledOverlayFont(font, scale = 2) { return String(font).replace( /(\d+(?:\.\d+)?)px/, (_, size) => `${Number(size) * scale}px`, ); } function drawEvidenceFallback(ctx, observation, width, height) { ctx.fillStyle = WORLD_OVERLAY_STYLE.background; ctx.fillRect(0, 0, width, height); ctx.strokeStyle = WORLD_OVERLAY_STYLE.leader; ctx.lineWidth = 2; for (let offset = -240; offset < width + 120; offset += 48) { ctx.beginPath(); ctx.moveTo(offset, height); ctx.lineTo(offset + 240, 0); ctx.stroke(); } ctx.fillStyle = WORLD_OVERLAY_STYLE.accent; ctx.font = scaledOverlayFont(WORLD_OVERLAY_STYLE.fontLabel, 1.4); ctx.fillText('NO PREVIEW', 20, 30); } function ellipsizeEvidenceText(value, limit) { const text = String(value || '').trim(); if (text.length <= limit) return text; return `${text.slice(0, Math.max(1, limit - 3)).trimEnd()}...`; } /** Repaint one stable frame with a poster or the current source-video frame. */ export function updateEvidenceFrame( canvas, observation, media = null, options = {}, ) { if (!canvas || typeof canvas.getContext !== 'function') return false; const ctx = canvas.getContext('2d'); if (!ctx) return false; const layout = canvas._bhoteEvidenceLayout || evidenceCardLayout(observation); const accent = BHOTE_EVENT_ACCENT; const mediaHeight = layout.mediaHeight; const infoY = mediaHeight; ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic'; ctx.fillStyle = WORLD_OVERLAY_STYLE.background; ctx.fillRect(0, 0, canvas.width, canvas.height); let mediaRendered = false; if (media) { try { const fit = String( observation.media?.fit || (layout.kind === 'portrait' ? 'contain' : 'cover'), ); if (fit === 'contain') drawContain(ctx, media, 0, 0, canvas.width, mediaHeight); else drawCover(ctx, media, 0, 0, canvas.width, mediaHeight); mediaRendered = true; } catch { // The deterministic local fallback below remains the visual source. } } if (!mediaRendered) drawEvidenceFallback(ctx, observation, canvas.width, mediaHeight); if (options.videoActive && mediaRendered) { const mediaProgress = clampUnit(options.mediaProgress); ctx.fillStyle = WORLD_OVERLAY_STYLE.selectedBackground; ctx.fillRect(0, mediaHeight - 4, canvas.width, 4); ctx.fillStyle = accent; ctx.fillRect(0, mediaHeight - 4, canvas.width * mediaProgress, 4); } ctx.fillStyle = WORLD_OVERLAY_STYLE.selectedBackground; ctx.fillRect(0, infoY, canvas.width, canvas.height - infoY); ctx.fillStyle = accent; ctx.fillRect(0, 0, canvas.width, 4); ctx.fillStyle = WORLD_OVERLAY_STYLE.title; ctx.font = scaledOverlayFont(WORLD_OVERLAY_STYLE.fontTitle); ctx.fillText( ellipsizeEvidenceText( observation.shortTitle || observation.title, layout.titleChars, ).toUpperCase(), 16, infoY + 27, ); ctx.fillStyle = WORLD_OVERLAY_STYLE.detail; ctx.font = scaledOverlayFont(WORLD_OVERLAY_STYLE.fontDetail); const publisher = String( observation.media?.publisher || 'SOURCE LINK', ).toUpperCase(); ctx.fillText( `${ellipsizeEvidenceText(publisher, 24)} / OPEN`, 16, infoY + 53, ); return true; } /** Compose source imagery and caveats into one stable renderer-owned frame. */ export function createEvidenceFrame(observation, poster = null, options = {}) { const canvas = evidenceFrameCanvas(observation); if (!canvas) return null; updateEvidenceFrame(canvas, observation, poster, options); return canvas; } function eventAsset(path) { return `${EVENT_ASSET_ROOT}${String(path || '').replace(/^\/+/, '')}`; } function cancellationError() { const error = new Error('Bhote Koshi event enable cancelled'); error.name = 'AbortError'; return error; } function throwIfEnableCancelled(signal, enabled) { if (signal?.aborted || !enabled) throw cancellationError(); } function openExternal(url) { const target = window.open(url, '_blank', 'noopener,noreferrer'); if (target) target.opener = null; } function focusView(viewer, viewpoint) { announceNavigationAuthority('bhote-koshi-event-focus'); const target = Cesium.Cartesian3.fromDegrees( viewpoint.lon, viewpoint.lat, viewpoint.targetElevationM, ); const offset = new Cesium.HeadingPitchRange( Cesium.Math.toRadians(viewpoint.headingDeg), Cesium.Math.toRadians(viewpoint.pitchDeg), viewpoint.rangeM, ); viewer.camera.cancelFlight?.(); viewer.camera.flyToBoundingSphere(new Cesium.BoundingSphere(target, 80), { offset, duration: 2.2, complete: () => { viewer.camera.lookAt(target, offset); viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); }, }); } function setElementProperty(element, property, value) { if (!element || element[property] === value) return false; element[property] = value; return true; } function setElementAttribute(element, attribute, value) { if (!element || element.getAttribute?.(attribute) === value) return false; element.setAttribute(attribute, value); return true; } function collectPanelReferences(panel) { return Object.freeze({ imagerySection: panel.querySelector('[data-role="imagery-comparison"]'), observedStatus: panel.querySelector('.bhote-event-status.observed'), beforeDate: panel.querySelector('[data-role="before-date"]'), afterDate: panel.querySelector('[data-role="after-date"]'), time: panel.querySelector('[data-role="time"]'), caveat: panel.querySelector('[data-role="caveat"]'), imageryCloudNote: panel.querySelector('[data-role="imagery-cloud-note"]'), reportList: panel.querySelector('.bhote-event-report-list'), playButton: panel.querySelector('[data-action="play"]'), playSceneButton: panel.querySelector('[data-action="play-scene"]'), cinematicButton: panel.querySelector('[data-action="cinematic"]'), storyReplayButton: panel.querySelector('[data-action="story-replay"]'), corridorButton: panel.querySelector('[data-action="corridor"]'), previousButton: panel.querySelector('[data-action="previous-beat"]'), nextButton: panel.querySelector('[data-action="next-beat"]'), openSourceButton: panel.querySelector('[data-action="open-source"]'), progressInput: panel.querySelector('[data-role="progress"]'), splitInput: panel.querySelector('[data-role="split"]'), splitReadout: panel.querySelector('[data-role="split-readout"]'), timelineLabel: panel.querySelector('[data-role="timeline-label"]'), beatIndex: panel.querySelector('[data-role="beat-index"]'), beatTitle: panel.querySelector('[data-role="beat-title"]'), beatMeta: panel.querySelector('[data-role="beat-meta"]'), }); } function createPanel(event, handlers) { const panel = document.createElement('aside'); panel.id = 'bhote-koshi-event-panel'; panel.setAttribute('aria-label', 'Bhote Koshi flood reconstruction controls'); panel.innerHTML = `
Clouds are preserved from the source imagery.
`; const refs = collectPanelReferences(panel); refs.beforeDate.textContent = event.imagery.before.label; refs.afterDate.textContent = event.imagery.after.label; refs.time.textContent = `~0:00 / ${elapsedLabel(1, event.reconstruction.elapsedSeconds)}`; refs.caveat.textContent = event.reconstruction.caveat; for (const report of event.fieldReports) { const button = document.createElement('button'); const label = document.createElement('span'); const status = document.createElement('small'); button.type = 'button'; button.className = 'bhote-event-report'; label.textContent = report.label; status.textContent = report.status; button.append(label, status); button.addEventListener('click', () => openExternal(report.url)); refs.reportList.appendChild(button); } panel .querySelector('[data-action="close"]') .addEventListener('click', handlers.close); panel .querySelector('[data-action="play"]') .addEventListener('click', handlers.play); panel .querySelector('[data-action="play-scene"]') .addEventListener('click', handlers.playScene); panel .querySelector('[data-action="cinematic"]') .addEventListener('click', handlers.toggleCinematic); panel .querySelector('[data-action="previous-beat"]') .addEventListener('click', handlers.previousBeat); panel .querySelector('[data-action="next-beat"]') .addEventListener('click', handlers.nextBeat); panel .querySelector('[data-action="story-replay"]') .addEventListener('click', handlers.replayCinematic); panel .querySelector('[data-action="open-source"]') .addEventListener('click', handlers.openActiveSource); panel .querySelector('[data-action="geolocation-map"]') .addEventListener('click', handlers.openGeolocationMap); panel .querySelector('[data-action="corridor"]') .addEventListener('click', handlers.focusCorridor); panel .querySelector('[data-role="split"]') .addEventListener('input', (inputEvent) => { handlers.setSplit(Number(inputEvent.target.value) / 100); }); panel .querySelector('[data-role="progress"]') .addEventListener('input', (inputEvent) => { handlers.previewProgress(Number(inputEvent.target.value) / 1000); }); panel .querySelector('[data-role="progress"]') .addEventListener('change', (inputEvent) => { handlers.commitProgress(Number(inputEvent.target.value) / 1000, { final: true, }); }); const rightRail = document.getElementById?.('right-context-rail'); rightRail?.classList.add('bhote-event-active'); (rightRail || document.body).appendChild(panel); return { panel, refs }; } export function createBhoteKoshiEventLayer({ overlayHost = DEFAULT_OVERLAY_HOST, renderHost = DEFAULT_RENDER_HOST, eventLoader = DEFAULT_EVENT_LOADER, imageryProviderFactory = DEFAULT_IMAGERY_PROVIDER_FACTORY, terrainSampler = DEFAULT_TERRAIN_SAMPLER, mediaLoader = DEFAULT_MEDIA_LOADER, evidenceFrameFactory = createEvidenceFrame, evidenceFrameUpdater = updateEvidenceFrame, videoFactory = DEFAULT_VIDEO_FACTORY, embeddedMediaFactory = createBhoteKoshiEmbeddedMedia, } = {}) { let _viewer = null; let _dataManager = null; let _mapStackController = null; let _event = null; let _enabled = false; let _panel = null; let _panelRefs = null; let _splitControl = null; let _beforeLayer = null; let _afterLayer = null; let _imageryLayerCollection = null; let _floodDataSource = null; let _floodSurfaceMode = null; let _floodHalo = null; let _floodCore = null; let _surgePoint = null; let _terrainPositions = []; let _sourceShotPaths = new Map(); let _scenePathPositionCache = new Map(); let _scenePathPositions = []; let _scenePathBeatId = null; let _scenePathStartProgress = 0; let _scenePathRevealProgress = 0; let _scenePathAnimationFrame = null; let _scenePathDeadlineTimer = null; let _scenePathHoldActive = false; let _corridorPositionCache = { visibleCount: -1, positions: [] }; let _evidenceTimeline = []; let _evidenceAnchors = []; let _evidenceFrameSlots = []; let _evidenceVideo = null; let _evidenceVideoIndex = -1; let _evidenceVideoReady = false; let _evidenceVideoGeneration = 0; let _evidenceVideoFrameHandle = null; let _embeddedMedia = null; let _embeddedEvidenceIndex = -1; let _sceneMediaPlaybackBeatId = null; let _sceneMediaPlaybackCompletedBeatId = null; let _sceneMediaPlaybackTimer = null; let _sceneMediaOwner = null; let _removeSceneMediaAbort = null; let _witnessEmbedActive = false; let _sceneRevealAnimationFrame = null; let _sceneRevealHoldActive = false; let _sceneRevealProgress = 1; let _cinematicKeyframes = []; let _cinematicActive = false; let _cinematicReplayArmed = false; let _cinematicPose = { target: new Cesium.Cartesian3() }; let _cinematicOffset = new Cesium.HeadingPitchRange(); let _lastCinematicProgress = null; let _cameraInputTarget = null; let _evidenceClickTarget = null; let _mediaAbortController = null; let _mediaGeneration = 0; let _progress = 0; let _split = 0.5; let _playing = false; let _playStartedAt = 0; let _playStartProgress = 0; let _animationFrame = null; let _comparisonLease = null; let _introTimer = null; let _introGeneration = 0; let _lastSurgePosition = null; let _lastSurgeVisible = null; let _presentation = 'standalone'; let _sceneBeatId = null; let _sceneBeatReveal = 0.36; let _sceneContext = null; let _sceneSurface = BHOTE_KOSHI_SCENE_PANEL_ONLY; let _sceneControls = {}; let _sceneController = null; let _sceneClockUnsubscribe = null; let _sceneScrubProgress = null; let _sceneSeekGeneration = 0; let _sceneSeekFrame = null; let _sceneActionPending = false; let _comparisonSurfaceGeneration = 0; function wantsTerrainComparison() { return ( _presentation === BHOTE_KOSHI_SCENE_PRESENTATION && _sceneControls.imageryComparison === true ); } function usesPhotorealFloodSurface() { return ( _mapStackController?.getActiveId?.() === 'photoreal' && !wantsTerrainComparison() ); } async function syncTerrainComparison() { const generation = ++_comparisonSurfaceGeneration; if (!_mapStackController?.setTerrainComparison) return; if (!_enabled || !wantsTerrainComparison()) { _mapStackController.clearTerrainComparison(); return; } await _mapStackController.setTerrainComparison(_event.imagery.rectangle); if (!_enabled || generation !== _comparisonSurfaceGeneration) return; syncFloodSurfaceMode(); updateProgress(_progress); renderHost.request('bhote-koshi-comparison-ready'); } function attachDataManager(dataManager) { _dataManager = dataManager; } function attachMapStackController(controller) { _mapStackController = controller; } function attachSceneController(controller) { _sceneClockUnsubscribe?.(); _sceneClockUnsubscribe = null; _sceneController = controller; if (typeof controller?.subscribeSceneClock === 'function') { _sceneClockUnsubscribe = controller.subscribeSceneClock((snapshot) => { if ( _presentation !== BHOTE_KOSHI_SCENE_PRESENTATION || !_sceneContext?.sceneId || snapshot?.sceneId !== _sceneContext.sceneId ) return; const playbackStopped = _sceneContext.running === true && snapshot.running === false; _sceneContext = { ..._sceneContext, ...snapshot }; if (playbackStopped || snapshot.stopped === true) { _sceneMediaPlaybackCompletedBeatId = _sceneBeatId; stopSceneEvidenceReveal(); stopSceneMediaPlayback(); releaseEvidenceVideo({ restorePoster: false }); } syncPanel(); }); } syncPanel(); } async function loadEvent() { if (_event) return _event; _event = await eventLoader(); return _event; } async function createImagery(event) { const rectangle = Cesium.Rectangle.fromDegrees( event.imagery.rectangle.west, event.imagery.rectangle.south, event.imagery.rectangle.east, event.imagery.rectangle.north, ); const [beforeProvider, afterProvider] = await Promise.all([ imageryProviderFactory(eventAsset(event.imagery.before.path), { rectangle, credit: 'Vantor WorldView Open Data · CC BY-NC 4.0', }), imageryProviderFactory(eventAsset(event.imagery.after.path), { rectangle, credit: 'Vantor WorldView Open Data · CC BY-NC 4.0', }), ]); if (!_enabled) return; _imageryLayerCollection = _viewer.imageryLayers; _beforeLayer = _imageryLayerCollection.addImageryProvider(beforeProvider); _afterLayer = _imageryLayerCollection.addImageryProvider(afterProvider); _beforeLayer.splitDirection = Cesium.SplitDirection.LEFT; _afterLayer.splitDirection = Cesium.SplitDirection.RIGHT; _beforeLayer.alpha = 0.96; _afterLayer.alpha = 0.96; setSplit(_split); } async function resolveEvidenceAnchors(event, signal) { const observations = Array.isArray(event.evidenceSpine) ? event.evidenceSpine : []; let sampled = null; try { sampled = await terrainSampler(_viewer, observations, { signal }); } catch (error) { if (error?.name === 'AbortError') throw error; console.warn( '[Data:BhoteKoshi] Evidence terrain sampling fell back:', error, ); } throwIfEnableCancelled(signal, _enabled); return observations.map((observation, index) => { const sampledHeight = Number(sampled?.[index]?.height); const fallbackHeight = Number(observation.fallbackElevationM) || 0; return Cesium.Cartesian3.fromDegrees( observation.lon, observation.lat, (Number.isFinite(sampledHeight) ? sampledHeight : fallbackHeight) + 18, ); }); } function evidencePositionGetter(index, breadcrumb = false) { return () => { if (!_enabled) return null; if (_witnessEmbedActive && !breadcrumb) return null; if (_presentation === BHOTE_KOSHI_SCENE_PRESENTATION) { if (_sceneSurface !== BHOTE_KOSHI_SCENE_EVIDENCE_BEAT || breadcrumb) return null; if (_evidenceTimeline[index]?.observation?.id !== _sceneBeatId) return null; if ( sceneEvidenceDefersUntilCameraSettles() && !sceneEvidenceCameraSettled() ) return null; if (index === _embeddedEvidenceIndex) return null; if ( _evidenceTimeline[index]?.observation?.id === _sceneMediaPlaybackCompletedBeatId ) return null; return _evidenceAnchors[index] || null; } const activeIndex = activeEvidenceIndex(_progress, _evidenceTimeline); if (breadcrumb ? index >= activeIndex : index !== activeIndex) return null; if (!breadcrumb && index === _embeddedEvidenceIndex) return null; return _evidenceAnchors[index] || null; }; } function showWitnessEmbed() { const witness = _event?.witnessAnchors?.[0]; if (!witness?.sourceUrl) return false; const anchor = Cesium.Cartesian3.fromDegrees( witness.lon, witness.lat, (Number(witness.elevationM) || 0) + 18, ); const shown = _embeddedMedia?.show?.({ observation: { title: witness.title, shortTitle: 'Rasuwagadhi witness', media: { sourceUrl: witness.sourceUrl }, }, anchor, sourceUrl: witness.sourceUrl, autoplay: false, }) === true; if (shown) _embeddedEvidenceIndex = -1; return shown; } function syncEmbeddedEvidence(index) { if (_witnessEmbedActive) { const shown = showWitnessEmbed(); if (!shown) _embeddedMedia?.hide?.(); return shown; } const observation = _evidenceTimeline[index]?.observation; if (observation?.id === _sceneMediaPlaybackCompletedBeatId) { _embeddedEvidenceIndex = index; _embeddedMedia?.hide?.(); return true; } if (!usesProviderEmbeddedEvidence(observation)) { _embeddedEvidenceIndex = -1; _embeddedMedia?.hide?.(); return false; } const shown = _embeddedMedia?.show?.({ observation, anchor: _evidenceAnchors[index], autoplay: sceneEvidenceMediaAutoplays() || _playing, }) === true; _embeddedEvidenceIndex = shown ? index : -1; if (shown && sceneEvidenceMediaAutoplays()) startSceneMediaPlayback(index); return shown; } function redrawEvidenceSlot(index, media = null, options = {}) { const slot = _evidenceFrameSlots[index]; const observation = _evidenceTimeline[index]?.observation; if (!slot || !observation) return false; const rendered = evidenceFrameUpdater( slot.frame, observation, media || slot.poster, options, ); if (!rendered) return false; slot.stamp += 1; return true; } // Playback authority is supplied by the Director, never restored from params. function setSceneMediaPlayback(owner = null) { _removeSceneMediaAbort?.(); _removeSceneMediaAbort = null; _sceneMediaOwner = owner; stopSceneMediaPlayback(); releaseEvidenceVideo({ restorePoster: false }); _embeddedMedia?.hide?.({ immediate: true }); _embeddedEvidenceIndex = -1; _sceneMediaPlaybackCompletedBeatId = null; if (owner?.token?.signal) { const signal = owner.token.signal; const cancel = () => setSceneMediaPlayback(); if (signal.aborted) cancel(); else { signal.addEventListener('abort', cancel, { once: true }); _removeSceneMediaAbort = () => signal.removeEventListener('abort', cancel); } } } function sceneMediaPlaybackActive() { return ( _sceneMediaOwner != null && !_sceneMediaOwner.token.cancelled && !_sceneMediaOwner.token.signal?.aborted && _sceneMediaOwner.sceneId === _sceneContext?.sceneId && _sceneMediaOwner.shotId === _sceneContext?.shotId ); } function sceneEvidenceMediaAutoplays() { return ( sceneMediaPlaybackActive() && _presentation === BHOTE_KOSHI_SCENE_PRESENTATION && _sceneSurface === BHOTE_KOSHI_SCENE_EVIDENCE_BEAT && _sceneControls.evidenceMediaAutoplay === true && !_sceneControls.timelineSeek ); } function sceneTimelineSeek() { return _presentation === BHOTE_KOSHI_SCENE_PRESENTATION && _sceneControls.timelineSeek && typeof _sceneControls.timelineSeek === 'object' ? _sceneControls.timelineSeek : null; } function sceneTimelineMediaVisible() { const seek = sceneTimelineSeek(); if (!seek || _sceneControls.evidenceMediaAutoplay !== true) return true; if (!sceneEvidenceCameraSettled()) return false; const holdElapsedSec = Math.max(0, Number(seek.holdElapsedSec) || 0); const mediaDurationSec = Math.max( SCENE_MEDIA_MIN_PLAYBACK_SECONDS, Number(_sceneControls.mediaPlaybackHoldSec) || 0, ); return holdElapsedSec < mediaDurationSec; } function stopSceneMediaPlayback({ pauseMedia = true } = {}) { const hadPlayback = _sceneMediaPlaybackTimer != null || _sceneMediaPlaybackBeatId != null; if (_sceneMediaPlaybackTimer != null) { globalThis.window?.clearTimeout?.(_sceneMediaPlaybackTimer); _sceneMediaPlaybackTimer = null; } _sceneMediaPlaybackBeatId = null; if (pauseMedia) _embeddedMedia?.pause?.(); if (hadPlayback) renderHost.request('bhote-koshi-scene-media-hold'); } function completeSceneMediaPlayback() { const beatId = _sceneMediaPlaybackBeatId; if (!beatId) return; _sceneMediaPlaybackCompletedBeatId = beatId; _embeddedMedia?.hide?.(); releaseEvidenceVideo({ restorePoster: false }); const exitDurationSec = Math.max( SCENE_MEDIA_EXIT_SECONDS, Number(_sceneControls.mediaExitDurationSec) || 0, ); _sceneMediaPlaybackTimer = globalThis.window?.setTimeout?.(() => { if (_sceneMediaPlaybackBeatId !== beatId) return; _sceneMediaPlaybackTimer = null; _sceneMediaPlaybackBeatId = null; renderHost.request('bhote-koshi-scene-media-exit'); }, exitDurationSec * 1000) ?? null; } function startSceneMediaPlayback(index) { if (!sceneEvidenceMediaAutoplays()) return; const beatId = _evidenceTimeline[index]?.observation?.id; if ( !beatId || beatId === _sceneMediaPlaybackCompletedBeatId || beatId === _sceneMediaPlaybackBeatId ) return; stopSceneMediaPlayback({ pauseMedia: false }); _sceneMediaPlaybackBeatId = beatId; _embeddedMedia?.play?.(); if (sceneUsesTrimmedMedia(beatId) && _embeddedEvidenceIndex === index) { const deadline = Date.now() + 18000; const check = () => { if ( !_enabled || _sceneBeatId !== beatId || _sceneMediaPlaybackBeatId !== beatId ) return; const state = _embeddedMedia?.getPlaybackState?.(); if ( [ 'completed', 'blocked', 'unavailable', 'timeout', 'cancelled', ].includes(state?.phase) || Date.now() >= deadline ) { _sceneMediaPlaybackTimer = null; completeSceneMediaPlayback(); return; } _sceneMediaPlaybackTimer = globalThis.window?.setTimeout?.(check, 100) ?? null; }; check(); return; } const exitDurationSec = Math.max( SCENE_MEDIA_EXIT_SECONDS, Number(_sceneControls.mediaExitDurationSec) || 0, ); const postMediaPathDurationSec = scenePathMovesDuringCamera() ? 0 : sceneUsesSourcePath() ? Number(_sceneControls.evidencePathDurationSec) || 0 : sceneEvidenceSequenceAnimates() && _sceneControls.evidencePath !== 'none' ? Number(_sceneControls.evidenceSequenceDurationSec) || 0 : 0; const availableMediaSec = (Number(_sceneContext?.holdSec) || 0) - exitDurationSec - postMediaPathDurationSec; const durationSec = Math.max( SCENE_MEDIA_MIN_PLAYBACK_SECONDS, Number(_sceneControls.mediaPlaybackHoldSec) || 0, availableMediaSec, ); _sceneMediaPlaybackTimer = globalThis.window?.setTimeout?.(() => { if ( !_enabled || _sceneBeatId !== beatId || _sceneMediaPlaybackBeatId !== beatId ) return; _sceneMediaPlaybackTimer = null; completeSceneMediaPlayback(); }, durationSec * 1000) ?? null; } function sceneUsesTrimmedMedia(beatId) { const media = _evidenceTimeline.find( (item) => item.observation?.id === beatId, )?.observation?.media; return ( Number(media?.embedEndAtSec) > Number(media?.embedStartAtSec || 0) && resolveEmbeddedMediaSource(media?.sourceUrl)?.provider === 'youtube' ); } function getSceneShotMediaHold(beatId) { if ( !_enabled || _sceneBeatId !== beatId || !sceneEvidenceMediaAutoplays() || !sceneUsesTrimmedMedia(beatId) ) return null; const media = _evidenceTimeline.find( (item) => item.observation?.id === beatId, )?.observation?.media; // Deliberate provider suppression is a source card, not a player waiting // to load. Returning no gate selects the Director's authored card dwell. // A local clip still owns playback; a starting supported provider still waits. if (_embeddedMedia?.supportsPlayback === false && !media?.videoPath) return null; return { pending: _sceneMediaPlaybackCompletedBeatId !== beatId || _sceneMediaPlaybackBeatId === beatId, maxWaitMs: 20000, }; } function sceneMediaPlaybackBlocksPath() { return ( _sceneMediaPlaybackBeatId === _sceneBeatId && _sceneControls.evidencePathDuringMedia !== true ); } function sceneUsesSourcePath() { return ( _presentation === BHOTE_KOSHI_SCENE_PRESENTATION && _sceneControls.evidencePath === 'source' ); } function sceneUsesPathHistory() { return ( _presentation === BHOTE_KOSHI_SCENE_PRESENTATION && _sceneControls.evidencePath === 'history' ); } function sceneUsesCumulativePath() { return ( (sceneUsesSourcePath() || sceneUsesPathHistory()) && _sceneControls.evidencePathHistory === true ); } function scenePathMovesDuringCamera() { return ( _presentation === BHOTE_KOSHI_SCENE_PRESENTATION && _sceneSurface === BHOTE_KOSHI_SCENE_EVIDENCE_BEAT && _sceneControls.evidencePathDuringCamera === true ); } function sceneKeepsFloodVisible() { return ( _presentation === BHOTE_KOSHI_SCENE_PRESENTATION && _sceneControls.evidencePathPersistent === true ); } function scenePathCanPrepare() { return ( !sceneEvidenceDefersUntilCameraSettles() || sceneEvidenceCameraSettled() || scenePathMovesDuringCamera() || // Media-led shots defer the new segment, not the completed upstream // prefix. Reconstruct that prefix for travel, direct loads and replay. (sceneKeepsFloodVisible() && sceneUsesCumulativePath()) || sceneUsesPathHistory() ); } function sourcePathPositions(beatId) { const cacheKey = `${beatId}:${_sceneControls.evidencePathElevation === 'source' ? 'source' : 'surface'}`; const cached = _scenePathPositionCache.get(cacheKey); if (cached) return cached; const positions = (_sourceShotPaths.get(beatId) || []).map((point) => { const position = Cesium.Cartographic.fromDegrees(point.lon, point.lat); let height; // Some schematic reaches have a source elevation profile. Do not replace // it with coarse mesh heights on entry/exit from the comparison surface: // a single high sample can make the front dive backward in screen space. if (_sceneControls.evidencePathElevation !== 'source') { // Sample once while preparing the shot, never from an animation frame. try { height = _viewer?.scene?.sampleHeight?.( position, [_floodCore, _floodHalo, _surgePoint].filter(Boolean), ); } catch {} if (!Number.isFinite(height)) height = _viewer?.scene?.globe?.getHeight?.(position); } if (!Number.isFinite(height)) height = point.fallbackElevationM; return Cesium.Cartesian3.fromDegrees(point.lon, point.lat, height + 8); }); _scenePathPositionCache.set(cacheKey, positions); return positions; } function appendPathSegment(target, segment, { replaceJoin = false } = {}) { if (!segment.length) return; if (!target.length) { target.push(...segment); return; } if (replaceJoin) target[target.length - 1] = segment[0]; target.push(...segment.slice(1)); } function prepareScenePath() { if ( (!sceneUsesSourcePath() && !sceneUsesPathHistory()) || !scenePathCanPrepare() ) { _scenePathBeatId = null; _scenePathPositions = []; _scenePathStartProgress = 0; return; } const targetBeatId = sceneUsesPathHistory() ? _sceneControls.evidencePathHistoryBeatId : _sceneBeatId; const pathKey = `${_sceneBeatId}:${targetBeatId}:${sceneUsesCumulativePath() ? 'history' : 'segment'}:${_sceneControls.evidencePathElevation || 'surface'}`; if (_scenePathBeatId === pathKey) return; _scenePathBeatId = pathKey; if (!sceneUsesCumulativePath()) { _scenePathPositions = [...sourcePathPositions(targetBeatId)]; _scenePathStartProgress = 0; return; } // Reconstruct the supported prefix from source data on every direct load. // Playback history is never trusted: backwards navigation therefore trims // downstream geometry, while a direct downstream load still has its full // upstream context. const targetIndex = SOURCE_PATH_BEAT_IDS.indexOf(targetBeatId); const upperValleyPath = targetIndex >= 0 && targetIndex < 2; const pathBeatIds = upperValleyPath ? SOURCE_PATH_BEAT_IDS.slice(0, 2) : SOURCE_PATH_BEAT_IDS.slice(2); const cumulative = upperValleyPath ? [] : [..._terrainPositions]; let activeStartIndex = Math.max(0, cumulative.length - 1); for (const beatId of pathBeatIds) { if (beatId === targetBeatId && !sceneUsesPathHistory()) { activeStartIndex = Math.max(0, cumulative.length - 1); } appendPathSegment(cumulative, sourcePathPositions(beatId), { replaceJoin: !upperValleyPath && beatId === SOURCE_PATH_BEAT_IDS[2], }); if (beatId === targetBeatId) break; } _scenePathPositions = cumulative; _scenePathStartProgress = sceneUsesPathHistory() ? 1 : activeStartIndex / Math.max(1, cumulative.length - 1); } function activeFloodPositions() { return sceneUsesSourcePath() || sceneUsesPathHistory() ? _scenePathPositions : _terrainPositions; } function sceneEvidenceDefersUntilCameraSettles() { return ( _presentation === BHOTE_KOSHI_SCENE_PRESENTATION && _sceneSurface === BHOTE_KOSHI_SCENE_EVIDENCE_BEAT && _sceneControls.deferEvidenceUntilCameraSettled === true ); } function sceneEvidenceSequenceAnimates() { return ( _presentation === BHOTE_KOSHI_SCENE_PRESENTATION && _sceneSurface === BHOTE_KOSHI_SCENE_EVIDENCE_BEAT && _sceneControls.evidenceSequence === true ); } function sceneEvidenceCameraSettled() { return ( !sceneEvidenceDefersUntilCameraSettles() || _sceneControls.cameraSettled === true ); } function sceneEvidencePresentation(index) { const beat = _evidenceTimeline[index]; if ( beat && _presentation === BHOTE_KOSHI_SCENE_PRESENTATION && _sceneControls.evidenceHoldCard === true ) { const span = Math.min(1, beat.deactivationProgress) - beat.activationProgress; const reveal = Math.min(1, _sceneRevealProgress * 6); return evidenceBeatPresentation( beat.activationProgress + span * _sceneBeatReveal * reveal, beat, ); } if ( !beat || sceneEvidenceSequenceAnimates() || !sceneEvidenceDefersUntilCameraSettles() ) { return evidenceBeatPresentation(_progress, beat); } const start = clampUnit(beat.activationProgress); const end = Math.max(start, Math.min(1, beat.deactivationProgress)); const revealProgress = start + (end - start) * _sceneBeatReveal * clampUnit(_sceneRevealProgress); return evidenceBeatPresentation(revealProgress, beat); } function stopSceneEvidenceReveal() { if (_sceneRevealAnimationFrame != null) cancelAnimationFrame(_sceneRevealAnimationFrame); _sceneRevealAnimationFrame = null; if (_sceneRevealHoldActive) renderHost.release(SCENE_REVEAL_RENDER_HOLD); _sceneRevealHoldActive = false; } function stopScenePathReveal() { if (_scenePathAnimationFrame != null) cancelAnimationFrame(_scenePathAnimationFrame); _scenePathAnimationFrame = null; if (_scenePathDeadlineTimer != null) globalThis.window?.clearTimeout?.(_scenePathDeadlineTimer); _scenePathDeadlineTimer = null; if (_scenePathHoldActive) renderHost.release(SCENE_PATH_RENDER_HOLD); _scenePathHoldActive = false; } function finishScenePathReveal(targetProgress, requestReason) { if (_scenePathAnimationFrame != null) cancelAnimationFrame(_scenePathAnimationFrame); _scenePathAnimationFrame = null; if (_scenePathDeadlineTimer != null) globalThis.window?.clearTimeout?.(_scenePathDeadlineTimer); _scenePathDeadlineTimer = null; _scenePathRevealProgress = targetProgress; updateFloodTrace(_scenePathRevealProgress); updateSurgeFront(_scenePathRevealProgress); renderHost.request(requestReason); if (_scenePathHoldActive) renderHost.release(SCENE_PATH_RENDER_HOLD); _scenePathHoldActive = false; } function scenePathProgressWindow() { if (sceneUsesPathHistory()) return { start: 1, target: 1 }; if (sceneUsesSourcePath()) return { start: _scenePathStartProgress, target: 1 }; const sequenceWindow = evidenceSequenceWindow( _sceneBeatId, _evidenceTimeline, ); if (!sequenceWindow) return null; return { start: floodProgressForStoryProgress( sequenceWindow.startProgress, _event?.reconstruction?.storyFloodWindow, ), target: floodProgressForStoryProgress( sequenceWindow.targetProgress, _event?.reconstruction?.storyFloodWindow, ), }; } /** Rebuild the flood front and callout reveal from an authored clock seek. */ function applySceneTimelineSeek() { const seek = sceneTimelineSeek(); if (!seek) return false; stopScenePathReveal(); stopSceneEvidenceReveal(); stopSceneMediaPlayback(); _sceneMediaPlaybackCompletedBeatId = null; const progressWindow = scenePathProgressWindow(); const startProgress = clampUnit(progressWindow?.start ?? 0); const targetProgress = Math.max( startProgress, clampUnit(progressWindow?.target ?? startProgress), ); const cameraProgress = clampUnit(Number(seek.cameraProgress) || 0); const holdElapsedSec = Math.max(0, Number(seek.holdElapsedSec) || 0); const flightDurationSec = Math.max( 0.2, Number(seek.flightDurationSec) || 0.2, ); if (sceneUsesPathHistory()) { _scenePathRevealProgress = 1; _sceneRevealProgress = 1; return true; } if (scenePathMovesDuringCamera()) { const authoredDurationSec = Number( _sceneControls.evidencePathTravelDurationSec, ); const pathDurationSec = Math.max( 0.12, Number.isFinite(authoredDurationSec) && authoredDurationSec > 0 ? Math.min(authoredDurationSec, flightDurationSec * 0.9) : Math.min(2.5, flightDurationSec * 0.6), ); const cameraElapsedSec = cameraProgress * flightDurationSec; const linear = clampUnit(cameraElapsedSec / pathDurationSec); const eased = 1 - (1 - linear) ** 2; _scenePathRevealProgress = startProgress + (targetProgress - startProgress) * eased; _sceneRevealProgress = cameraProgress >= 1 ? 1 : clampUnit( cameraElapsedSec / Math.max( 0.25, Number(_sceneControls.evidenceRevealDurationSec) || 0.9, ), ); return true; } const mediaDurationSec = _sceneControls.evidenceMediaAutoplay === true ? Math.max( SCENE_MEDIA_MIN_PLAYBACK_SECONDS, Number(_sceneControls.mediaPlaybackHoldSec) || 0, ) : 0; const exitDurationSec = _sceneControls.evidenceMediaAutoplay === true ? Math.max( SCENE_MEDIA_EXIT_SECONDS, Number(_sceneControls.mediaExitDurationSec) || 0, ) : 0; const pathDurationSec = Math.max( 0.25, Number( sceneUsesSourcePath() ? _sceneControls.evidencePathDurationSec : sceneEvidenceSequenceAnimates() ? _sceneControls.evidenceSequenceDurationSec : _sceneControls.evidenceRevealDurationSec, ) || 0.9, ); const pathStartSec = Math.max( 0, Number(_sceneControls.evidencePathStartDelaySec) || 0, _sceneControls.evidencePathDuringMedia === true ? 0 : mediaDurationSec + exitDurationSec, ); const linear = clampUnit((holdElapsedSec - pathStartSec) / pathDurationSec); _sceneRevealProgress = smoothstep(0, 1, linear); _scenePathRevealProgress = startProgress + (targetProgress - startProgress) * _sceneRevealProgress; const sequenceWindow = sceneEvidenceSequenceAnimates() ? evidenceSequenceWindow(_sceneBeatId, _evidenceTimeline) : null; if (sequenceWindow) { _progress = sequenceWindow.startProgress + (sequenceWindow.targetProgress - sequenceWindow.startProgress) * _sceneRevealProgress; } return true; } function startScenePathReveal() { stopScenePathReveal(); if (!scenePathMovesDuringCamera()) return; const travel = _sceneControls.cameraTravel; if (!travel?.active || travel.cancelled) return; const progressWindow = scenePathProgressWindow(); if (!progressWindow) return; const startProgress = clampUnit(progressWindow.start); const targetProgress = Math.max( startProgress, clampUnit(progressWindow.target), ); _scenePathRevealProgress = startProgress; updateFloodTrace(_scenePathRevealProgress); updateSurgeFront(_scenePathRevealProgress); if (targetProgress <= startProgress) return; // Finish before the camera so the flood front leads the move instead of // arriving after the viewpoint. The director supplies the actual duration // for LOAD, replay, and scene-run routes. const travelDurationSec = Math.max(0.2, Number(travel.durationSec) || 0.2); // Each Nepal reach has different length and framing. Use its authored // travel timing when supplied, bounded by the actual flight so the amber // head always finishes ahead of the camera rather than sharing one global // speed across every segment. const authoredDurationSec = Number( _sceneControls.evidencePathTravelDurationSec, ); const durationSec = Number.isFinite(authoredDurationSec) && authoredDurationSec > 0 ? Math.min(authoredDurationSec, travelDurationSec * 0.9) : Math.min(2.5, travelDurationSec * 0.6); const durationMs = Math.max(120, durationSec * 1000); const travelId = travel.id; let lastTickAt = performance.now(); let activeElapsedMs = 0; const accrueActiveTime = (now) => { const tickAt = Math.max(lastTickAt, Number(now) || lastTickAt); if (!sceneMediaPlaybackBlocksPath()) activeElapsedMs += tickAt - lastTickAt; lastTickAt = tickAt; return activeElapsedMs; }; renderHost.hold(SCENE_PATH_RENDER_HOLD); _scenePathHoldActive = true; // RAF normally paints every intermediate frame. This independent deadline // is registered before Cesium starts the flight, so a throttled or briefly // blocked render loop still establishes the complete tail before arrival. const finishAtDeadline = () => { const currentTravel = _sceneControls.cameraTravel; if ( !_enabled || !scenePathMovesDuringCamera() || !currentTravel?.active || currentTravel.cancelled || currentTravel.id !== travelId ) return; const remainingMs = durationMs - accrueActiveTime(performance.now()); if (remainingMs > 0) { _scenePathDeadlineTimer = globalThis.window?.setTimeout?.( finishAtDeadline, Math.max(32, remainingMs), ) ?? null; return; } finishScenePathReveal(targetProgress, 'bhote-koshi-scene-path-deadline'); }; _scenePathDeadlineTimer = globalThis.window?.setTimeout?.(finishAtDeadline, durationMs) ?? null; const step = (now) => { const currentTravel = _sceneControls.cameraTravel; if ( !_enabled || !scenePathMovesDuringCamera() || !currentTravel?.active || currentTravel.cancelled || currentTravel.id !== travelId ) { stopScenePathReveal(); return; } const linear = clampUnit(accrueActiveTime(now) / durationMs); // Ease out so the schematic head establishes the downstream route before // the camera reaches it. Combined with the shorter travel window, this // keeps the front alongside or ahead of a normally eased Cesium flight. const eased = 1 - (1 - linear) ** 2; _scenePathRevealProgress = startProgress + (targetProgress - startProgress) * eased; updateFloodTrace(_scenePathRevealProgress); updateSurgeFront(_scenePathRevealProgress); renderHost.request('bhote-koshi-scene-path-travel'); if (linear < 1) { _scenePathAnimationFrame = requestAnimationFrame(step); } else { finishScenePathReveal( targetProgress, 'bhote-koshi-scene-path-complete', ); } }; _scenePathAnimationFrame = requestAnimationFrame(step); } function startSceneEvidenceReveal() { stopSceneEvidenceReveal(); const sequenceAnimates = sceneEvidenceSequenceAnimates(); if ( (!sequenceAnimates && !sceneEvidenceDefersUntilCameraSettles()) || !sceneEvidenceCameraSettled() ) return; const sequenceWindow = sequenceAnimates ? evidenceSequenceWindow(_sceneBeatId, _evidenceTimeline) : null; if (sequenceAnimates && !sequenceWindow) return; _sceneRevealProgress = 0; const sourceWindow = sceneUsesSourcePath() || sceneUsesPathHistory() ? scenePathProgressWindow() : null; // The independent travel clock owns persistent path motion. Older beats // retain the arrival-gated reveal behavior for both card and path. if (!scenePathMovesDuringCamera()) { const initialFloodProgress = clampUnit(sourceWindow?.start ?? 0); updateFloodTrace(initialFloodProgress); updateSurgeFront(initialFloodProgress); } const durationMs = Math.max( 250, (Number( sceneUsesSourcePath() ? _sceneControls.evidencePathDurationSec : sequenceAnimates ? _sceneControls.evidenceSequenceDurationSec : _sceneControls.evidenceRevealDurationSec, ) || 0.9) * 1000, ); const targetFloodProgress = sourceWindow ? sourceWindow.target : _sceneControls.evidencePath === 'none' ? 0 : floodProgressForStoryProgress( _progress, _event?.reconstruction?.storyFloodWindow, ); if (sequenceWindow) updateProgress(sequenceWindow.startProgress); let lastTickAt = performance.now(); let activeElapsedMs = 0; renderHost.hold(SCENE_REVEAL_RENDER_HOLD); _sceneRevealHoldActive = true; const step = (now) => { if ( !_enabled || !sceneEvidenceCameraSettled() || (sequenceAnimates && !sceneEvidenceSequenceAnimates()) ) { stopSceneEvidenceReveal(); return; } const tickAt = Math.max(lastTickAt, Number(now) || lastTickAt); if (!sceneMediaPlaybackBlocksPath()) activeElapsedMs += tickAt - lastTickAt; lastTickAt = tickAt; const linear = clampUnit(activeElapsedMs / durationMs); _sceneRevealProgress = smoothstep(0, 1, linear); if (sequenceWindow) { updateProgress( sequenceWindow.startProgress + (sequenceWindow.targetProgress - sequenceWindow.startProgress) * _sceneRevealProgress, ); } else if (!scenePathMovesDuringCamera()) { const floodProgress = sourceWindow ? sourceWindow.start + (sourceWindow.target - sourceWindow.start) * _sceneRevealProgress : targetFloodProgress * _sceneRevealProgress; updateFloodTrace(floodProgress); updateSurgeFront(floodProgress); } renderHost.request('bhote-koshi-scene-beat-reveal'); if (linear < 1) { _sceneRevealAnimationFrame = requestAnimationFrame(step); } else { _sceneRevealAnimationFrame = null; renderHost.release(SCENE_REVEAL_RENDER_HOLD); _sceneRevealHoldActive = false; } }; _sceneRevealAnimationFrame = requestAnimationFrame(step); } function stopEvidenceVideoFrameLoop(video = _evidenceVideo) { if (_evidenceVideoFrameHandle == null) return; try { video?.cancelVideoFrameCallback?.(_evidenceVideoFrameHandle); } catch {} _evidenceVideoFrameHandle = null; } function startEvidenceVideoFrameLoop( video, index, generation, clipIn, clipDuration, ) { if ( !sceneEvidenceMediaAutoplays() || typeof video?.requestVideoFrameCallback !== 'function' || _evidenceVideoFrameHandle != null ) return; const drawFrame = () => { _evidenceVideoFrameHandle = null; if ( !_enabled || generation !== _evidenceVideoGeneration || video !== _evidenceVideo || !sceneEvidenceMediaAutoplays() ) return; if ( redrawEvidenceSlot(index, video, { videoActive: true, mediaProgress: clampUnit( ((Number(video.currentTime) || 0) - clipIn) / clipDuration, ), }) ) { renderHost.request('bhote-koshi-evidence-video-frame'); } _evidenceVideoFrameHandle = video.requestVideoFrameCallback(drawFrame); }; _evidenceVideoFrameHandle = video.requestVideoFrameCallback(drawFrame); } function releaseEvidenceVideo({ restorePoster = true } = {}) { const previousIndex = _evidenceVideoIndex; const video = _evidenceVideo; stopEvidenceVideoFrameLoop(video); _evidenceVideoGeneration += 1; _evidenceVideo = null; _evidenceVideoIndex = -1; _evidenceVideoReady = false; if (video) { try { video.pause(); } catch {} try { video.removeAttribute?.('src'); video.load?.(); } catch {} } if ( restorePoster && previousIndex >= 0 && redrawEvidenceSlot(previousIndex) ) { renderHost.request('bhote-koshi-evidence-poster-restore'); } } function activateEvidenceVideo(index) { if (_evidenceVideoIndex === index) return; releaseEvidenceVideo(); const observation = _evidenceTimeline[index]?.observation; const videoPath = observation?.media?.videoPath; if (!videoPath) return; const video = videoFactory(); if (!video) return; const generation = ++_evidenceVideoGeneration; _evidenceVideo = video; _evidenceVideoIndex = index; _evidenceVideoReady = false; video.muted = true; video.playsInline = true; video.preload = 'auto'; video.loop = true; const markReady = () => { if ( !_enabled || generation !== _evidenceVideoGeneration || video !== _evidenceVideo ) return; _evidenceVideoReady = true; renderHost.request('bhote-koshi-evidence-video-ready'); syncEvidenceMedia(); }; video.addEventListener?.('loadeddata', markReady, { once: true }); video.addEventListener?.('seeked', () => { if ( !_enabled || generation !== _evidenceVideoGeneration || video !== _evidenceVideo ) return; renderHost.request('bhote-koshi-evidence-video-seek'); }); video.src = eventAsset(videoPath); video.load?.(); } function syncEvidenceMedia() { if ( _presentation === BHOTE_KOSHI_SCENE_PRESENTATION && !_evidenceTimeline.some( ({ observation }) => observation.id === _sceneBeatId, ) ) { releaseEvidenceVideo(); _embeddedEvidenceIndex = -1; _embeddedMedia?.hide?.({ immediate: true }); return; } if (!sceneEvidenceCameraSettled()) { releaseEvidenceVideo(); _embeddedEvidenceIndex = -1; const warmingEvidence = sceneMediaPlaybackActive() && _presentation === BHOTE_KOSHI_SCENE_PRESENTATION && _sceneSurface === BHOTE_KOSHI_SCENE_EVIDENCE_BEAT; // Keep the same preload across camera frames; stop/disable still cancels it. _embeddedMedia?.hide?.({ preserveWarm: warmingEvidence }); if (warmingEvidence) { const upcoming = _evidenceTimeline.find( ({ observation }) => observation.id === _sceneBeatId, )?.observation; if (usesProviderEmbeddedEvidence(upcoming)) { _embeddedMedia?.warm?.({ observation: upcoming }); } else { _embeddedMedia?.warm?.(); } } return; } if ( _presentation === BHOTE_KOSHI_SCENE_PRESENTATION && _sceneSurface !== BHOTE_KOSHI_SCENE_EVIDENCE_BEAT ) { releaseEvidenceVideo(); _embeddedEvidenceIndex = -1; _embeddedMedia?.hide?.(); return; } const index = _presentation === BHOTE_KOSHI_SCENE_PRESENTATION && _sceneControls.evidenceHoldCard ? _evidenceTimeline.findIndex( ({ observation }) => observation.id === _sceneBeatId, ) : activeEvidenceIndex(_progress, _evidenceTimeline); if (index < 0) { releaseEvidenceVideo(); _embeddedEvidenceIndex = -1; _embeddedMedia?.hide?.(); return; } if (sceneTimelineSeek() && !sceneTimelineMediaVisible()) { releaseEvidenceVideo({ restorePoster: false }); _embeddedEvidenceIndex = index; _embeddedMedia?.hide?.({ immediate: true }); return; } if ( _evidenceTimeline[index]?.observation?.id === _sceneMediaPlaybackCompletedBeatId ) { if (_evidenceVideo || _evidenceVideoIndex >= 0) { releaseEvidenceVideo({ restorePoster: false }); } _embeddedEvidenceIndex = index; _embeddedMedia?.hide?.(); return; } if (syncEmbeddedEvidence(index)) { releaseEvidenceVideo(); return; } activateEvidenceVideo(index); const video = _evidenceVideo; const beat = _evidenceTimeline[index]; if (!video || !_evidenceVideoReady || !beat) return; const presentation = evidenceBeatPresentation(_progress, beat); const clipIn = Math.max(0, Number(beat.observation.media?.clipInSec) || 0); const declaredOut = Number(beat.observation.media?.clipOutSec); const mediaDuration = Number(video.duration); const clipOut = Number.isFinite(declaredOut) ? Math.max(clipIn + 0.1, declaredOut) : Number.isFinite(mediaDuration) ? Math.max(clipIn + 0.1, mediaDuration) : clipIn + 4; const clipDuration = clipOut - clipIn; const targetTime = clipIn + presentation.localProgress * clipDuration; const beatDuration = Math.max( 0.1, (Math.min(1, beat.deactivationProgress) - beat.activationProgress) * BHOTE_KOSHI_PLAYBACK_SECONDS, ); const sceneAutoplay = sceneEvidenceMediaAutoplays(); video.playbackRate = sceneAutoplay ? 1 : Math.max(0.5, Math.min(2, clipDuration / beatDuration)); if (!_playing && !sceneAutoplay) { stopEvidenceVideoFrameLoop(video); try { video.pause(); } catch {} if (Math.abs((Number(video.currentTime) || 0) - targetTime) > 0.04) { try { video.currentTime = targetTime; } catch {} } } else { if ( _playing && Math.abs((Number(video.currentTime) || 0) - targetTime) > 0.42 ) { try { video.currentTime = targetTime; } catch {} } if (video.paused) { try { const playback = video.play(); startSceneMediaPlayback(index); void playback?.catch?.(() => {}); } catch {} } if (sceneAutoplay) { startEvidenceVideoFrameLoop( video, index, _evidenceVideoGeneration, clipIn, clipDuration, ); } } if ( redrawEvidenceSlot(index, video, { videoActive: true, mediaProgress: presentation.localProgress, }) ) { renderHost.request('bhote-koshi-evidence-video-frame'); } } function publishEvidenceEntries(event) { const observations = _evidenceTimeline.map((beat) => beat.observation); _evidenceFrameSlots = observations.map((observation) => ({ frame: evidenceFrameFactory(observation), layout: evidenceCardLayout(observation), poster: null, stamp: 1, })); const entries = []; for (let index = 0; index < observations.length; index += 1) { const observation = observations[index]; const anchor = _evidenceAnchors[index]; const frameSlot = _evidenceFrameSlots[index]; entries.push({ id: `evidence-card-${observation.id}`, position: evidencePositionGetter(index), cullPosition: anchor, variant: 'thumbnail', paintLane: 'thumbnail', title: '', image: frameSlot, sourceAlpha: () => sceneEvidencePresentation(index).alpha, presentationScale: () => sceneEvidencePresentation(index).scale, leaderProgress: () => sceneEvidencePresentation(index).leaderProgress, contentAlpha: () => sceneEvidencePresentation(index).contentAlpha, anchorDot: true, requireImage: true, thumbnailWidth: frameSlot.layout.thumbnailWidth, thumbnailHeight: frameSlot.layout.thumbnailHeight, thumbnailPadX: 6, thumbnailPadTop: 6, thumbnailPadBottom: 7, thumbnailTitleGap: 0, thumbnailTitleHeight: 0, thumbnailTitleChars: 0, thumbnailRuleHeight: 3, thumbnailRuleColor: BHOTE_EVENT_ACCENT, thumbnailLeaderColor: WORLD_OVERLAY_STYLE.leader, thumbnailBackground: WORLD_OVERLAY_STYLE.background, thumbnailTitleColor: WORLD_OVERLAY_STYLE.title, thumbnailTitleFont: WORLD_OVERLAY_STYLE.fontTitle, accent: BHOTE_EVENT_ACCENT, priority: 1_000_000 - index, protected: true, active: true, collisionGroup: 'ambient-card', zIndex: 60, interactive: true, accessibilityLabel: `Open original source for ${observation.title}; capture time unverified`, activate: () => openExternal(observation.media.sourceUrl), minDistance: 0, maxDistance: 220000, distanceFadeStartRatio: 0.82, distanceScale: { near: 1200, nearValue: 1, far: 180000, farValue: 0.72, }, edgeFade: 'keyhole', horizonCull: true, terrainOcclusion: true, gapPx: 24, anchorRadiusPx: 4, anchorGapPaddingPx: 26, // This short upstream reach sits immediately above its witness pin. // Keep the landscape card below it rather than hiding the whole path. placement: _presentation === BHOTE_KOSHI_SCENE_PRESENTATION && observation.id === 'debris-dammed-lake' ? 'below' : 'right', verticalOnly: false, leaderStyle: 'elbow', viewportMargin: 18, }); entries.push({ id: `evidence-breadcrumb-${observation.id}`, position: evidencePositionGetter(index, true), cullPosition: anchor, variant: 'label', title: `${String(observation.sequence).padStart(2, '0')} · ${observation.shortTitle || observation.title}`, details: [], accent: WORLD_OVERLAY_STYLE.accent, priority: 80_000 - index, collisionGroup: 'ambient-label', zIndex: 35, minDistance: 0, maxDistance: 260000, distanceFadeStartRatio: 0.7, distanceScale: { near: 1800, nearValue: 1, far: 220000, farValue: 0.66, }, edgeFade: 'keyhole', horizonCull: true, terrainOcclusion: true, gapPx: 9, placement: 'above', }); } overlayHost.setEntries(BHOTE_KOSHI_OVERLAY_SOURCE_ID, entries, { cohortLimit: 16, collisionCapacity: 12, moving: false, }); overlayHost.setVisible( BHOTE_KOSHI_OVERLAY_SOURCE_ID, _presentation !== BHOTE_KOSHI_SCENE_PRESENTATION || _sceneSurface === BHOTE_KOSHI_SCENE_EVIDENCE_BEAT, ); } async function loadEvidencePosters(event, signal, generation) { const observations = _evidenceTimeline.map((beat) => beat.observation); await Promise.allSettled( observations.map(async (observation, index) => { const posterUrl = observation.media?.posterPath ? eventAsset(observation.media.posterPath) : observation.media?.posterUrl; if (!posterUrl) return; let poster = null; try { poster = await mediaLoader(posterUrl, { signal }); if (signal.aborted || !_enabled || generation !== _mediaGeneration) { closeFrame(poster); return; } const slot = _evidenceFrameSlots[index]; if (!slot) { closeFrame(poster); return; } closeFrame(slot.poster); slot.poster = poster; poster = null; if (!redrawEvidenceSlot(index)) return; renderHost.request('bhote-koshi-evidence-poster'); } catch (error) { closeFrame(poster); if ( error?.name !== 'AbortError' && error?.message !== 'Evidence poster decoding is unavailable' ) { console.warn( `[Data:BhoteKoshi] Evidence poster failed for ${observation.id}:`, error, ); } } }), ); } async function prepareEventTerrain(event, signal) { _sourceShotPaths = buildSourceShotPaths(event); const elevationOffsetM = _presentation === BHOTE_KOSHI_SCENE_PRESENTATION ? 55 : 8; _terrainPositions = event.reconstruction.corridor.map((point) => Cesium.Cartesian3.fromDegrees( point.lon, point.lat, point.elevationM + elevationOffsetM, ), ); prepareScenePath(); _evidenceTimeline = buildEvidenceTimeline( event.evidenceSpine, event.reconstruction.corridor, ); const timelineEvent = { ...event, evidenceSpine: _evidenceTimeline.map((beat) => beat.observation), }; _evidenceAnchors = await resolveEvidenceAnchors(timelineEvent, signal); throwIfEnableCancelled(signal, _enabled); _cinematicKeyframes = buildCinematicKeyframes( _evidenceTimeline, _evidenceAnchors, ); const requestedBeatProgress = evidenceBeatProgress( _sceneBeatId, _evidenceTimeline, _sceneBeatReveal, ); const sequenceWindow = sceneEvidenceSequenceAnimates() ? evidenceSequenceWindow(_sceneBeatId, _evidenceTimeline) : null; if (sequenceWindow) _progress = sequenceWindow.startProgress; else if (requestedBeatProgress != null) _progress = requestedBeatProgress; publishEvidenceEntries(timelineEvent); _mediaAbortController = new AbortController(); const mediaController = _mediaAbortController; const generation = ++_mediaGeneration; signal?.addEventListener('abort', () => mediaController.abort(), { once: true, }); void loadEvidencePosters(timelineEvent, mediaController.signal, generation); updateProgress(_progress); } function createFloodDataSource() { const tileSafe = usesPhotorealFloodSurface(); _floodSurfaceMode = tileSafe ? 'tiles' : 'terrain'; const haloMaterial = new Cesium.PolylineDashMaterialProperty({ color: FLOOD_COLOR.withAlpha(0.38), dashLength: 18, }); const coreMaterial = new Cesium.PolylineGlowMaterialProperty({ color: FLOOD_COLOR.withAlpha(0.96), glowPower: 0.24, taperPower: 0.7, }); _floodDataSource = new Cesium.CustomDataSource('bhote-koshi-flood'); _floodHalo = _floodDataSource.entities.add({ id: 'bhote-koshi-flood-halo', show: false, polyline: { positions: new Cesium.CallbackProperty( () => _corridorPositionCache.positions, false, ), width: 18, // Dynamic non-ground Cesium lines do not support depthFailMaterial. // Classify the active surface so later-refined mesh tiles cannot bury it. clampToGround: true, material: haloMaterial, classificationType: tileSafe ? Cesium.ClassificationType.BOTH : Cesium.ClassificationType.TERRAIN, }, }); _floodCore = _floodDataSource.entities.add({ id: 'bhote-koshi-flood-core', show: false, polyline: { positions: new Cesium.CallbackProperty( () => _corridorPositionCache.positions, false, ), width: 5, clampToGround: true, material: coreMaterial, classificationType: tileSafe ? Cesium.ClassificationType.BOTH : Cesium.ClassificationType.TERRAIN, }, }); _surgePoint = _floodDataSource.entities.add({ id: 'bhote-koshi-surge-front', show: false, position: Cesium.Cartesian3.ZERO, point: { pixelSize: 11, color: SURGE_COLOR, outlineColor: Cesium.Color.BLACK.withAlpha(0.85), outlineWidth: 3, heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, disableDepthTestDistance: tileSafe ? Number.POSITIVE_INFINITY : 0, }, }); _viewer.dataSources.add(_floodDataSource); } function syncFloodSurfaceMode() { const nextMode = usesPhotorealFloodSurface() ? 'tiles' : 'terrain'; if (!_viewer || !_floodDataSource || _floodSurfaceMode === nextMode) return; _viewer.dataSources.remove(_floodDataSource, true); _floodDataSource = null; _floodHalo = null; _floodCore = null; _surgePoint = null; _lastSurgePosition = null; _lastSurgeVisible = null; _corridorPositionCache = { visibleCount: -1, positions: [] }; createFloodDataSource(); } function createSplitControl(event) { _splitControl = createImagerySplit({ scene: _viewer.scene, initialValue: _split, id: 'bhote-koshi-split-line', handleClass: 'bhote-koshi-split-handle', cssProperty: '--bhote-koshi-split', beforeTitle: event.imagery.before.label, afterTitle: event.imagery.after.label, ariaLabel: 'Historical reference and post-event image divider', formatValueText: (beforePercent, afterPercent) => `A historical reference ${beforePercent} percent, B post-event ${afterPercent} percent`, onChange: setSplit, }); } function applyCinematicCamera() { if ( !_cinematicActive || !_viewer?.camera || _cinematicKeyframes.length === 0 ) return; if (_lastCinematicProgress === _progress) return; const pose = sampleCinematicPose( _progress, _cinematicKeyframes, _cinematicPose, ); if (!pose) return; _cinematicOffset.heading = Cesium.Math.toRadians(pose.headingDeg); _cinematicOffset.pitch = Cesium.Math.toRadians(pose.pitchDeg); _cinematicOffset.range = pose.rangeM; _viewer.camera.lookAt(pose.target, _cinematicOffset); _lastCinematicProgress = _progress; } function syncCinematicButton() { const button = _panelRefs?.cinematicButton; if (!button) return; setElementProperty( button, 'textContent', _cinematicActive ? '■ RELEASE CAMERA' : '◉ CINEMATIC', ); setElementAttribute(button, 'aria-pressed', String(_cinematicActive)); } function claimCinematicCamera() { if (!_enabled || _cinematicKeyframes.length === 0) return false; _cinematicReplayArmed = true; if (!_cinematicActive) { _cinematicActive = true; _lastCinematicProgress = null; announceNavigationAuthority('bhote-koshi-cinematic'); _viewer?.camera?.cancelFlight?.(); } syncCinematicButton(); return true; } function stopCinematic({ releaseCamera = true, preserveReplay = false, } = {}) { if (!preserveReplay) _cinematicReplayArmed = false; if (!_cinematicActive) { syncPanel(); return; } _cinematicActive = false; _lastCinematicProgress = null; if (releaseCamera) _viewer?.camera?.lookAtTransform?.(Cesium.Matrix4.IDENTITY); syncCinematicButton(); renderHost.request('bhote-koshi-cinematic-stop'); } function startCinematic({ play = true } = {}) { if (_progress >= 1) updateProgress(0); if (!claimCinematicCamera()) return false; applyCinematicCamera(); if (play && !_playing) startPlayback(); return true; } function toggleCinematic() { invalidateIntroAutostart(); if (_cinematicActive) { stopCinematic(); return; } startCinematic(); } function handleManualCameraInput() { invalidateIntroAutostart(); if (_cinematicActive) stopCinematic(); } function invalidateIntroAutostart() { _introGeneration += 1; if (_introTimer != null) window.clearTimeout(_introTimer); _introTimer = null; } function attachCameraInputRelease() { _cameraInputTarget = _viewer?.scene?.canvas || null; for (const type of CINEMATIC_INPUT_EVENTS) { _cameraInputTarget?.addEventListener?.(type, handleManualCameraInput); } } function detachCameraInputRelease() { for (const type of CINEMATIC_INPUT_EVENTS) { _cameraInputTarget?.removeEventListener?.(type, handleManualCameraInput); } _cameraInputTarget = null; } function handleEvidenceCardClick(event) { if ( !isPointerFree() || !_enabled || _presentation !== BHOTE_KOSHI_SCENE_PRESENTATION || _sceneSurface !== BHOTE_KOSHI_SCENE_EVIDENCE_BEAT ) return; const target = _evidenceClickTarget; const bounds = target?.getBoundingClientRect?.(); const offsetX = Number(event?.offsetX); const offsetY = Number(event?.offsetY); const clientX = Number(event?.clientX); const clientY = Number(event?.clientY); const x = Number.isFinite(offsetX) ? offsetX : Number.isFinite(clientX) && bounds ? clientX - bounds.left : Number.NaN; const y = Number.isFinite(offsetY) ? offsetY : Number.isFinite(clientY) && bounds ? clientY - bounds.top : Number.NaN; const hit = overlayHost.hitTest?.(x, y, { sourceId: BHOTE_KOSHI_OVERLAY_SOURCE_ID, filter: (entry) => String(entry?.id || '').startsWith('evidence-card-'), }); if (typeof hit?.entry?.activate !== 'function') return; hit.entry.activate(); } function attachEvidenceCardActivation() { _evidenceClickTarget = _viewer?.scene?.canvas || null; _evidenceClickTarget?.addEventListener?.('click', handleEvidenceCardClick); } function detachEvidenceCardActivation() { _evidenceClickTarget?.removeEventListener?.( 'click', handleEvidenceCardClick, ); _evidenceClickTarget = null; } function currentEvidenceIndex() { return Math.max(0, activeEvidenceIndex(_progress, _evidenceTimeline)); } function jumpToEvidenceBeat(direction) { if (!_enabled || _evidenceTimeline.length === 0) return false; invalidateIntroAutostart(); stopPlayback(); const targetIndex = adjacentEvidenceIndex( _progress, _evidenceTimeline, direction, ); if (targetIndex < 0) return false; if ( _presentation !== BHOTE_KOSHI_SCENE_PRESENTATION && !claimCinematicCamera() ) return false; updateProgress(_evidenceTimeline[targetIndex].activationProgress); return true; } async function navigateSceneShot(direction) { if ( !_sceneController?.loadAdjacentShot || !_sceneContext || _sceneActionPending ) return false; _sceneActionPending = true; syncPanel(); try { return await _sceneController.loadAdjacentShot( _sceneContext.sceneId, _sceneContext.shotId, direction, ); } finally { _sceneActionPending = false; syncPanel(); } } async function playSceneShot() { if (!_sceneController?.replayShot || !_sceneContext || _sceneActionPending) return false; _sceneActionPending = true; syncPanel(); try { return await _sceneController.replayShot( _sceneContext.sceneId, _sceneContext.shotId, ); } finally { _sceneActionPending = false; syncPanel(); } } async function playWholeScene() { if ( !_enabled || _presentation !== BHOTE_KOSHI_SCENE_PRESENTATION || !_sceneController?.continueScene || !_sceneContext?.sceneId || !_sceneContext?.shotId || _sceneActionPending ) return false; const action = { type: 'scene' }; _sceneActionPending = action; syncPanel(); try { return await _sceneController.continueScene( _sceneContext.sceneId, _sceneContext.shotId, ); } catch (error) { console.warn('[Bhote Koshi] Scene playback failed:', error); return false; } finally { // A disable/re-enable may have handed the panel to a newer action. if (_sceneActionPending === action) { _sceneActionPending = false; syncPanel(); } } } function handlePlay() { if (_presentation === BHOTE_KOSHI_SCENE_PRESENTATION) { void playSceneShot(); return; } togglePlayback(); } function handlePrevious() { if (_presentation === BHOTE_KOSHI_SCENE_PRESENTATION) { void navigateSceneShot(-1); return; } jumpToEvidenceBeat(-1); } function handleNext() { if (_presentation === BHOTE_KOSHI_SCENE_PRESENTATION) { void navigateSceneShot(1); return; } jumpToEvidenceBeat(1); } function replayCinematic() { if (!_enabled) return false; invalidateIntroAutostart(); stopPlayback(); updateProgress(0); return startCinematic(); } function handleStoryReplay() { replayCinematic(); } function previewProgress(progress) { if (_presentation !== BHOTE_KOSHI_SCENE_PRESENTATION) { invalidateIntroAutostart(); stopPlayback(); updateProgress(progress); return; } _sceneScrubProgress = clampUnit(progress); const sceneDurationSec = Number(_sceneContext?.sceneDurationSec) || 0; setElementProperty( _panelRefs?.progressInput, 'value', String(Math.round(_sceneScrubProgress * 1000)), ); setElementProperty( _panelRefs?.time, 'textContent', `~${elapsedLabel(_sceneScrubProgress, sceneDurationSec)} / ${elapsedLabel(1, sceneDurationSec)}`, ); if (_sceneSeekFrame != null) return; const schedule = globalThis.requestAnimationFrame || ((callback) => globalThis.setTimeout(callback, 16)); _sceneSeekFrame = schedule(() => { _sceneSeekFrame = null; void commitProgress(_sceneScrubProgress, { final: false }); }); } async function commitProgress(progress, { final = false } = {}) { if (_presentation !== BHOTE_KOSHI_SCENE_PRESENTATION) return; if ( !_sceneController?.seekScene || !_sceneContext?.sceneId || (_sceneActionPending && !['scene', 'scene-seek'].includes(_sceneActionPending.type)) ) { syncPanel(); return; } if (final && _sceneSeekFrame != null) { const cancel = globalThis.cancelAnimationFrame || globalThis.clearTimeout; cancel?.(_sceneSeekFrame); _sceneSeekFrame = null; } const requestedProgress = clampUnit(progress); _sceneScrubProgress = requestedProgress; const generation = ++_sceneSeekGeneration; const action = { type: 'scene-seek', generation }; _sceneActionPending = action; syncPanel(); try { await _sceneController.seekScene( _sceneContext.sceneId, requestedProgress, ); } catch (error) { console.warn('[Bhote Koshi] Scene clock seek failed:', error); } finally { if (generation === _sceneSeekGeneration && final) _sceneScrubProgress = null; if ( _sceneActionPending === action && generation === _sceneSeekGeneration ) { _sceneActionPending = false; syncPanel(); } } } function openActiveEvidenceSource() { const beatIndex = currentEvidenceIndex(); const sourceUrl = _evidenceTimeline[beatIndex]?.observation?.media?.sourceUrl; if (!sourceUrl) return false; openExternal(sourceUrl); return true; } function openGeolocationMap() { const sourceUrl = _event?.geolocationMap?.sourceUrl; if (!sourceUrl) return false; openExternal(sourceUrl); return true; } function syncPanel() { if (!_panelRefs || !_event) return; const sceneDirected = _presentation === BHOTE_KOSHI_SCENE_PRESENTATION; const sceneClockProgress = clampUnit( Number(_sceneContext?.sceneProgress) || 0, ); const visibleSceneProgress = _sceneScrubProgress == null ? sceneClockProgress : clampUnit(_sceneScrubProgress); const displayProgress = sceneDirected ? visibleSceneProgress : _progress; setElementProperty( _panelRefs.progressInput, 'value', String(Math.round(displayProgress * 1000)), ); setElementProperty( _panelRefs.progressInput, 'disabled', sceneDirected && (!_sceneController?.seekScene || !_sceneContext?.sceneId || Boolean( _sceneActionPending && !['scene', 'scene-seek'].includes(_sceneActionPending.type), )), ); setElementAttribute( _panelRefs.progressInput, 'aria-label', sceneDirected ? 'Seek Nepal scene clock' : 'Schematic downstream progression', ); setElementProperty( _panelRefs.timelineLabel, 'textContent', sceneDirected ? 'NEPAL SCENE CLOCK' : 'RECONSTRUCTION CLOCK', ); const sceneDuration = Number(_sceneContext?.sceneDurationSec) || 0; setElementProperty( _panelRefs.time, 'textContent', sceneDirected ? `~${elapsedLabel(visibleSceneProgress, sceneDuration)} / ${elapsedLabel(1, sceneDuration)}` : `~${elapsedLabel(_progress, _event.reconstruction.elapsedSeconds)} / ` + elapsedLabel(1, _event.reconstruction.elapsedSeconds), ); const playLabel = sceneDirected ? _sceneActionPending && _sceneActionPending.type !== 'scene' ? '… PLAYING SHOT' : '▶ PLAY SHOT' : _playing ? 'Ⅱ PAUSE' : _progress >= 1 ? _cinematicReplayArmed ? '↺ REPLAY CINEMATIC' : '↺ REPLAY' : '▶ PLAY'; setElementProperty(_panelRefs.playButton, 'textContent', playLabel); setElementProperty( _panelRefs.playSceneButton, 'textContent', _sceneActionPending?.type === 'scene' ? '… PLAYING SCENE' : '▶ PLAY SCENE', ); setElementProperty( _panelRefs.playSceneButton, 'disabled', !sceneDirected || !_sceneController?.continueScene || !_sceneContext?.sceneId || !_sceneContext?.shotId || Number(_sceneContext.shotIndex) >= Number(_sceneContext.shotCount) - 1 || Boolean(_sceneActionPending), ); setElementAttribute( _panelRefs.playSceneButton, 'aria-label', `Continue ${_sceneContext?.sceneTitle || 'current scene'} from next shot`, ); setElementProperty( _panelRefs.storyReplayButton, 'textContent', '↺ FULL STORY', ); if (!sceneDirected) { setElementProperty(_panelRefs.storyReplayButton, 'disabled', false); setElementAttribute( _panelRefs.storyReplayButton, 'aria-label', 'Replay the full reconstruction', ); } const beatIndex = currentEvidenceIndex(); const sceneBeatIndex = sceneDirected ? _evidenceTimeline.findIndex( ({ observation: item }) => item.id === _sceneBeatId, ) : beatIndex; const observation = _evidenceTimeline[sceneBeatIndex]?.observation || null; const sceneShotIndex = Math.max(0, Number(_sceneContext?.shotIndex) || 0); const beatCount = sceneDirected ? Math.max(0, Number(_sceneContext?.shotCount) || 0) : _evidenceTimeline.length; const displayIndex = sceneDirected ? sceneShotIndex : beatIndex; setElementProperty( _panelRefs.beatIndex, 'textContent', `${String(displayIndex + 1).padStart(2, '0')} / ${String(beatCount).padStart(2, '0')}`, ); setElementProperty( _panelRefs.beatTitle, 'textContent', sceneDirected ? _sceneContext?.shotTitle || 'SCENE SHOT' : observation?.shortTitle || observation?.title || 'EVIDENCE', ); const sourceCount = Math.max( 0, Math.floor(Number(observation?.corroboration?.sourceCount) || 0), ); const beatMeta = [ String(observation?.phase || 'EVIDENCE').toUpperCase(), 'TIME UNVERIFIED', sourceCount > 1 ? `${sourceCount} SOURCES` : '', observation?.imageryCoverage === 'outside' ? 'OUTSIDE IMAGERY SWIPE' : '', ] .filter(Boolean) .join(' / '); setElementProperty( _panelRefs.beatMeta, 'textContent', sceneDirected ? `${String(_sceneContext?.sceneTitle || 'NEPAL FLOOD INCIDENT').toUpperCase()} / AUTHORED SHOT` : beatMeta, ); setElementProperty( _panelRefs.previousButton, 'disabled', Boolean(_sceneActionPending) || displayIndex <= 0, ); setElementProperty( _panelRefs.nextButton, 'disabled', Boolean(_sceneActionPending) || beatCount === 0 || displayIndex >= beatCount - 1, ); setElementProperty( _panelRefs.playButton, 'disabled', sceneDirected && (!_sceneController?.replayShot || !_sceneContext || Boolean(_sceneActionPending)), ); setElementAttribute( _panelRefs.playButton, 'aria-label', sceneDirected ? `Play ${_sceneContext?.shotTitle || 'current scene shot'}` : 'Play reconstruction', ); setElementAttribute( _panelRefs.previousButton, 'aria-label', sceneDirected ? 'Load previous scene shot' : 'Previous story beat', ); setElementAttribute( _panelRefs.nextButton, 'aria-label', sceneDirected ? 'Load next scene shot' : 'Next story beat', ); setElementProperty( _panelRefs.openSourceButton, 'disabled', !observation?.media?.sourceUrl, ); syncCinematicButton(); setElementProperty( _panelRefs.splitInput, 'value', String(Math.round(_split * 100)), ); const beforePercent = Math.round(_split * 100); setElementProperty( _panelRefs.splitReadout, 'textContent', `${beforePercent} / ${100 - beforePercent}`, ); } function syncPresentationMode() { const sceneDirected = _presentation === BHOTE_KOSHI_SCENE_PRESENTATION; const imageryAvailable = !sceneDirected || _sceneControls.imageryComparison === true; const scrubberAvailable = !sceneDirected || Boolean(_sceneController?.seekScene); const knownSceneBeat = !sceneDirected || _evidenceTimeline.some( ({ observation }) => observation.id === _sceneBeatId, ); const evidenceAvailable = !sceneDirected || (_sceneSurface === BHOTE_KOSHI_SCENE_EVIDENCE_BEAT && knownSceneBeat); _panel?.classList?.toggle('bhote-event-scene-beat', sceneDirected); if (_panelRefs?.playSceneButton) _panelRefs.playSceneButton.hidden = !sceneDirected; if (_panelRefs?.imagerySection) _panelRefs.imagerySection.hidden = !imageryAvailable; if (_panelRefs?.observedStatus) _panelRefs.observedStatus.hidden = !imageryAvailable; if (_panelRefs?.progressInput) _panelRefs.progressInput.hidden = !scrubberAvailable; _splitControl?.setVisible(imageryAvailable); if (_beforeLayer) _beforeLayer.show = imageryAvailable; if (_afterLayer) _afterLayer.show = imageryAvailable; if (_panelRefs?.caveat) { _panelRefs.caveat.textContent = imageryAvailable ? _event?.reconstruction?.caveat || '' : 'SCHEMATIC CORRIDOR, NOT MODELED ARRIVAL TIME.'; } if (_panelRefs?.imageryCloudNote) _panelRefs.imageryCloudNote.hidden = !imageryAvailable; for (const [button, control, sceneDefault] of [ [_panelRefs?.cinematicButton, 'cinematic'], [_panelRefs?.storyReplayButton, 'storyReplay'], [_panelRefs?.corridorButton, 'corridor', true], ]) { if (!button) continue; const available = !sceneDirected || sceneDefault === true || _sceneControls[control] === true; button.hidden = !available; if (!available) button.disabled = true; } if (_panelRefs?.openSourceButton) _panelRefs.openSourceButton.hidden = !evidenceAvailable; overlayHost.setVisible(BHOTE_KOSHI_OVERLAY_SOURCE_ID, evidenceAvailable); } function updateFloodTrace(progress) { if (!_floodHalo || !_floodCore) return; if ( _presentation === BHOTE_KOSHI_SCENE_PRESENTATION && _sceneSurface !== BHOTE_KOSHI_SCENE_EVIDENCE_BEAT ) { _floodHalo.show = false; _floodCore.show = false; return; } if ( !updateCorridorPositionCache( progress, activeFloodPositions(), _corridorPositionCache, ) ) return; const positions = _corridorPositionCache.positions; _floodHalo.show = positions.length > 1; _floodCore.show = positions.length > 1; } function updateSurgeFront(progress) { if (!_surgePoint) return; if ( _presentation === BHOTE_KOSHI_SCENE_PRESENTATION && _sceneSurface !== BHOTE_KOSHI_SCENE_EVIDENCE_BEAT ) { _surgePoint.show = false; return; } const headPosition = _corridorPositionCache.headPosition; if ( headPosition && !Cesium.Cartesian3.equals(_lastSurgePosition, headPosition) ) { _surgePoint.position = headPosition; _lastSurgePosition = Cesium.Cartesian3.clone( headPosition, _lastSurgePosition || new Cesium.Cartesian3(), ); } const visible = Boolean(headPosition) && progress > 0 && (sceneUsesSourcePath() || sceneUsesPathHistory() || sceneKeepsFloodVisible() || progress < 1); if (_lastSurgeVisible !== visible) { _surgePoint.show = visible; _lastSurgeVisible = visible; } } function updateProgress(progress) { _witnessEmbedActive = false; _progress = clampUnit(progress); const sourceWindow = sceneUsesSourcePath() ? scenePathProgressWindow() : null; const floodProgress = scenePathMovesDuringCamera() || sceneUsesPathHistory() ? _scenePathRevealProgress : sourceWindow ? sourceWindow.start + (sourceWindow.target - sourceWindow.start) * _sceneRevealProgress : _presentation === BHOTE_KOSHI_SCENE_PRESENTATION && _sceneControls.evidencePath === 'none' ? 0 : floodProgressForStoryProgress( _progress, _event?.reconstruction?.storyFloodWindow, ) * (sceneEvidenceDefersUntilCameraSettles() && !sceneEvidenceSequenceAnimates() ? _sceneRevealProgress : 1); updateFloodTrace(floodProgress); updateSurgeFront(floodProgress); applyCinematicCamera(); syncEvidenceMedia(); syncPanel(); renderHost.request('bhote-koshi-progress'); } function setSplit(split) { _split = clampUnit(split); _splitControl?.setValue(_split); syncPanel(); renderHost.request('bhote-koshi-split'); } function stopPlayback() { if (!_playing) return; _playing = false; if (_animationFrame != null) cancelAnimationFrame(_animationFrame); _animationFrame = null; renderHost.release(FLOOD_RENDER_HOLD); syncEvidenceMedia(); syncPanel(); } function animationTick(timestamp) { if (!_playing) return; const elapsed = (timestamp - _playStartedAt) / 1000; const next = _playStartProgress + elapsed / BHOTE_KOSHI_PLAYBACK_SECONDS; updateProgress(next); if (next >= 1) { stopPlayback(); stopCinematic({ preserveReplay: true }); return; } _animationFrame = requestAnimationFrame(animationTick); } function startPlayback() { if (_playing) return; if (_progress >= 1) updateProgress(0); _playing = true; _playStartProgress = _progress; _playStartedAt = performance.now(); renderHost.hold(FLOOD_RENDER_HOLD); syncEvidenceMedia(); syncPanel(); _animationFrame = requestAnimationFrame(animationTick); } function togglePlayback() { invalidateIntroAutostart(); if (_playing) { stopPlayback(); return; } if (_progress >= 1 && _cinematicReplayArmed) { startCinematic(); return; } startPlayback(); } function focusCorridor() { if (!_event) return; invalidateIntroAutostart(); stopCinematic(); _witnessEmbedActive = false; _embeddedEvidenceIndex = -1; _embeddedMedia?.hide?.(); setSplit(0.5); focusView(_viewer, _event.viewpoints.corridor); } function removeImagery() { if (_beforeLayer) _imageryLayerCollection?.remove(_beforeLayer, true); if (_afterLayer) _imageryLayerCollection?.remove(_afterLayer, true); _beforeLayer = null; _afterLayer = null; _imageryLayerCollection = null; } async function init(viewer) { _viewer = viewer; _embeddedMedia = embeddedMediaFactory({ viewer }); await loadEvent(); console.log('[Data:BhoteKoshi] Event pack ready'); return true; } async function enable(viewer, { origin = 'local-restore', signal } = {}) { _viewer = viewer; _enabled = true; // Public lifecycle passes signal, not intent origin, to enable. Its atomic // restore has already applied the scene parameters; those own presentation. // A missing origin is passive, never authority to focus or start playback. const sceneDirected = _presentation === BHOTE_KOSHI_SCENE_PRESENTATION; try { const event = await loadEvent(); throwIfEnableCancelled(signal, _enabled); registerDynamicCredit(viewer, BHOTE_KOSHI_CREDIT); if (_mapStackController) { _comparisonLease = acquireImageryComparison(_mapStackController, { owner: BHOTE_KOSHI_LAYER_ID, switchPolicy: sceneDirected ? 'preserve' : 'esri', }); await _comparisonLease.ready; } throwIfEnableCancelled(signal, _enabled); await createImagery(event); throwIfEnableCancelled(signal, _enabled); await syncTerrainComparison(); throwIfEnableCancelled(signal, _enabled); createFloodDataSource(); createSplitControl(event); const panelView = createPanel(event, { close: () => _dataManager?.setEnabled(BHOTE_KOSHI_LAYER_ID, false, { origin: 'user', }), play: handlePlay, playScene: playWholeScene, toggleCinematic, previousBeat: handlePrevious, nextBeat: handleNext, replayCinematic: handleStoryReplay, openActiveSource: openActiveEvidenceSource, openGeolocationMap, previewProgress, commitProgress, setSplit, focusCorridor, }); _panel = panelView.panel; _panelRefs = panelView.refs; syncPresentationMode(); overlayHost.setVisible( BHOTE_KOSHI_OVERLAY_SOURCE_ID, !sceneDirected || _sceneSurface === BHOTE_KOSHI_SCENE_EVIDENCE_BEAT, ); await prepareEventTerrain(event, signal); throwIfEnableCancelled(signal, _enabled); attachCameraInputRelease(); attachEvidenceCardActivation(); updateProgress(_progress); setSplit(_split); if (scenePathMovesDuringCamera()) startScenePathReveal(); if (sceneEvidenceSequenceAnimates() && sceneEvidenceCameraSettled()) { startSceneEvidenceReveal(); } if (!sceneDirected && !PASSIVE_ENABLE_ORIGINS.has(origin)) focusCorridor(); if ( !sceneDirected && (origin === 'scene' || !PASSIVE_ENABLE_ORIGINS.has(origin)) ) { const introGeneration = ++_introGeneration; _introTimer = window.setTimeout(() => { if (introGeneration !== _introGeneration) return; _introTimer = null; if (!_enabled || _playing || _progress !== 0) return; if (origin === 'scene') startCinematic(); else startPlayback(); }, 2400); } return true; } catch (error) { try { await disable(); } catch (cleanupError) { console.warn( '[Data:BhoteKoshi] Failed-enable cleanup error:', cleanupError, ); } throw error; } } async function disable() { setSceneMediaPlayback(); _enabled = false; ++_comparisonSurfaceGeneration; _mapStackController?.clearTerrainComparison?.(); invalidateIntroAutostart(); stopPlayback(); stopCinematic(); stopSceneEvidenceReveal(); stopScenePathReveal(); stopSceneMediaPlayback(); detachCameraInputRelease(); detachEvidenceCardActivation(); _mediaAbortController?.abort(); _mediaAbortController = null; _mediaGeneration += 1; releaseEvidenceVideo({ restorePoster: false }); _embeddedMedia?.hide?.(); _embeddedEvidenceIndex = -1; _sceneMediaPlaybackCompletedBeatId = null; _witnessEmbedActive = false; overlayHost.clearSource(BHOTE_KOSHI_OVERLAY_SOURCE_ID); overlayHost.setVisible(BHOTE_KOSHI_OVERLAY_SOURCE_ID, false); removeImagery(); _terrainPositions = []; _sourceShotPaths.clear(); _scenePathPositionCache.clear(); _scenePathPositions = []; _scenePathBeatId = null; _scenePathStartProgress = 0; _scenePathRevealProgress = 0; _corridorPositionCache = { visibleCount: -1, positions: [] }; _evidenceTimeline = []; _evidenceAnchors = []; _cinematicKeyframes = []; for (const slot of _evidenceFrameSlots) { closeFrame(slot.frame); closeFrame(slot.poster); } _evidenceFrameSlots = []; if (_floodDataSource) _viewer.dataSources.remove(_floodDataSource, true); _floodDataSource = null; _floodSurfaceMode = null; _floodHalo = null; _floodCore = null; _surgePoint = null; _lastSurgePosition = null; _lastSurgeVisible = null; _panel?.remove(); document .getElementById?.('right-context-rail') ?.classList.remove('bhote-event-active'); _panel = null; _panelRefs = null; _splitControl?.destroy(); _splitControl = null; _progress = 0; _split = 0.5; _presentation = 'standalone'; _sceneBeatId = null; _sceneBeatReveal = 0.36; _sceneContext = null; _sceneSurface = BHOTE_KOSHI_SCENE_PANEL_ONLY; _sceneControls = {}; _sceneRevealProgress = 1; _sceneActionPending = false; _sceneScrubProgress = null; _sceneSeekGeneration += 1; if (_sceneSeekFrame != null) { const cancel = globalThis.cancelAnimationFrame || globalThis.clearTimeout; cancel?.(_sceneSeekFrame); _sceneSeekFrame = null; } const comparisonLease = _comparisonLease; _comparisonLease = null; try { await comparisonLease?.release(); } finally { renderHost.request('bhote-koshi-disable'); } return true; } async function destroy() { await disable(); _sceneClockUnsubscribe?.(); _sceneClockUnsubscribe = null; _embeddedMedia?.destroy?.(); _embeddedMedia = null; _event = null; _viewer = null; } /** * Apply a deterministic event beat without taking camera ownership from a * Scene shot. SceneDirector remains the sole camera writer while this layer * updates only the panel and event surfaces explicitly owned by that shot. */ function setParams(params = {}, { origin = 'programmatic' } = {}) { const previousBeatId = _sceneBeatId; const previouslyDeferred = sceneEvidenceDefersUntilCameraSettles(); const cameraWasSettled = sceneEvidenceCameraSettled(); const previousPathDuringCamera = scenePathMovesDuringCamera(); const previousTravelId = _sceneControls.cameraTravel?.id; const previousTravelActive = _sceneControls.cameraTravel?.active === true; const previousTravelCompleted = _sceneControls.cameraTravel?.completed === true; const previousTravelCancelled = _sceneControls.cameraTravel?.cancelled === true; if (params.presentation === BHOTE_KOSHI_SCENE_PRESENTATION) { _presentation = BHOTE_KOSHI_SCENE_PRESENTATION; _sceneSurface = params.sceneSurface === BHOTE_KOSHI_SCENE_EVIDENCE_BEAT ? BHOTE_KOSHI_SCENE_EVIDENCE_BEAT : BHOTE_KOSHI_SCENE_PANEL_ONLY; _sceneControls = params.sceneControls && typeof params.sceneControls === 'object' ? { ...params.sceneControls } : {}; } else if (params.presentation === 'standalone') { _presentation = 'standalone'; _sceneContext = null; _sceneSurface = BHOTE_KOSHI_SCENE_PANEL_ONLY; _sceneControls = {}; } if (params.sceneContext && typeof params.sceneContext === 'object') { _sceneContext = { ...params.sceneContext }; if (params.sceneContext.seeking !== true && !_sceneActionPending) { _sceneScrubProgress = null; } } if (typeof params.beatId === 'string' && params.beatId.trim()) { _sceneBeatId = params.beatId.trim(); _sceneBeatReveal = Number.isFinite(Number(params.beatReveal)) ? clampUnit(params.beatReveal) : 0.36; } else if (Object.hasOwn(params, 'progress')) { _sceneBeatId = null; } const sceneDirected = _presentation === BHOTE_KOSHI_SCENE_PRESENTATION; if (sceneDirected) { _presentation = BHOTE_KOSHI_SCENE_PRESENTATION; invalidateIntroAutostart(); stopPlayback(); stopCinematic(); } const nowDeferred = sceneEvidenceDefersUntilCameraSettles(); const cameraIsSettled = sceneEvidenceCameraSettled(); const beatChanged = previousBeatId !== _sceneBeatId; const travelId = _sceneControls.cameraTravel?.id; const travelActive = _sceneControls.cameraTravel?.active === true; const travelChanged = previousTravelId !== travelId || previousTravelActive !== travelActive || previousTravelCompleted !== (_sceneControls.cameraTravel?.completed === true) || previousTravelCancelled !== (_sceneControls.cameraTravel?.cancelled === true); if (beatChanged || !cameraIsSettled || !sceneEvidenceMediaAutoplays()) { stopSceneMediaPlayback(); _sceneMediaPlaybackCompletedBeatId = null; } if (!nowDeferred) { stopSceneEvidenceReveal(); _sceneRevealProgress = 1; } else if (!cameraIsSettled || beatChanged || !previouslyDeferred) { stopSceneEvidenceReveal(); _sceneRevealProgress = 0; } const sequenceWindow = sceneEvidenceSequenceAnimates() ? evidenceSequenceWindow(_sceneBeatId, _evidenceTimeline) : null; const beatProgress = sequenceWindow?.startProgress ?? evidenceBeatProgress(_sceneBeatId, _evidenceTimeline, _sceneBeatReveal); const hasProgress = beatProgress != null || Number.isFinite(Number(params.progress)); const hasSplit = Number.isFinite(Number(params.split)); if (hasProgress) _progress = beatProgress ?? clampUnit(params.progress); if (hasSplit) _split = clampUnit(params.split); if (_enabled) { if (beatChanged || travelChanged || !scenePathMovesDuringCamera()) { stopScenePathReveal(); } void syncTerrainComparison().catch((error) => { console.warn( '[Data:BhoteKoshi] Comparison surface unavailable:', error, ); }); syncFloodSurfaceMode(); syncPresentationMode(); prepareScenePath(); const timelineSeekActive = applySceneTimelineSeek(); const pathWindow = scenePathMovesDuringCamera() || sceneUsesPathHistory() ? scenePathProgressWindow() : null; if (timelineSeekActive) { // applySceneTimelineSeek reconstructed the exact authored phase. } else if (sceneUsesPathHistory()) { _scenePathRevealProgress = 1; } else if ( pathWindow && (beatChanged || travelChanged || !previousPathDuringCamera) ) { if (_sceneControls.cameraTravel?.completed) { _scenePathRevealProgress = pathWindow.target; } else if (!_sceneControls.cameraTravel?.cancelled) { _scenePathRevealProgress = pathWindow.start; } } if (hasProgress) updateProgress(_progress); if (hasSplit) setSplit(_split); if ( !timelineSeekActive && scenePathMovesDuringCamera() && travelActive && (beatChanged || travelChanged || !previousPathDuringCamera) ) { startScenePathReveal(); } if ( !timelineSeekActive && cameraIsSettled && (nowDeferred || sceneEvidenceSequenceAnimates()) && (beatChanged || !previouslyDeferred || !cameraWasSettled) ) { startSceneEvidenceReveal(); } if (!sceneDirected && params.cinematic === true) startCinematic({ play: params.autoPlay !== false }); else if (!sceneDirected && params.autoPlay === true) startPlayback(); } return true; } function getParams() { return { presentation: _presentation, ...(_presentation === BHOTE_KOSHI_SCENE_PRESENTATION ? { sceneSurface: _sceneSurface, ...(Object.keys(_sceneControls).length ? { sceneControls: { ..._sceneControls } } : {}), } : {}), ...(_sceneBeatId ? { beatId: _sceneBeatId, beatReveal: _sceneBeatReveal } : { progress: _progress }), split: _split, }; } return { id: BHOTE_KOSHI_LAYER_ID, name: 'Bhote Koshi Flood', // Scene-owned component; retain registration without a standalone menu row. showInTogglePanel: false, icon: '🌊', source: 'Vantor + GeoPera', updateInterval: 0, init, enable, disable, update: async () => true, destroy, attachDataManager, attachMapStackController, attachSceneController, setParams, getParams, getSceneShotMediaHold, setSceneMediaPlayback, getStats() { return { count: _enabled ? 1 : 0, status: _enabled ? 'nominal' : 'idle', source: 'Vantor + GeoPera', coverage: _enabled ? 'Rasuwa · 26 Aug 2026' : 'Event reconstruction', }; }, getPlaybackState() { return { enabled: _enabled, playing: _playing, progress: _progress, split: _split, }; }, getCinematicState() { const activeIndex = activeEvidenceIndex(_progress, _evidenceTimeline); return { active: _cinematicActive, progress: _progress, evidenceId: _evidenceTimeline[activeIndex]?.observation?.id || null, mediaReady: _evidenceVideoReady, mediaEvidenceId: _evidenceTimeline[_evidenceVideoIndex]?.observation?.id || null, }; }, }; } const bhoteKoshiEventLayer = createBhoteKoshiEventLayer(); export default bhoteKoshiEventLayer;