import * as Cesium from 'cesium'; import { clearOverlaySource, setOverlayEntries, } from '../overlays/worldOverlay.js'; import { governorRequestRender } from '../renderGovernor.js'; import { BHOTE_KOSHI_LOCATOR_CREDIT, registerDynamicCredit, showOsmCredit, hideOsmCredit, } from './dataCredits.js'; import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js'; import { INCIDENT_OVERVIEW_PLACES } from './bhoteKoshiIncidentPlaces.js'; import { t, msg, getLocale, isolate } from '../i18n/index.js'; const BHOTE_KOSHI_LOCATOR_LAYER_ID = 'bhote-koshi-locator'; export const BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID = 'bhote-koshi-locator-callout'; export const NEPAL_CONTEXT_PRESENTATION = 'nepal-context'; export const BHOTE_KOSHI_REGIONAL_PRESENTATION = 'bhote-koshi-regional'; export const BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION = 'bhote-koshi-city-context'; export const BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION = 'bhote-koshi-incident-places'; export const BHOTE_KOSHI_FLOOD_PATH_PRESENTATION = 'bhote-koshi-flood-path'; export const BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION = 'bhote-koshi-path-overview'; export const BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION = 'bhote-koshi-trigger-record'; const LEGACY_PATH_BORDER_PRESENTATION = 'bhote-koshi-path-border'; const BORDER_DRAW_DURATION_MS = 2400; const BORDER_START_DELAY_MS = 700; const INCIDENT_CALLOUT_GAP_MS = 200; const CALLOUT_REVEAL_DURATION_MS = 1200; const CALLOUT_PULSE_DURATION_MS = 1800; const CALLOUT_RING_MIN_SIZE_PX = 16; const CALLOUT_RING_SIZE_RANGE_PX = 14; const CITY_CALLOUT_START_DELAY_MS = 350; const CITY_CALLOUT_STAGGER_MS = 220; const CITY_CALLOUT_REVEAL_MS = 500; const PLACE_CALLOUT_START_DELAY_MS = 350; const PLACE_CALLOUT_STAGGER_MS = 650; const PLACE_CALLOUT_REVEAL_MS = 520; const FLOOD_PATH_START_DELAY_MS = 250; const FLOOD_PATH_DRAW_DURATION_MS = 4000; const TRIGGER_APPROACH_ARM_MS = 500; const TRIGGER_APPROACH_DURATION_SEC = 4.6; const TRIGGER_ORBIT_DURATION_SEC = 2.8; const TRIGGER_SOURCE_URL = 'https://earthquake.usgs.gov/earthquakes/eventpage/us7000tbwb/executive'; // Descriptive pin titles translate at display (same entries as the event // panel); place names stay Latin, anything else shows as authored. const PLACE_TITLE_TEXT = new Map( [ msg('Collapse viewpoint'), msg('Dammed lake'), msg('Second landslide'), msg('Border gate'), msg('Dandagaun viewpoint'), msg('Bidur bridge'), msg('Devighat bridge'), ].map((text) => [text.toUpperCase(), text]), ); function placeTitle(title) { const text = PLACE_TITLE_TEXT.get(title); // Isolated: the card draws left to right after its "11 · " counter, so in // Arabic a translated title with a Latin place name («نقطة الإطلالة في // DANDAGAUN») must keep its own right-to-left order. return text ? isolate(t(text).toLocaleUpperCase(getLocale())) : title; } const INCIDENT = Object.freeze({ lat: 28.3632333, lon: 85.4423639, heightM: 5200, title: msg('BHOTE KOSHI FLOOD INCIDENT'), subtitle: msg('NEPAL · 26 AUG 2026'), }); const TRIGGER_RECORD = Object.freeze({ lat: 28.271, lon: 85.515, heightM: 4200, title: msg('USGS INITIAL REPORT'), details: Object.freeze([ msg('26 AUG 2026 · 08:37 NPT'), msg('28.2710° N · 85.5150° E'), msg('INITIAL M4.4 · RECLASSIFIED M5.2 LANDSLIDE'), msg('USGS EVENT us7000tbwb · OPEN SOURCE'), ]), }); const TRIGGER_APPROACH_CAMERA = Object.freeze({ lat: 28.368, lon: 85.473, alt: 10526, heading: 156, pitch: -44, roll: 0, }); const TRIGGER_ORBIT_CAMERA = Object.freeze({ lat: 28.3769, lon: 85.4923, alt: 10526, heading: 168, pitch: -44, roll: 0, }); const NEPAL = Object.freeze({ lat: 28.3949, lon: 84.124, heightM: 6800, // i18n-ignore: country name, shown as is title: 'NEPAL', }); const NEARBY_CITIES = Object.freeze([ Object.freeze({ id: 'kathmandu', // i18n-ignore: city name title: 'KATHMANDU', lat: 27.7017, lon: 85.3206, heightM: 3600, placement: 'left', }), Object.freeze({ id: 'bhaktapur', // i18n-ignore: city name title: 'BHAKTAPUR', lat: 27.673, lon: 85.43, heightM: 3400, placement: 'right', }), Object.freeze({ id: 'dhulikhel', // i18n-ignore: city name title: 'DHULIKHEL', lat: 27.6221, lon: 85.5428, heightM: 3600, placement: 'right-lower', }), ]); const BORDER_COLOR = Cesium.Color.fromCssColorString('#20e7f2'); const BORDER_ACCENT = '#20e7f2'; const INCIDENT_ACCENT = '#ffad55'; const INCIDENT_COLOR = Cesium.Color.fromCssColorString(INCIDENT_ACCENT); const DEFAULT_OVERLAY_HOST = Object.freeze({ setEntries: setOverlayEntries, clearSource: clearOverlaySource, }); // Applications supply their boundary resolver; the layer has no server-route dependency. const DEFAULT_BOUNDARY_RESOLVER = async () => null; function abortError() { return new DOMException( 'Bhote Koshi locator activation cancelled', 'AbortError', ); } function normalizePresentation(value) { if (value === NEPAL_CONTEXT_PRESENTATION) return NEPAL_CONTEXT_PRESENTATION; if (value === BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION) { return BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION; } if (value === BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION) { return BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION; } if (value === BHOTE_KOSHI_FLOOD_PATH_PRESENTATION) { return BHOTE_KOSHI_FLOOD_PATH_PRESENTATION; } if ( value === BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION || value === LEGACY_PATH_BORDER_PRESENTATION ) { return BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION; } if (value === BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION) { return BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION; } return BHOTE_KOSHI_REGIONAL_PRESENTATION; } function presentationUsesBoundary(value) { return ( value === BHOTE_KOSHI_REGIONAL_PRESENTATION || value === BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION ); } function presentationAnimatesBoundary(value) { return value === BHOTE_KOSHI_REGIONAL_PRESENTATION; } function smoothstep(from, to, value) { if (to <= from) return value >= to ? 1 : 0; const amount = Math.max(0, Math.min(1, (value - from) / (to - from))); return amount * amount * (3 - 2 * amount); } /** Resolve the persistent text callout's dot, leader, and card entrance. */ export function anchoredCalloutPresentation(elapsedMs) { const progress = Math.max( 0, Math.min(1, Number(elapsedMs) / CALLOUT_REVEAL_DURATION_MS || 0), ); const contentAlpha = smoothstep(0.34, 1, progress); return { alpha: smoothstep(0, 0.18, progress), leaderProgress: smoothstep(0.1, 0.52, progress), contentAlpha, scale: 0.96 + contentAlpha * 0.04, }; } /** Return the progressive prefix used by the animated Nepal border. */ export function revealPolylinePositions(positions, progress) { if (!Array.isArray(positions) || positions.length < 2) return []; const clamped = Math.max(0, Math.min(1, Number(progress) || 0)); if (clamped >= 1) return positions; const scaled = clamped * (positions.length - 1); const completed = Math.floor(scaled); const revealed = positions.slice(0, completed + 1); const next = Math.min(completed + 1, positions.length - 1); if (next > completed) { revealed.push( Cesium.Cartesian3.lerp( positions[completed], positions[next], scaled - completed, new Cesium.Cartesian3(), ), ); } return revealed.length >= 2 ? revealed : [positions[0], positions[0]]; } /** Close a region ring without duplicating an already-closed final vertex. */ export function closeRegionRing(ring) { if (!Array.isArray(ring) || ring.length < 3) return []; const normalized = ring .map((point) => [Number(point?.[0]), Number(point?.[1])]) .filter(([lon, lat]) => Number.isFinite(lon) && Number.isFinite(lat)); if (normalized.length < 3) return []; const [firstLon, firstLat] = normalized[0]; const [lastLon, lastLat] = normalized[normalized.length - 1]; if (firstLon !== lastLon || firstLat !== lastLat) normalized.push([firstLon, firstLat]); return normalized; } /** Return the largest exterior ring from a Polygon or MultiPolygon GeoJSON. */ export function largestGeoJsonRing(geojson) { const polygons = geojson?.type === 'Polygon' ? [geojson.coordinates] : geojson?.type === 'MultiPolygon' ? geojson.coordinates : []; const rings = polygons .map((polygon) => polygon?.[0]) .filter((ring) => Array.isArray(ring) && ring.length >= 3); return rings.sort((a, b) => b.length - a.length)[0] || []; } /** * Lightweight scene overlay for the multi-shot Nepal incident setup. It uses * the local OpenStreetMap boundary proxy and deliberately avoids the full * reconstruction layer's imagery, panel, playback, and camera ownership. */ export function createBhoteKoshiLocatorLayer({ boundaryResolver = DEFAULT_BOUNDARY_RESOLVER, overlayHost = DEFAULT_OVERLAY_HOST, requestRender = governorRequestRender, now = () => performance.now(), scheduleFrame = (callback) => requestAnimationFrame(callback), cancelFrame = (handle) => cancelAnimationFrame(handle), scheduleDelay = (callback, delayMs) => setTimeout(callback, delayMs), cancelDelay = (handle) => clearTimeout(handle), } = {}) { let _viewer = null; let _dataSource = null; let _enabled = false; let _boundaryReady = false; let _boundaryResolved = false; let _boundaryRing = null; let _generation = 0; let _presentation = BHOTE_KOSHI_REGIONAL_PRESENTATION; let _borderAnimationFrame = null; let _calloutPulseFrame = null; let _triggerApproachTimer = null; let _removeTriggerMoveStart = null; let _removeTriggerMoveEnd = null; let _triggerApproachInFlight = false; let _triggerApproachAttempt = null; let _triggerCalloutStartedAt = Number.POSITIVE_INFINITY; let _approachEnabled = false; let _overlayEntries = []; let _placeRevealMoving = false; let _placeRevealSettledAt = Number.POSITIVE_INFINITY; function queueOverlayEntry(entry) { _overlayEntries.push(entry); } function publishOverlayEntries({ moving = false } = {}) { overlayHost.setEntries( BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID, _overlayEntries, { cohortLimit: Math.max(1, _overlayEntries.length), collisionCapacity: Math.max(1, _overlayEntries.length), moving, }, ); } function clearTriggerApproach({ preserveCallout = false } = {}) { // Revoke ownership before cancelFlight can synchronously deliver callbacks. _triggerApproachAttempt = null; if (_triggerApproachTimer !== null) cancelDelay(_triggerApproachTimer); _triggerApproachTimer = null; _removeTriggerMoveStart?.(); _removeTriggerMoveEnd?.(); _removeTriggerMoveStart = null; _removeTriggerMoveEnd = null; const wasInFlight = _triggerApproachInFlight; _triggerApproachInFlight = false; if (wasInFlight) _viewer?.camera?.cancelFlight?.(); if (!preserveCallout) _triggerCalloutStartedAt = Number.POSITIVE_INFINITY; } function stopAnimations() { if (_borderAnimationFrame !== null) cancelFrame(_borderAnimationFrame); if (_calloutPulseFrame !== null) cancelFrame(_calloutPulseFrame); _borderAnimationFrame = null; _calloutPulseFrame = null; _placeRevealMoving = false; _placeRevealSettledAt = Number.POSITIVE_INFINITY; clearTriggerApproach(); } function removeDataSource() { stopAnimations(); overlayHost.clearSource(BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID); if (_dataSource && _viewer?.dataSources) _viewer.dataSources.remove(_dataSource, true); _dataSource = null; } function addCallout( dataSource, subject, color, { labelPlacement = 'right', sequenceStartedAt = now(), visibleAfterMs = 0, showAnchor = true, } = {}, ) { const position = Cesium.Cartesian3.fromDegrees( subject.lon, subject.lat, subject.heightM, ); const visibleAt = sequenceStartedAt + visibleAfterMs; const isVisible = () => now() >= visibleAt; const pulseProgress = () => { const elapsed = Math.max(0, now() - visibleAt); return (elapsed % CALLOUT_PULSE_DURATION_MS) / CALLOUT_PULSE_DURATION_MS; }; const ringSize = new Cesium.CallbackProperty(() => { return ( CALLOUT_RING_MIN_SIZE_PX + CALLOUT_RING_SIZE_RANGE_PX * pulseProgress() ); }, false); const ringColor = new Cesium.CallbackProperty((_time, result) => { const dynamicColor = Cesium.Color.clone(color, result); dynamicColor.alpha = isVisible() ? 0.14 * (1 - pulseProgress()) : 0; return dynamicColor; }, false); const ringOutlineColor = new Cesium.CallbackProperty((_time, result) => { const dynamicColor = Cesium.Color.clone(color, result); dynamicColor.alpha = isVisible() ? 0.08 + 0.72 * (1 - pulseProgress()) : 0; return dynamicColor; }, false); if (showAnchor) { dataSource.entities.add({ id: `${subject === INCIDENT ? 'bhote-koshi-incident' : 'nepal-context'}-halo`, position, show: new Cesium.CallbackProperty(isVisible, false), point: { pixelSize: ringSize, color: ringColor, outlineColor: ringOutlineColor, outlineWidth: 2, disableDepthTestDistance: Number.POSITIVE_INFINITY, scaleByDistance: new Cesium.NearFarScalar( 250000, 0.8, 25000000, 1.35, ), }, }); } const presentation = () => anchoredCalloutPresentation(now() - visibleAt); if (subject === INCIDENT) { queueOverlayEntry({ id: 'bhote-koshi-incident-callout', position, variant: 'card', paintLane: 'thumbnail', title: t(subject.title), details: [t(subject.subtitle)], accent: INCIDENT_ACCENT, sourceAlpha: () => presentation().alpha, presentationScale: () => presentation().scale, leaderProgress: () => presentation().leaderProgress, contentAlpha: () => presentation().contentAlpha, anchorDot: true, leaderStyle: 'elbow', priority: 1_000_000, protected: true, active: true, collisionGroup: 'ambient-card', zIndex: 60, minDistance: 0, maxDistance: 5_000_000, distanceFadeStartRatio: 0.9, distanceScale: { near: 1200, nearValue: 1, far: 4_000_000, farValue: 0.78, }, edgeFade: 'none', horizonCull: true, terrainOcclusion: false, gapPx: 36, leaderOffsetPx: 4, placement: 'right', }); } else { const labelOnLeft = labelPlacement === 'left'; queueOverlayEntry({ id: 'nepal-context-callout', position, variant: 'label', paintLane: 'ambient-label', title: subject.title, details: subject.subtitle ? [subject.subtitle] : [], accent: BORDER_ACCENT, sourceAlpha: () => presentation().alpha, presentationScale: () => presentation().scale, leaderProgress: () => presentation().leaderProgress, contentAlpha: () => presentation().contentAlpha, anchorDot: showAnchor, leaderStyle: 'elbow', priority: 900_000, protected: true, active: true, collisionGroup: 'nepal-locator-label', zIndex: 48, minDistance: 0, maxDistance: 25_000_000, distanceFadeStartRatio: 0.9, distanceScale: { near: 250000, nearValue: 1.05, far: 25000000, farValue: 0.72, }, edgeFade: 'none', horizonCull: true, terrainOcclusion: false, gapPx: 18, placement: labelOnLeft ? 'left' : 'right', }); } } function startCalloutAnimation() { const generation = _generation; const pulseFrame = () => { if (!_enabled || generation !== _generation) return; if (_placeRevealMoving && now() >= _placeRevealSettledAt) { _placeRevealMoving = false; _placeRevealSettledAt = Number.POSITIVE_INFINITY; publishOverlayEntries(); } requestRender('bhote-koshi-locator-callout-pulse'); _calloutPulseFrame = scheduleFrame(pulseFrame); }; _calloutPulseFrame = scheduleFrame(pulseFrame); } function addIncidentCallout( dataSource, sequenceStartedAt, visibleAfterMs = 0, ) { addCallout(dataSource, INCIDENT, INCIDENT_COLOR, { sequenceStartedAt, visibleAfterMs, }); } function addTriggerRecordCallout(dataSource) { const position = Cesium.Cartesian3.fromDegrees( TRIGGER_RECORD.lon, TRIGGER_RECORD.lat, TRIGGER_RECORD.heightM, ); const isVisible = () => now() >= _triggerCalloutStartedAt; const presentation = () => anchoredCalloutPresentation(now() - _triggerCalloutStartedAt); const pulseProgress = () => { if (!isVisible()) return 0; return ( ((now() - _triggerCalloutStartedAt) % CALLOUT_PULSE_DURATION_MS) / CALLOUT_PULSE_DURATION_MS ); }; dataSource.entities.add({ id: 'bhote-koshi-trigger-record-halo', position, show: new Cesium.CallbackProperty(isVisible, false), point: { pixelSize: new Cesium.CallbackProperty( () => CALLOUT_RING_MIN_SIZE_PX + CALLOUT_RING_SIZE_RANGE_PX * pulseProgress(), false, ), color: new Cesium.CallbackProperty((_time, result) => { const color = Cesium.Color.clone(INCIDENT_COLOR, result); color.alpha = 0.16 * (1 - pulseProgress()); return color; }, false), outlineColor: new Cesium.CallbackProperty((_time, result) => { const color = Cesium.Color.clone(INCIDENT_COLOR, result); color.alpha = isVisible() ? 0.08 + 0.78 * (1 - pulseProgress()) : 0; return color; }, false), outlineWidth: 2, disableDepthTestDistance: Number.POSITIVE_INFINITY, scaleByDistance: new Cesium.NearFarScalar(3000, 1, 250000, 0.82), }, }); queueOverlayEntry({ id: 'bhote-koshi-trigger-record-callout', position, variant: 'card', paintLane: 'thumbnail', title: t(TRIGGER_RECORD.title), details: TRIGGER_RECORD.details.map((line) => t(line)), accent: INCIDENT_ACCENT, sourceAlpha: () => presentation().alpha, presentationScale: () => presentation().scale, leaderProgress: () => presentation().leaderProgress, contentAlpha: () => presentation().contentAlpha, anchorDot: true, leaderStyle: 'elbow', priority: 1_000_000, protected: true, active: true, interactive: true, accessibilityLabel: t( 'Open the USGS initial report for the 26 August 2026 Nepal landslide', ), activate: () => { const opened = window.open( TRIGGER_SOURCE_URL, '_blank', 'noopener,noreferrer', ); if (opened) opened.opener = null; }, collisionGroup: 'ambient-card', zIndex: 60, minDistance: 0, maxDistance: 1_000_000, distanceFadeStartRatio: 0.92, distanceScale: { near: 1200, nearValue: 1, far: 800000, farValue: 0.82 }, edgeFade: 'none', horizonCull: true, terrainOcclusion: false, gapPx: 34, leaderOffsetPx: 4, placement: 'left', }); } function startTriggerApproach(attempt) { if ( !_enabled || _presentation !== BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION || _triggerApproachAttempt !== attempt || attempt.phase !== 'armed' ) return; attempt.phase = 'flying'; if (_triggerApproachTimer !== null) cancelDelay(_triggerApproachTimer); _triggerApproachTimer = null; _removeTriggerMoveStart?.(); _removeTriggerMoveEnd?.(); _removeTriggerMoveStart = null; _removeTriggerMoveEnd = null; _triggerCalloutStartedAt = now(); requestRender('bhote-koshi-trigger-record-reveal'); const camera = _viewer?.camera; if (!camera?.flyTo) return; const generation = _generation; _triggerApproachInFlight = true; const finish = () => { if (_triggerApproachAttempt !== attempt) return; attempt.phase = 'finished'; _triggerApproachInFlight = false; }; camera.flyTo({ destination: Cesium.Cartesian3.fromDegrees( TRIGGER_APPROACH_CAMERA.lon, TRIGGER_APPROACH_CAMERA.lat, TRIGGER_APPROACH_CAMERA.alt, ), orientation: { heading: Cesium.Math.toRadians(TRIGGER_APPROACH_CAMERA.heading), pitch: Cesium.Math.toRadians(TRIGGER_APPROACH_CAMERA.pitch), roll: Cesium.Math.toRadians(TRIGGER_APPROACH_CAMERA.roll), }, duration: TRIGGER_APPROACH_DURATION_SEC, easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, complete: () => { if (_triggerApproachAttempt !== attempt || attempt.phase !== 'flying') return; if ( !_enabled || generation !== _generation || _presentation !== BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION ) { finish(); return; } attempt.phase = 'orbiting'; camera.flyTo({ destination: Cesium.Cartesian3.fromDegrees( TRIGGER_ORBIT_CAMERA.lon, TRIGGER_ORBIT_CAMERA.lat, TRIGGER_ORBIT_CAMERA.alt, ), orientation: { heading: Cesium.Math.toRadians(TRIGGER_ORBIT_CAMERA.heading), pitch: Cesium.Math.toRadians(TRIGGER_ORBIT_CAMERA.pitch), roll: Cesium.Math.toRadians(TRIGGER_ORBIT_CAMERA.roll), }, duration: TRIGGER_ORBIT_DURATION_SEC, easingFunction: Cesium.EasingFunction.SINUSOIDAL_IN_OUT, complete: finish, cancel: finish, }); }, cancel: finish, }); } function armTriggerApproach() { if (!_approachEnabled) { _triggerCalloutStartedAt = now(); return; } const attempt = { phase: 'armed' }; _triggerApproachAttempt = attempt; const start = () => startTriggerApproach(attempt); const camera = _viewer?.camera; if ( !camera?.moveStart?.addEventListener || !camera?.moveEnd?.addEventListener ) { start(); return; } _removeTriggerMoveStart = camera.moveStart.addEventListener(() => { if (_triggerApproachAttempt !== attempt || attempt.phase !== 'armed') return; if (_triggerApproachTimer !== null) cancelDelay(_triggerApproachTimer); _triggerApproachTimer = null; _removeTriggerMoveStart?.(); _removeTriggerMoveStart = null; _removeTriggerMoveEnd = camera.moveEnd.addEventListener(start); }); _triggerApproachTimer = scheduleDelay(start, TRIGGER_APPROACH_ARM_MS); } function addNepalCallout( dataSource, sequenceStartedAt = now(), labelPlacement = 'right', { showAnchor = true } = {}, ) { addCallout(dataSource, NEPAL, BORDER_COLOR, { labelPlacement, sequenceStartedAt, showAnchor, }); } function addNearbyCityCallouts(dataSource, sequenceStartedAt) { NEARBY_CITIES.forEach((city, index) => { const visibleAt = sequenceStartedAt + CITY_CALLOUT_START_DELAY_MS + index * CITY_CALLOUT_STAGGER_MS; const progress = () => smoothstep(0, 1, (now() - visibleAt) / CITY_CALLOUT_REVEAL_MS); const labelOnLeft = city.placement === 'left'; const labelLower = city.placement === 'right-lower'; const position = Cesium.Cartesian3.fromDegrees( city.lon, city.lat, city.heightM, ); queueOverlayEntry({ id: `bhote-koshi-city-${city.id}`, position, variant: 'label', paintLane: 'ambient-label', title: city.title, details: [], accent: BORDER_ACCENT, sourceAlpha: progress, presentationScale: () => 0.92 + progress() * 0.08, leaderProgress: progress, contentAlpha: progress, anchorDot: true, leaderStyle: 'elbow', priority: 700_000 - index, collisionGroup: 'nepal-nearby-city', zIndex: 44, minDistance: 0, maxDistance: 3_000_000, distanceFadeStartRatio: 0.82, distanceScale: { near: 100000, nearValue: 1.08, far: 3000000, farValue: 0.78, }, edgeFade: 'keyhole', horizonCull: true, terrainOcclusion: false, gapPx: 14, placement: labelLower ? 'below' : labelOnLeft ? 'left' : 'right', }); }); } function addIncidentPlaceCallouts( dataSource, sequenceStartedAt, { pulseHalos = false, places = INCIDENT_OVERVIEW_PLACES } = {}, ) { places.forEach((place, index) => { const visibleAt = sequenceStartedAt + PLACE_CALLOUT_START_DELAY_MS + index * PLACE_CALLOUT_STAGGER_MS; const progress = () => smoothstep(0, 1, (now() - visibleAt) / PLACE_CALLOUT_REVEAL_MS); const labelOnLeft = place.placement === 'left'; const labelLower = place.placement === 'right-lower'; const position = Cesium.Cartesian3.fromDegrees( place.lon, place.lat, place.heightM, ); if (pulseHalos) { const pulseSequenceDurationMs = PLACE_CALLOUT_STAGGER_MS * places.length; const pulseWindowElapsed = () => { const sequenceElapsed = Math.max( 0, now() - sequenceStartedAt - PLACE_CALLOUT_START_DELAY_MS, ); return ( (sequenceElapsed % pulseSequenceDurationMs) - index * PLACE_CALLOUT_STAGGER_MS ); }; const pulseIsActive = () => { const elapsed = pulseWindowElapsed(); return ( now() >= visibleAt && elapsed >= 0 && elapsed < PLACE_CALLOUT_STAGGER_MS ); }; const pulseProgress = () => { return Math.max( 0, Math.min(1, pulseWindowElapsed() / PLACE_CALLOUT_STAGGER_MS), ); }; dataSource.entities.add({ id: `bhote-koshi-place-${place.id}-halo`, position, show: new Cesium.CallbackProperty(pulseIsActive, false), point: { pixelSize: new Cesium.CallbackProperty( () => CALLOUT_RING_MIN_SIZE_PX + CALLOUT_RING_SIZE_RANGE_PX * pulseProgress(), false, ), color: new Cesium.CallbackProperty((_time, result) => { const color = Cesium.Color.clone(INCIDENT_COLOR, result); color.alpha = 0.16 * (1 - pulseProgress()); return color; }, false), outlineColor: new Cesium.CallbackProperty((_time, result) => { const color = Cesium.Color.clone(INCIDENT_COLOR, result); color.alpha = 0.08 + 0.78 * (1 - pulseProgress()); return color; }, false), outlineWidth: 2, disableDepthTestDistance: Number.POSITIVE_INFINITY, scaleByDistance: new Cesium.NearFarScalar(5000, 1, 500000, 0.82), }, }); } queueOverlayEntry({ id: `bhote-koshi-place-${place.id}`, position, variant: 'label', paintLane: 'ambient-label', title: `${String(index + 1).padStart(2, '0')} · ${placeTitle(place.title)}`, details: [], accent: INCIDENT_ACCENT, sourceAlpha: progress, presentationScale: () => 0.92 + progress() * 0.08, leaderProgress: progress, contentAlpha: progress, anchorDot: true, leaderStyle: 'elbow', priority: 800_000 - index, collisionGroup: 'bhote-koshi-place', zIndex: 46, minDistance: 0, maxDistance: 500_000, distanceFadeStartRatio: 0.82, distanceScale: { near: 5000, nearValue: 1.08, far: 500000, farValue: 0.82, }, edgeFade: 'keyhole', horizonCull: true, terrainOcclusion: false, gapPx: 14, placement: labelLower ? 'below' : labelOnLeft ? 'left' : 'right', }); }); } function addIncidentFloodPath(dataSource, sequenceStartedAt) { const positions = BHOTE_KOSHI_FLOOD_PATH.map(([lon, lat]) => Cesium.Cartesian3.fromDegrees(lon, lat), ); const drawStartedAt = sequenceStartedAt + FLOOD_PATH_START_DELAY_MS; const animatedPositions = new Cesium.CallbackProperty( () => revealPolylinePositions( positions, (now() - drawStartedAt) / FLOOD_PATH_DRAW_DURATION_MS, ), false, ); dataSource.entities.add({ id: 'bhote-koshi-flood-path', show: new Cesium.CallbackProperty(() => now() >= drawStartedAt, false), polyline: { positions: animatedPositions, width: 3.5, clampToGround: true, material: BORDER_COLOR.withAlpha(0.96), classificationType: Cesium.ClassificationType.BOTH, }, }); } function addNepalBoundary(dataSource, ring, sequenceStartedAt) { const closed = closeRegionRing(ring); if (closed.length < 4) return false; const positions = Cesium.Cartesian3.fromDegreesArray(closed.flat()); const hierarchy = new Cesium.PolygonHierarchy(positions.slice(0, -1)); const drawStartedAt = sequenceStartedAt + BORDER_START_DELAY_MS; const animatedPositions = new Cesium.CallbackProperty( () => revealPolylinePositions( positions, (now() - drawStartedAt) / BORDER_DRAW_DURATION_MS, ), false, ); dataSource.entities.add({ id: 'nepal-border-fill', show: new Cesium.CallbackProperty(() => now() >= drawStartedAt, false), polygon: { hierarchy, material: BORDER_COLOR.withAlpha(0.055), classificationType: Cesium.ClassificationType.BOTH, }, }); dataSource.entities.add({ id: 'nepal-border-halo', polyline: { positions: animatedPositions, width: 5, clampToGround: true, material: BORDER_COLOR.withAlpha(0.12), classificationType: Cesium.ClassificationType.BOTH, }, }); dataSource.entities.add({ id: 'nepal-border-highlight', polyline: { positions: animatedPositions, width: 2.5, clampToGround: true, material: BORDER_COLOR.withAlpha(0.96), classificationType: Cesium.ClassificationType.BOTH, }, }); const generation = _generation; const drawFrame = () => { if ( !_enabled || generation !== _generation || !presentationAnimatesBoundary(_presentation) ) return; requestRender('bhote-koshi-locator-border-draw'); if (now() - drawStartedAt < BORDER_DRAW_DURATION_MS) { _borderAnimationFrame = scheduleFrame(drawFrame); } else { _borderAnimationFrame = null; } }; _borderAnimationFrame = scheduleFrame(drawFrame); return true; } function renderPresentation() { if (!_dataSource) return; stopAnimations(); overlayHost.clearSource(BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID); _overlayEntries = []; _dataSource.entities.removeAll(); if (_presentation === NEPAL_CONTEXT_PRESENTATION) { addNepalCallout(_dataSource); } else if (_presentation === BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION) { const sequenceStartedAt = now(); addIncidentPlaceCallouts(_dataSource, sequenceStartedAt, { pulseHalos: true, places: INCIDENT_OVERVIEW_PLACES, }); _placeRevealMoving = true; _placeRevealSettledAt = sequenceStartedAt + PLACE_CALLOUT_START_DELAY_MS + (INCIDENT_OVERVIEW_PLACES.length - 1) * PLACE_CALLOUT_STAGGER_MS + PLACE_CALLOUT_REVEAL_MS; } else if (_presentation === BHOTE_KOSHI_FLOOD_PATH_PRESENTATION) { const sequenceStartedAt = now(); const settledPlacesAt = sequenceStartedAt - PLACE_CALLOUT_START_DELAY_MS - (INCIDENT_OVERVIEW_PLACES.length - 1) * PLACE_CALLOUT_STAGGER_MS - PLACE_CALLOUT_REVEAL_MS; addIncidentFloodPath(_dataSource, sequenceStartedAt); addIncidentPlaceCallouts(_dataSource, settledPlacesAt); } else if (_presentation === BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION) { const sequenceStartedAt = now(); const settledPathAt = sequenceStartedAt - FLOOD_PATH_START_DELAY_MS - FLOOD_PATH_DRAW_DURATION_MS; const settledPlacesAt = sequenceStartedAt - PLACE_CALLOUT_START_DELAY_MS - (INCIDENT_OVERVIEW_PLACES.length - 1) * PLACE_CALLOUT_STAGGER_MS - PLACE_CALLOUT_REVEAL_MS; addIncidentFloodPath(_dataSource, settledPathAt); addIncidentPlaceCallouts(_dataSource, settledPlacesAt); } else if (_presentation === BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION) { const sequenceStartedAt = now(); const settledPathAt = sequenceStartedAt - FLOOD_PATH_START_DELAY_MS - FLOOD_PATH_DRAW_DURATION_MS; addIncidentFloodPath(_dataSource, settledPathAt); addTriggerRecordCallout(_dataSource); armTriggerApproach(); } else { const sequenceStartedAt = now(); addNepalCallout(_dataSource, sequenceStartedAt, 'left'); if (!_boundaryResolved) { publishOverlayEntries(); startCalloutAnimation(); requestRender('bhote-koshi-locator-presentation'); return; } if (_presentation === BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION) { const settledAt = sequenceStartedAt - BORDER_START_DELAY_MS - BORDER_DRAW_DURATION_MS; if (_boundaryRing) addNepalBoundary(_dataSource, _boundaryRing, settledAt); addIncidentCallout( _dataSource, sequenceStartedAt - CALLOUT_REVEAL_DURATION_MS, ); addNearbyCityCallouts(_dataSource, sequenceStartedAt); } else { if (_boundaryRing) addNepalBoundary(_dataSource, _boundaryRing, sequenceStartedAt); const incidentDelayMs = _boundaryRing ? BORDER_START_DELAY_MS + BORDER_DRAW_DURATION_MS + INCIDENT_CALLOUT_GAP_MS : BORDER_START_DELAY_MS; addIncidentCallout(_dataSource, sequenceStartedAt, incidentDelayMs); } } publishOverlayEntries({ moving: _placeRevealMoving }); startCalloutAnimation(); requestRender('bhote-koshi-locator-presentation'); } async function init(viewer) { _viewer = viewer; return true; } async function enable(viewer, { signal } = {}) { _viewer = viewer; _enabled = true; _boundaryReady = false; _boundaryResolved = false; _boundaryRing = null; const generation = ++_generation; removeDataSource(); const dataSource = new Cesium.CustomDataSource('bhote-koshi-locator'); _dataSource = dataSource; renderPresentation(); viewer.dataSources.add(dataSource); registerDynamicCredit(viewer, BHOTE_KOSHI_LOCATOR_CREDIT); showOsmCredit(viewer, BHOTE_KOSHI_LOCATOR_LAYER_ID); requestRender('bhote-koshi-locator-callout'); try { const boundary = await boundaryResolver(signal); if (signal?.aborted || !_enabled || generation !== _generation) throw abortError(); _boundaryRing = closeRegionRing(boundary?.ring); _boundaryReady = _boundaryRing.length >= 4; _boundaryResolved = true; if (!_boundaryReady && presentationUsesBoundary(_presentation)) { console.warn('[Data:BhoteKoshiLocator] Nepal boundary unavailable'); } if (presentationUsesBoundary(_presentation)) { renderPresentation(); } requestRender('bhote-koshi-locator-boundary'); return true; } catch (error) { if (error?.name === 'AbortError') { if (generation === _generation) removeDataSource(); throw error; } console.warn( '[Data:BhoteKoshiLocator] Nepal boundary lookup failed:', error, ); _boundaryResolved = true; _boundaryRing = null; if (presentationUsesBoundary(_presentation)) renderPresentation(); requestRender('bhote-koshi-locator-degraded'); return true; } } async function disable() { hideOsmCredit(_viewer, BHOTE_KOSHI_LOCATOR_LAYER_ID); _enabled = false; _boundaryReady = false; _boundaryResolved = false; _boundaryRing = null; _generation += 1; removeDataSource(); requestRender('bhote-koshi-locator-disable'); return true; } async function destroy() { await disable(); _viewer = null; } return { id: BHOTE_KOSHI_LOCATOR_LAYER_ID, name: 'Bhote Koshi Locator', // Scene-owned component; retain registration without a standalone menu row. showInTogglePanel: false, icon: '◎', source: 'OpenStreetMap + GeoPera', updateInterval: 0, init, enable, disable, update: async () => true, destroy, cancelSceneMotion() { clearTriggerApproach({ preserveCallout: true }); }, setParams(params = {}) { _presentation = normalizePresentation(params.presentation); _approachEnabled = _presentation === BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION && params.preview !== true; if (_enabled) renderPresentation(); return true; }, getParams() { return { presentation: _presentation }; }, getRowControls() { return { chips: [ { id: 'nepal-context', // i18n-ignore: country name label: 'NEPAL', active: _presentation === NEPAL_CONTEXT_PRESENTATION, title: t( 'Show the Nepal context callout without a border or country fill', ), params: { presentation: NEPAL_CONTEXT_PRESENTATION }, }, { id: 'bhote-koshi-regional', // i18n-ignore: river name (Bhote Koshi) label: 'BHOTE', active: _presentation === BHOTE_KOSHI_REGIONAL_PRESENTATION, title: t( 'Introduce Nepal, draw its border, then reveal the Bhote Koshi incident', ), params: { presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION }, }, { id: 'bhote-koshi-city-context', label: t('CITIES'), active: _presentation === BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION, title: t( 'Keep the regional incident context and introduce nearby city labels', ), params: { presentation: BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION }, }, { id: 'bhote-koshi-incident-places', label: t('PLACES'), active: _presentation === BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION, title: t( 'Introduce the reconstruction scene key places from trigger to downstream', ), params: { presentation: BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION }, }, { id: 'bhote-koshi-flood-path', label: t('PATH'), active: _presentation === BHOTE_KOSHI_FLOOD_PATH_PRESENTATION, title: t( 'Animate the sourced cyan river-centerline route to Trishuli Bazaar', ), params: { presentation: BHOTE_KOSHI_FLOOD_PATH_PRESENTATION }, }, { id: 'bhote-koshi-path-overview', label: t('OVERVIEW'), active: _presentation === BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION, title: t( 'Keep the completed flood path and location labels visible', ), params: { presentation: BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION }, }, { id: 'bhote-koshi-trigger-record', label: t('RECORD'), active: _presentation === BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION, title: t( 'Reveal the trigger record while making a slow camera approach', ), params: { presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION, preview: true, }, }, ], legend: [], }; }, getStats() { return { count: _enabled ? 1 : 0, status: !_enabled ? 'idle' : _presentation === BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION || _presentation === BHOTE_KOSHI_FLOOD_PATH_PRESENTATION || _presentation === BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION || _presentation === BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION || _boundaryReady ? 'nominal' : 'degraded', source: 'OpenStreetMap + GeoPera', coverage: !_enabled ? 'Incident locator' : _presentation === NEPAL_CONTEXT_PRESENTATION ? 'Nepal context' : _presentation === BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION ? 'Bhote Koshi · Nearby cities' : _presentation === BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION ? 'Bhote Koshi · Incident places' : _presentation === BHOTE_KOSHI_FLOOD_PATH_PRESENTATION ? 'Bhote Koshi · Flood path' : _presentation === BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION ? 'Bhote Koshi · Path overview' : _presentation === BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION ? 'Bhote Koshi · Trigger record' : 'Nepal · Bhote Koshi', }; }, }; }