diff --git a/CHANGELOG.md b/CHANGELOG.md
index f3696f7ea..1b3d01413 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,18 @@
# Changelog
- Separate map-feature acquisition from annotation/search selection. Move road and mapped-installation decoding into source adapters while preserving geometry policy, cancellation, retry outcomes and compatibility exports.
+
+- Cancel hidden Nepal provider preloads on Stop, event disable and replacement, and respect drawing-tool pointer ownership for fallback evidence cards. Preserve @manjunath22466’s Nepal scene contribution and source attribution.
+
+- Cancel the Nepal Upper Valley locator's pending approach and orbit on scene Stop, replacement, seek, and teardown; late camera callbacks cannot take over a newer shot.
+
+- Keep completed flood history visible when later Nepal media-only shots arrive and reveal their source cards.
+- Reconcile an already-playing Nepal video when the YouTube API attaches, so missed playback notifications cannot truncate the seven-second clip or replay an already-ended clip.
+
+- Clamp the Nepal flood trail and surge marker to the active terrain or photoreal surface so refined 3D tiles cannot bury the path.
+
+- Preserve Nepal shot camera and map ownership through the public layer lifecycle; passive restoration does not start standalone playback.
+
- Separate transit snapshot/history acquisition from the layer and expose its bounded request service independently of Vite. Preserve feed selection, playback, cache policy and compatibility exports.
- Widen opaque sensor halos to 3 display pixels with a 30 px fleet core while retaining at least 60% opaque core coverage, adding contrast margin through thermal blur and bloom on bright roofs.
@@ -15,6 +27,25 @@
- Reject isolated transit history outliers without changing the active playback bracket; rejected live reports retain the last accepted route, mode and detection text.
+- Isolate optional scene-card presentation callbacks from the shared overlay projection path, preserving ordinary-layer allocation budgets and recovering from failed animation callbacks.
+
+- Rename scene shots inline with a double-click; Enter or focus loss saves, and Escape cancels.
+- Stop scene playback and delayed media when its event layer is explicitly closed; reject stale seek and replay completions. Ship only the Nepal incident default, without a separate reconstruction recipe.
+
+- Keep Nepal flood and locator components available through Scenes without separate entries in Data Layers.
+
+- Start the shared Nepal flood route at the Debris-Dammed Lake river reach, removing the earlier upstream section from overview paths.
+
+- Remove the redundant WITNESS panel shortcut; retain per-shot source media and Open Original links.
+
+- Follow the Mailung Bazzar, Dandaguan video's source clock through 0:07 and advance after its brief fade-out, including older saved scenes. Bound delayed or blocked playback and cancel late provider callbacks on Stop or replacement.
+
+- Keep the completed flood trail visible during the Debris-Dammed Lake to Second landslide camera handoff, without revealing the next reach early.
+
+- Preserve loaded base imagery when successive scene shots use the same provider, avoiding a bare-globe flash at shot handoffs.
+
+- Add the Nepal Flood Incident scene with geographic labels, synchronized event controls, linked witness sources, and Vantor comparison imagery over Esri. Nepal uses Google 3D when available and falls back to Esri with keyless terrain without rewriting saved shots. Bundled event imagery and derived data retain their separate non-commercial terms.
+
- Add an optional Nominatim geocoding adapter with configurable search/reverse endpoints, cancellation, bounded responses and retryable upstream errors. Extract portable response-reading and Overpass lexical helpers while retaining existing server exports.
- Expose reference feed factories independently of standalone catalog construction; preserve source choices and asset attribution.
diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md
index 1de1b9dc1..850d27e39 100644
--- a/DATA_SOURCES.md
+++ b/DATA_SOURCES.md
@@ -87,10 +87,12 @@ How to read this:
---
-## Bundled snapshots (committed under `src/data/local_data/`)
+## Bundled snapshots
Static datasets shipped in the repo for an out-of-the-box experience. **None are MIT** — each keeps its own license (see the carve-out in [LICENSE](LICENSE)). Each folder also has its own provenance README.
+The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `public/events/`, contains Vantor imagery crops and a GeoPera-derived river centerline under **CC BY-NC 4.0**, separately from the MIT code. The derived coordinate dataset in `src/data/bhoteKoshiFloodPath.js` has the same non-commercial restriction and is compiled into the Nepal scene. Commercial users must obtain separate permission or exclude both the event pack and that source-derived dataset from their source and build. Deleting only `public/events/bhote-koshi-2026/` does not remove all restricted data; excluding the scene also requires removing its registrations and imports before building. Linked witness posts and geolocation-map records retain their owners' terms; no social-media clips or cached posters are bundled. Other snapshots below are under `src/data/local_data/`.
+
| Dataset | Folder | License | Commercial use? | Attribution |
| --------------------------------------------------------------------------- | --------------------------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | --------------------------------------------------------------------------- |
| **Datacenters** (~4.3K) | `datacenters/` | **ODbL 1.0** (OpenStreetMap extract) | ✅ (attribution + share-alike on data) | "© OpenStreetMap contributors" |
diff --git a/LICENSE b/LICENSE
index 8cff5265a..4f01699a2 100644
--- a/LICENSE
+++ b/LICENSE
@@ -38,6 +38,14 @@ remain subject to their own licenses and terms. Notably:
Open Database License (ODbL) 1.0 — attribution + share-alike on the data.
- NASA FIRMS active-fire snapshot: CC0 / U.S. public domain (citation
requested).
+ - Bhote Koshi event imagery and derived river centerline
+ (public/events/bhote-koshi-2026/ and the coordinate dataset in
+ src/data/bhoteKoshiFloodPath.js): Vantor / GeoPera, CC BY-NC 4.0.
+ NOT for commercial use without separate permission. Source-linked witness
+ posts retain their owners' terms; no witness clips are bundled. See the
+ folder README for attribution and modification notices. The derived
+ coordinates remain third-party data even though they are embedded in a
+ JavaScript source file; the MIT grant does not cover that dataset.
Live sources (Google Maps, OpenSky, adsb.lol, AISStream, CelesTrak, USGS,
OSM Overpass, City of Austin, GBFS) are fetched at runtime under each
diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md
index 836d7e45a..40e441b25 100644
--- a/docs/CURRENT-STATE.md
+++ b/docs/CURRENT-STATE.md
@@ -1,6 +1,33 @@
# God's Eye View Current State
Search framing and annotations request semantic map features from an explicit source. The Overpass adapter owns bounded queries, member/tag decoding and request deadlines; callers retain candidate ranking, outline caching, deferred retries and scene placement. Empty, transient and throttled outcomes remain distinct. Traffic sources return road records and installation sources return mapped records with freshness/saturation metadata, so their layers no longer decode upstream elements. ALPR already normalizes its records in the source. Default providers, footprints, road directions, exact-viewport retries and source attribution are unchanged.
+
+Nepal media preloads survive repeated camera-flight updates, but Stop, event disable and replacement remove abandoned frames and revoke pending Facebook sessions. Fallback evidence-card clicks respect the shared drawing-tool pointer lease.
+
+Nepal flood lines classify the active terrain or photoreal surface, and the surge marker follows that surface. Geographic route coordinates are unchanged; dynamic lines do not rely on unsupported depth-failure materials. Later media-only shots keep their completed source-path history throughout arrival and card reveal.
+
+Scene Stop, replacement, seek, and teardown revoke the Upper Valley locator's delayed camera approach and orbit while preserving its visible callout. A revoked timer or camera callback cannot start another flight or interfere with a newer shot.
+
+Nepal scene ownership follows the parameters prepared by the public layer manager before activation, without depending on enable-time origin metadata. Enabling a scene beat preserves its selected map and camera; passive restoration does not schedule standalone playback.
+
+Animated scene cards sample their optional scale, opacity and leader callbacks once per projected frame. A failed callback suppresses that frame and can recover on the next one; ordinary layers keep the shared projection path without animation evaluation.
+
+Nepal Flood Incident is accessed through Scenes. Its flood and locator components remain registered for scene playback but are hidden from the Data Layers menu. Closing the event stops the active scene, including pending loads and media. The default catalog does not install a separate reconstruction scene; existing saved scenes remain untouched.
+
+Double-click a shot name to edit it inline. Enter or moving focus saves a nonempty name, Escape cancels, and saved names persist with the scene project.
+
+The Nepal overview route and lake-shot trail share the river-centerline starting point nearest Debris-Dammed Lake. No earlier upstream section is included; evidence pins remain separate from the schematic river geometry.
+
+The event panel omits the WITNESS shortcut. Shot media, source attribution, Open Original links and corridor navigation remain available.
+
+The Mailung Bazzar, Dandaguan YouTube embed follows the provider's playback clock through 0:07, then the scene advances after its 0.65-second media exit. The controller reconciles provider state and source time when the API attaches and while waiting for playback, including missed PLAYING notifications and an already-ended clip. Startup is bounded to five seconds, and stalled playback has a bounded timeout; unavailable or blocked media cannot strand the scene. The authored estimate remains seven seconds plus the exit, replacing older saved holds at runtime without rewriting them. Stop, replacement and teardown revoke pending playback callbacks.
+
+Persistent media-led Nepal shots preserve their completed upstream flood path during camera travel. The next reach still reveals after arrival; direct loads reconstruct the prefix and backward replay trims downstream history.
+
+Repeated map-stack requests retain the live imagery layer when its provider is unchanged. Scene handoffs keep loaded tiles while real source changes, switch cancellation, and tile-error fallbacks retain their existing behavior.
+
+Nepal Flood Incident is available in Scenes, with a 25-shot sequence, geographic labels, an event panel, scene scrubbing and shot playback. Comparison shots use Esri beneath Vantor imagery. Other Nepal shots use Google 3D when its tileset is available and fall back to Esri otherwise; without an ion token, the existing map stack uses Re:Earth terrain. Saved shots retain their authored camera and map preferences across keyed and keyless runtimes. The bundled pack contains historical 2021 and post-event 2026 imagery, not an isolated before/after measurement; the animated flood path is schematic, not a hazard model. Witness media opens or embeds original sources without bundled clips. See `public/events/bhote-koshi-2026/README.md` for provenance and non-commercial data terms.
+
Transit snapshot and selected-history reads use an explicit source interface. The standalone source selects the existing routes; rendering, playback and selection retain their existing owners. A portable request service owns registered-feed admission, bounded decoding, cache/revalidation, backoff and history; development and preview use the same thin adapter. Compatibility exports and source attribution are preserved.
Place search accepts an explicit Nominatim provider with independently configured search and reverse endpoints. The default offline/Google/Photon/local-fallback order is unchanged when no provider is selected. Provider adapters share normalized coordinates, viewport framing and reverse labels; roads and boundary geometry remain separate services. Portable capped-response and Overpass lexical helpers are exported independently of the Node server.
@@ -734,7 +761,7 @@ Non-object or array-valued properties reject the response instead of being treat
Launch payloads with missing records now say PAYLOAD DATA UNAVAILABLE. Missing names use Unnamed payload; absent or invalid mass stays unknown instead of appearing as 0 KG.
-Updated: August 24, 2026
+Updated: September 15, 2026
## Aircraft and vessel server modules
diff --git a/public/events/bhote-koshi-2026/README.md b/public/events/bhote-koshi-2026/README.md
new file mode 100644
index 000000000..f8d3657e7
--- /dev/null
+++ b/public/events/bhote-koshi-2026/README.md
@@ -0,0 +1,55 @@
+# Bhote Koshi 2026 event pack
+
+A non-commercial visualization of the 26 August 2026 Bhote Koshi outburst flood.
+This is not an official hazard model.
+
+## Provenance and licenses
+
+| File | Source and modifications | License |
+| --- | --- | --- |
+| `pre.webp` | Vantor WorldView-2 scene `10300100C86CED00`, observed 2021-10-16; natural-color crop of 85.295–85.379 E, 28.135–28.292 N, resampled to 2048×4352 and WebP-compressed | CC BY-NC 4.0 |
+| `post.webp` | Vantor WorldView-3 scene `B040001100881410`, observed 2026-08-27; same bounds and output geometry, with source clouds retained | CC BY-NC 4.0 |
+| `event.json` | GeoPera reconstruction, commit `59ee5bd`; every fourth published centerline point inside the image bounds, plus sixteen curated evidence records linking public witness posts and the GeoGeorgeShadrach geolocation map | CC BY-NC 4.0 for the derived centerline; linked posts and map records retain their owners' terms |
+| `src/data/bhoteKoshiFloodPath.js` (outside this directory) | Simplified GeoPera centerline from the Debris-Dammed Lake observation to Trishuli Bazaar, compiled into the scene and locator | CC BY-NC 4.0 for the coordinate dataset, separately from the executable code |
+
+These third-party assets are **not covered by the application's MIT license**.
+Keep attribution and the [CC BY-NC 4.0 license](https://creativecommons.org/licenses/by-nc/4.0/).
+For commercial use, obtain separate permission or exclude both this pack and the
+derived coordinate dataset in `src/data/bhoteKoshiFloodPath.js` from the source
+and build. Deleting this directory alone does not remove the compiled river
+data. Excluding the Nepal scene also requires removing its registrations and
+imports before building.
+
+Sources:
+
+- [Vantor Open Data Program](https://vantor.com/company/open-data-program)
+- [Historical source image](https://vantor-opendata.s3.amazonaws.com/events/Nepal-Flooding-Aug-2026/10300100C86CED00.tif)
+- [Post-event source image](https://vantor-opendata.s3.amazonaws.com/events/Nepal-Flooding-Aug-2026/B040001100881410.tif)
+- [GeoPera reconstruction](https://github.com/geo-pera/bhotekoshi-2026-reconstruction)
+- [GeoGeorgeShadrach public geolocation-map introduction](https://x.com/geogeorgeology/status/2093632283442053371)
+
+Witness clips and cached posters are not bundled. Cards link to the original
+source, or use the original platform's embed where available. An unavailable
+embed leaves the source link accessible. Capture times remain unverified.
+
+Esri basemap imagery and runtime terrain are fetched separately, not bundled;
+their provider attribution remains visible in the application.
+
+The Nepal sequence supports both keyed and keyless map setups. Google 3D is used
+for authored 3D shots when available; otherwise those shots use Esri imagery.
+Without a Cesium ion token, terrain comes from Re:Earth / Mapterhorn. With an
+ion token, the configured Cesium terrain remains available. Vantor comparison
+shots use Esri in either mode. Provider resolution does not rewrite saved shots.
+
+## Interpretation limits
+
+The 2021 image is historical context, not an immediate pre-event baseline.
+Differences also span development, seasons, sensors, and the July 2025
+Rasuwagadhi flood. The amber corridor is a schematic progression along the
+published river centerline, not modeled arrival time. Its width and timing
+must not be used for emergency planning, exposure analysis, or casualty claims.
+
+Evidence cards identify observations outside the Vantor comparison rectangle.
+The Rasuwagadhi witness focus uses the 2021 context image because the 2026
+scene does not provide useful coverage at that point. The Timure record groups
+five source-map placements, not five independent timing confirmations.
diff --git a/public/events/bhote-koshi-2026/event.json b/public/events/bhote-koshi-2026/event.json
new file mode 100644
index 000000000..d00f0d3a7
--- /dev/null
+++ b/public/events/bhote-koshi-2026/event.json
@@ -0,0 +1,1633 @@
+{
+ "schemaVersion": 1,
+ "id": "bhote-koshi-2026",
+ "title": "Bhote Koshi Outburst Flood",
+ "observedDate": "2026-08-26",
+ "geolocationMap": {
+ "credit": "GeoGeorgeShadrach public geolocation map",
+ "sourceUrl": "https://x.com/geogeorgeology/status/2093632283442053371"
+ },
+ "imagery": {
+ "rectangle": {
+ "west": 85.295,
+ "south": 28.135,
+ "east": 85.379,
+ "north": 28.292
+ },
+ "before": {
+ "path": "pre.webp",
+ "observedAt": "2021-10-16",
+ "label": "2021 HISTORICAL REFERENCE · 16 OCT 2021",
+ "source": "Vantor WorldView-2 Open Data"
+ },
+ "after": {
+ "path": "post.webp",
+ "observedAt": "2026-08-27",
+ "label": "2026 POST-EVENT · 27 AUG 2026",
+ "source": "Vantor WorldView-3 Open Data"
+ }
+ },
+ "reconstruction": {
+ "elapsedSeconds": 4170,
+ "label": "SCHEMATIC DOWNSTREAM PROGRESSION, NOT MODELED ARRIVAL TIME",
+ "caveat": "2021 historical reference versus 2026 post-event imagery; changes are cumulative and include the July 2025 Rasuwagadhi flood. Schematic downstream progression, not modeled arrival time; not an official hazard model.",
+ "corridor": [
+ {
+ "chainageM": 23705,
+ "lon": 85.37877,
+ "lat": 28.27962,
+ "elevationM": 1818.4
+ },
+ {
+ "chainageM": 23863,
+ "lon": 85.37756,
+ "lat": 28.27879,
+ "elevationM": 1811.7
+ },
+ {
+ "chainageM": 24020,
+ "lon": 85.37665,
+ "lat": 28.2777,
+ "elevationM": 1796.5
+ },
+ {
+ "chainageM": 24140,
+ "lon": 85.37667,
+ "lat": 28.27661,
+ "elevationM": 1783.5
+ },
+ {
+ "chainageM": 24260,
+ "lon": 85.37669,
+ "lat": 28.27553,
+ "elevationM": 1777.4
+ },
+ {
+ "chainageM": 24392,
+ "lon": 85.37701,
+ "lat": 28.27445,
+ "elevationM": 1777.6
+ },
+ {
+ "chainageM": 24537,
+ "lon": 85.37641,
+ "lat": 28.27336,
+ "elevationM": 1779.8
+ },
+ {
+ "chainageM": 24682,
+ "lon": 85.37643,
+ "lat": 28.27228,
+ "elevationM": 1781.3
+ },
+ {
+ "chainageM": 24814,
+ "lon": 85.37614,
+ "lat": 28.27119,
+ "elevationM": 1780.2
+ },
+ {
+ "chainageM": 24934,
+ "lon": 85.37616,
+ "lat": 28.27011,
+ "elevationM": 1780.1
+ },
+ {
+ "chainageM": 25054,
+ "lon": 85.37617,
+ "lat": 28.26903,
+ "elevationM": 1779.5
+ },
+ {
+ "chainageM": 25212,
+ "lon": 85.37589,
+ "lat": 28.26794,
+ "elevationM": 1775.7
+ },
+ {
+ "chainageM": 25357,
+ "lon": 85.37529,
+ "lat": 28.26685,
+ "elevationM": 1759.9
+ },
+ {
+ "chainageM": 25526,
+ "lon": 85.37408,
+ "lat": 28.26575,
+ "elevationM": 1739.7
+ },
+ {
+ "chainageM": 25684,
+ "lon": 85.37318,
+ "lat": 28.26466,
+ "elevationM": 1745.1
+ },
+ {
+ "chainageM": 25853,
+ "lon": 85.37198,
+ "lat": 28.26356,
+ "elevationM": 1734
+ },
+ {
+ "chainageM": 25973,
+ "lon": 85.37199,
+ "lat": 28.26248,
+ "elevationM": 1725.5
+ },
+ {
+ "chainageM": 26143,
+ "lon": 85.37079,
+ "lat": 28.26138,
+ "elevationM": 1729.2
+ },
+ {
+ "chainageM": 26313,
+ "lon": 85.36958,
+ "lat": 28.26029,
+ "elevationM": 1726.2
+ },
+ {
+ "chainageM": 26470,
+ "lon": 85.36836,
+ "lat": 28.26,
+ "elevationM": 1700.8
+ },
+ {
+ "chainageM": 26640,
+ "lon": 85.36715,
+ "lat": 28.2589,
+ "elevationM": 1714
+ },
+ {
+ "chainageM": 26809,
+ "lon": 85.36656,
+ "lat": 28.25781,
+ "elevationM": 1714
+ },
+ {
+ "chainageM": 26954,
+ "lon": 85.36596,
+ "lat": 28.25672,
+ "elevationM": 1706.8
+ },
+ {
+ "chainageM": 27111,
+ "lon": 85.36506,
+ "lat": 28.25563,
+ "elevationM": 1692.9
+ },
+ {
+ "chainageM": 27244,
+ "lon": 85.36477,
+ "lat": 28.25454,
+ "elevationM": 1691.4
+ },
+ {
+ "chainageM": 27376,
+ "lon": 85.36449,
+ "lat": 28.25346,
+ "elevationM": 1691.9
+ },
+ {
+ "chainageM": 27521,
+ "lon": 85.36511,
+ "lat": 28.25238,
+ "elevationM": 1689.4
+ },
+ {
+ "chainageM": 27666,
+ "lon": 85.36452,
+ "lat": 28.25129,
+ "elevationM": 1674.4
+ },
+ {
+ "chainageM": 27798,
+ "lon": 85.36423,
+ "lat": 28.2502,
+ "elevationM": 1669.5
+ },
+ {
+ "chainageM": 27931,
+ "lon": 85.36455,
+ "lat": 28.24912,
+ "elevationM": 1676.8
+ },
+ {
+ "chainageM": 28088,
+ "lon": 85.36365,
+ "lat": 28.24803,
+ "elevationM": 1673.2
+ },
+ {
+ "chainageM": 28245,
+ "lon": 85.36275,
+ "lat": 28.24694,
+ "elevationM": 1672.1
+ },
+ {
+ "chainageM": 28415,
+ "lon": 85.36154,
+ "lat": 28.24584,
+ "elevationM": 1672.1
+ },
+ {
+ "chainageM": 28585,
+ "lon": 85.36034,
+ "lat": 28.24474,
+ "elevationM": 1673.6
+ },
+ {
+ "chainageM": 28755,
+ "lon": 85.35912,
+ "lat": 28.24419,
+ "elevationM": 1680.3
+ },
+ {
+ "chainageM": 28912,
+ "lon": 85.35822,
+ "lat": 28.24309,
+ "elevationM": 1662.8
+ },
+ {
+ "chainageM": 29057,
+ "lon": 85.35763,
+ "lat": 28.242,
+ "elevationM": 1645.5
+ },
+ {
+ "chainageM": 29189,
+ "lon": 85.35734,
+ "lat": 28.24092,
+ "elevationM": 1636.4
+ },
+ {
+ "chainageM": 29334,
+ "lon": 85.35797,
+ "lat": 28.23984,
+ "elevationM": 1645.3
+ },
+ {
+ "chainageM": 29466,
+ "lon": 85.35768,
+ "lat": 28.23875,
+ "elevationM": 1645.1
+ },
+ {
+ "chainageM": 29611,
+ "lon": 85.35769,
+ "lat": 28.23767,
+ "elevationM": 1640.9
+ },
+ {
+ "chainageM": 29731,
+ "lon": 85.35771,
+ "lat": 28.23659,
+ "elevationM": 1643.8
+ },
+ {
+ "chainageM": 29876,
+ "lon": 85.35834,
+ "lat": 28.23551,
+ "elevationM": 1642.5
+ },
+ {
+ "chainageM": 30021,
+ "lon": 85.35897,
+ "lat": 28.23444,
+ "elevationM": 1645.7
+ },
+ {
+ "chainageM": 30166,
+ "lon": 85.3596,
+ "lat": 28.23336,
+ "elevationM": 1644.8
+ },
+ {
+ "chainageM": 30311,
+ "lon": 85.36022,
+ "lat": 28.23229,
+ "elevationM": 1640.9
+ },
+ {
+ "chainageM": 30455,
+ "lon": 85.36024,
+ "lat": 28.2312,
+ "elevationM": 1640.7
+ },
+ {
+ "chainageM": 30588,
+ "lon": 85.36056,
+ "lat": 28.23012,
+ "elevationM": 1617.5
+ },
+ {
+ "chainageM": 30720,
+ "lon": 85.36088,
+ "lat": 28.22905,
+ "elevationM": 1619.7
+ },
+ {
+ "chainageM": 30853,
+ "lon": 85.36059,
+ "lat": 28.22796,
+ "elevationM": 1606.7
+ },
+ {
+ "chainageM": 30985,
+ "lon": 85.36092,
+ "lat": 28.22688,
+ "elevationM": 1605
+ },
+ {
+ "chainageM": 31105,
+ "lon": 85.36093,
+ "lat": 28.2258,
+ "elevationM": 1591.3
+ },
+ {
+ "chainageM": 31238,
+ "lon": 85.36126,
+ "lat": 28.22472,
+ "elevationM": 1587.9
+ },
+ {
+ "chainageM": 31370,
+ "lon": 85.36097,
+ "lat": 28.22363,
+ "elevationM": 1588.5
+ },
+ {
+ "chainageM": 31515,
+ "lon": 85.36037,
+ "lat": 28.22254,
+ "elevationM": 1585.7
+ },
+ {
+ "chainageM": 31685,
+ "lon": 85.35917,
+ "lat": 28.22144,
+ "elevationM": 1573.5
+ },
+ {
+ "chainageM": 31854,
+ "lon": 85.35796,
+ "lat": 28.22035,
+ "elevationM": 1590.1
+ },
+ {
+ "chainageM": 32012,
+ "lon": 85.35706,
+ "lat": 28.21925,
+ "elevationM": 1580.1
+ },
+ {
+ "chainageM": 32156,
+ "lon": 85.35647,
+ "lat": 28.21816,
+ "elevationM": 1574.3
+ },
+ {
+ "chainageM": 32314,
+ "lon": 85.35557,
+ "lat": 28.21707,
+ "elevationM": 1561.1
+ },
+ {
+ "chainageM": 32459,
+ "lon": 85.35497,
+ "lat": 28.21598,
+ "elevationM": 1558.2
+ },
+ {
+ "chainageM": 32591,
+ "lon": 85.35468,
+ "lat": 28.21489,
+ "elevationM": 1554.7
+ },
+ {
+ "chainageM": 32723,
+ "lon": 85.355,
+ "lat": 28.21381,
+ "elevationM": 1552.4
+ },
+ {
+ "chainageM": 32856,
+ "lon": 85.35471,
+ "lat": 28.21273,
+ "elevationM": 1548.3
+ },
+ {
+ "chainageM": 32988,
+ "lon": 85.35443,
+ "lat": 28.21164,
+ "elevationM": 1546.4
+ },
+ {
+ "chainageM": 33121,
+ "lon": 85.35414,
+ "lat": 28.21055,
+ "elevationM": 1542.7
+ },
+ {
+ "chainageM": 33241,
+ "lon": 85.35415,
+ "lat": 28.20947,
+ "elevationM": 1541.1
+ },
+ {
+ "chainageM": 33373,
+ "lon": 85.35448,
+ "lat": 28.20839,
+ "elevationM": 1539.9
+ },
+ {
+ "chainageM": 33506,
+ "lon": 85.3548,
+ "lat": 28.20731,
+ "elevationM": 1536.1
+ },
+ {
+ "chainageM": 33626,
+ "lon": 85.35481,
+ "lat": 28.20623,
+ "elevationM": 1534.7
+ },
+ {
+ "chainageM": 33770,
+ "lon": 85.35422,
+ "lat": 28.20514,
+ "elevationM": 1530.3
+ },
+ {
+ "chainageM": 33915,
+ "lon": 85.35363,
+ "lat": 28.20405,
+ "elevationM": 1526.4
+ },
+ {
+ "chainageM": 34060,
+ "lon": 85.35303,
+ "lat": 28.20296,
+ "elevationM": 1526.2
+ },
+ {
+ "chainageM": 34193,
+ "lon": 85.35274,
+ "lat": 28.20187,
+ "elevationM": 1522.2
+ },
+ {
+ "chainageM": 34313,
+ "lon": 85.35276,
+ "lat": 28.20079,
+ "elevationM": 1523.5
+ },
+ {
+ "chainageM": 34445,
+ "lon": 85.35247,
+ "lat": 28.1997,
+ "elevationM": 1514.7
+ },
+ {
+ "chainageM": 34577,
+ "lon": 85.35218,
+ "lat": 28.19862,
+ "elevationM": 1508
+ },
+ {
+ "chainageM": 34735,
+ "lon": 85.35128,
+ "lat": 28.19752,
+ "elevationM": 1504.4
+ },
+ {
+ "chainageM": 34892,
+ "lon": 85.35038,
+ "lat": 28.19643,
+ "elevationM": 1496.7
+ },
+ {
+ "chainageM": 35024,
+ "lon": 85.35009,
+ "lat": 28.19534,
+ "elevationM": 1495.1
+ },
+ {
+ "chainageM": 35157,
+ "lon": 85.3498,
+ "lat": 28.19426,
+ "elevationM": 1493.6
+ },
+ {
+ "chainageM": 35302,
+ "lon": 85.34921,
+ "lat": 28.19317,
+ "elevationM": 1488.9
+ },
+ {
+ "chainageM": 35434,
+ "lon": 85.34892,
+ "lat": 28.19208,
+ "elevationM": 1484
+ },
+ {
+ "chainageM": 35579,
+ "lon": 85.34832,
+ "lat": 28.19099,
+ "elevationM": 1482.6
+ },
+ {
+ "chainageM": 35724,
+ "lon": 85.34773,
+ "lat": 28.1899,
+ "elevationM": 1481
+ },
+ {
+ "chainageM": 35893,
+ "lon": 85.34652,
+ "lat": 28.1888,
+ "elevationM": 1477.6
+ },
+ {
+ "chainageM": 36038,
+ "lon": 85.34593,
+ "lat": 28.18771,
+ "elevationM": 1475.5
+ },
+ {
+ "chainageM": 36158,
+ "lon": 85.34595,
+ "lat": 28.18663,
+ "elevationM": 1473.2
+ },
+ {
+ "chainageM": 36316,
+ "lon": 85.34505,
+ "lat": 28.18553,
+ "elevationM": 1467.4
+ },
+ {
+ "chainageM": 36473,
+ "lon": 85.34415,
+ "lat": 28.18444,
+ "elevationM": 1465.5
+ },
+ {
+ "chainageM": 36618,
+ "lon": 85.34355,
+ "lat": 28.18335,
+ "elevationM": 1466.3
+ },
+ {
+ "chainageM": 36763,
+ "lon": 85.34296,
+ "lat": 28.18226,
+ "elevationM": 1467.9
+ },
+ {
+ "chainageM": 36907,
+ "lon": 85.34236,
+ "lat": 28.18117,
+ "elevationM": 1464.2
+ },
+ {
+ "chainageM": 37027,
+ "lon": 85.34238,
+ "lat": 28.18009,
+ "elevationM": 1463.6
+ },
+ {
+ "chainageM": 37160,
+ "lon": 85.3427,
+ "lat": 28.17901,
+ "elevationM": 1474.1
+ },
+ {
+ "chainageM": 37280,
+ "lon": 85.34272,
+ "lat": 28.17793,
+ "elevationM": 1465.8
+ },
+ {
+ "chainageM": 37400,
+ "lon": 85.34274,
+ "lat": 28.17684,
+ "elevationM": 1456.3
+ },
+ {
+ "chainageM": 37532,
+ "lon": 85.34245,
+ "lat": 28.17576,
+ "elevationM": 1445.9
+ },
+ {
+ "chainageM": 37665,
+ "lon": 85.34216,
+ "lat": 28.17467,
+ "elevationM": 1431.7
+ },
+ {
+ "chainageM": 37797,
+ "lon": 85.34248,
+ "lat": 28.17359,
+ "elevationM": 1443.6
+ },
+ {
+ "chainageM": 37942,
+ "lon": 85.34189,
+ "lat": 28.1725,
+ "elevationM": 1446.6
+ },
+ {
+ "chainageM": 38062,
+ "lon": 85.3419,
+ "lat": 28.17142,
+ "elevationM": 1437.8
+ },
+ {
+ "chainageM": 38182,
+ "lon": 85.34192,
+ "lat": 28.17033,
+ "elevationM": 1444.8
+ },
+ {
+ "chainageM": 38327,
+ "lon": 85.34255,
+ "lat": 28.16926,
+ "elevationM": 1450.6
+ },
+ {
+ "chainageM": 38447,
+ "lon": 85.34256,
+ "lat": 28.16818,
+ "elevationM": 1444
+ },
+ {
+ "chainageM": 38604,
+ "lon": 85.34289,
+ "lat": 28.1671,
+ "elevationM": 1442.5
+ },
+ {
+ "chainageM": 38737,
+ "lon": 85.3426,
+ "lat": 28.16601,
+ "elevationM": 1436.6
+ },
+ {
+ "chainageM": 38881,
+ "lon": 85.342,
+ "lat": 28.16492,
+ "elevationM": 1428.4
+ },
+ {
+ "chainageM": 39026,
+ "lon": 85.34079,
+ "lat": 28.16436,
+ "elevationM": 1418.8
+ },
+ {
+ "chainageM": 39171,
+ "lon": 85.33958,
+ "lat": 28.16381,
+ "elevationM": 1417.9
+ },
+ {
+ "chainageM": 39328,
+ "lon": 85.33837,
+ "lat": 28.16298,
+ "elevationM": 1417
+ },
+ {
+ "chainageM": 39486,
+ "lon": 85.33747,
+ "lat": 28.16189,
+ "elevationM": 1418.6
+ },
+ {
+ "chainageM": 39606,
+ "lon": 85.33748,
+ "lat": 28.1608,
+ "elevationM": 1405.8
+ },
+ {
+ "chainageM": 39751,
+ "lon": 85.33658,
+ "lat": 28.15998,
+ "elevationM": 1408.6
+ },
+ {
+ "chainageM": 39908,
+ "lon": 85.33537,
+ "lat": 28.15915,
+ "elevationM": 1402.1
+ },
+ {
+ "chainageM": 40053,
+ "lon": 85.33416,
+ "lat": 28.1586,
+ "elevationM": 1398.3
+ },
+ {
+ "chainageM": 40198,
+ "lon": 85.33295,
+ "lat": 28.15804,
+ "elevationM": 1396.2
+ },
+ {
+ "chainageM": 40342,
+ "lon": 85.33204,
+ "lat": 28.15722,
+ "elevationM": 1389.7
+ },
+ {
+ "chainageM": 40500,
+ "lon": 85.33083,
+ "lat": 28.15639,
+ "elevationM": 1387.9
+ },
+ {
+ "chainageM": 40657,
+ "lon": 85.32962,
+ "lat": 28.15556,
+ "elevationM": 1384.2
+ },
+ {
+ "chainageM": 40814,
+ "lon": 85.32872,
+ "lat": 28.15447,
+ "elevationM": 1385.5
+ },
+ {
+ "chainageM": 40972,
+ "lon": 85.32751,
+ "lat": 28.15364,
+ "elevationM": 1379.1
+ },
+ {
+ "chainageM": 41129,
+ "lon": 85.32662,
+ "lat": 28.15255,
+ "elevationM": 1376.3
+ },
+ {
+ "chainageM": 41286,
+ "lon": 85.32541,
+ "lat": 28.15172,
+ "elevationM": 1368.8
+ },
+ {
+ "chainageM": 41431,
+ "lon": 85.32419,
+ "lat": 28.15117,
+ "elevationM": 1365.4
+ },
+ {
+ "chainageM": 41576,
+ "lon": 85.32298,
+ "lat": 28.15061,
+ "elevationM": 1365.1
+ },
+ {
+ "chainageM": 41721,
+ "lon": 85.32177,
+ "lat": 28.15005,
+ "elevationM": 1364.6
+ },
+ {
+ "chainageM": 41878,
+ "lon": 85.32056,
+ "lat": 28.14923,
+ "elevationM": 1357.3
+ },
+ {
+ "chainageM": 42035,
+ "lon": 85.31935,
+ "lat": 28.1484,
+ "elevationM": 1359.7
+ },
+ {
+ "chainageM": 42168,
+ "lon": 85.31813,
+ "lat": 28.14811,
+ "elevationM": 1354.2
+ },
+ {
+ "chainageM": 42337,
+ "lon": 85.31693,
+ "lat": 28.14702,
+ "elevationM": 1357
+ },
+ {
+ "chainageM": 42482,
+ "lon": 85.31571,
+ "lat": 28.14646,
+ "elevationM": 1357
+ },
+ {
+ "chainageM": 42639,
+ "lon": 85.3145,
+ "lat": 28.14563,
+ "elevationM": 1342.2
+ },
+ {
+ "chainageM": 42809,
+ "lon": 85.3133,
+ "lat": 28.14453,
+ "elevationM": 1344.9
+ },
+ {
+ "chainageM": 42966,
+ "lon": 85.3124,
+ "lat": 28.14344,
+ "elevationM": 1358.7
+ },
+ {
+ "chainageM": 43099,
+ "lon": 85.31211,
+ "lat": 28.14235,
+ "elevationM": 1358.4
+ },
+ {
+ "chainageM": 43244,
+ "lon": 85.31089,
+ "lat": 28.14234,
+ "elevationM": 1336.9
+ },
+ {
+ "chainageM": 43376,
+ "lon": 85.30966,
+ "lat": 28.14259,
+ "elevationM": 1343
+ },
+ {
+ "chainageM": 43546,
+ "lon": 85.30846,
+ "lat": 28.1415,
+ "elevationM": 1331.3
+ },
+ {
+ "chainageM": 43716,
+ "lon": 85.30726,
+ "lat": 28.1404,
+ "elevationM": 1331.4
+ },
+ {
+ "chainageM": 43885,
+ "lon": 85.30605,
+ "lat": 28.1393,
+ "elevationM": 1324.1
+ },
+ {
+ "chainageM": 44055,
+ "lon": 85.30485,
+ "lat": 28.1382,
+ "elevationM": 1341.6
+ },
+ {
+ "chainageM": 44187,
+ "lon": 85.30456,
+ "lat": 28.13712,
+ "elevationM": 1339
+ },
+ {
+ "chainageM": 44332,
+ "lon": 85.30396,
+ "lat": 28.13603,
+ "elevationM": 1326.1
+ },
+ {
+ "chainageM": 44447,
+ "lon": 85.30337,
+ "lat": 28.13521,
+ "elevationM": 1323
+ }
+ ],
+ "storyFloodWindow": {
+ "start": 0.195,
+ "end": 0.39
+ }
+ },
+ "witnessAnchors": [
+ {
+ "id": "rasuwagadhi-geoconfirmed",
+ "title": "RASUWAGADHI · SOURCE GEOCONFIRMED",
+ "details": [
+ "28.279942 N · 85.377725 E",
+ "WITNESS VIDEO · OPEN ORIGINAL"
+ ],
+ "lat": 28.279942,
+ "lon": 85.377725,
+ "sourceUrl": "https://x.com/Indowatchosint/status/2092640554568007958",
+ "provenance": "Published as GeoConfirmed in the linked IndoWatchOSINT post; original video credited there.",
+ "elevationM": 1836.4
+ }
+ ],
+ "fieldReports": [
+ {
+ "label": "Rasuwagadhi witness video",
+ "status": "SOURCE GEOCONFIRMED · WORLD-PINNED",
+ "url": "https://x.com/Indowatchosint/status/2092640554568007958"
+ },
+ {
+ "label": "Bhote Koshi valley footage · Nirmal Prasai",
+ "status": "SOURCE CREDITED · LOCATION UNRESOLVED",
+ "url": "https://x.com/GlobalUpdates24/status/2092602195812470980"
+ },
+ {
+ "label": "Flood footage · Surajit",
+ "status": "SOURCE CREDITED · LOCATION UNRESOLVED",
+ "url": "https://x.com/BNODesk/status/2092568283098575057"
+ },
+ {
+ "label": "Compilation and casualty claims",
+ "status": "REFERENCE ONLY · CLAIMS UNVERIFIED",
+ "url": "https://x.com/Emeka2026/status/2093196357242662947"
+ }
+ ],
+ "viewpoints": {
+ "corridor": {
+ "lat": 28.207,
+ "lon": 85.3548,
+ "targetElevationM": 1536,
+ "rangeM": 8600,
+ "headingDeg": 344,
+ "pitchDeg": -39
+ },
+ "witness": {
+ "lat": 28.2775,
+ "lon": 85.374,
+ "targetElevationM": 1795,
+ "rangeM": 1700,
+ "headingDeg": 0,
+ "pitchDeg": -60,
+ "imagery": "before"
+ }
+ },
+ "evidenceSpine": [
+ {
+ "id": "immediate-collapse-viewpoint",
+ "sequence": 1,
+ "title": "Immediate collapse viewpoint",
+ "shortTitle": "Collapse viewpoint",
+ "summary": "Geolocated viewpoint toward the upper-valley collapse zone.",
+ "phase": "initiation",
+ "lat": 28.3632333,
+ "lon": 85.4423639,
+ "fallbackElevationM": 2850,
+ "imageryCoverage": "outside",
+ "trigger": {
+ "mode": "phase",
+ "storyProgress": 0
+ },
+ "timing": {
+ "capturedAt": null,
+ "note": "capture time unverified"
+ },
+ "confidence": {
+ "location": "viewpoint geolocated",
+ "time": "unverified"
+ },
+ "media": {
+ "sourceUrl": "https://www.youtube.com/watch?v=DbqRexFxv3k",
+ "posterUrl": "https://i.ytimg.com/vi/DbqRexFxv3k/hqdefault.jpg",
+ "platform": "YouTube",
+ "publisher": "YouTube source",
+ "width": 240,
+ "height": 422,
+ "orientation": "portrait",
+ "fit": "contain",
+ "clipInSec": 0,
+ "clipOutSec": 8.6,
+ "rightsStatus": "Linked original source; media is not redistributed."
+ },
+ "provenance": {
+ "via": "GeoGeorgeShadrach public geolocation map",
+ "viaUrl": "https://x.com/geogeorgeology/status/2093632283442053371",
+ "rights": "third-party link only; media not redistributed"
+ }
+ },
+ {
+ "id": "debris-dammed-lake",
+ "sequence": 2,
+ "title": "Debris-dammed lake",
+ "shortTitle": "Dammed lake",
+ "summary": "Geolocated observation of impoundment above the border corridor.",
+ "phase": "damming",
+ "lat": 28.3346111,
+ "lon": 85.4861389,
+ "fallbackElevationM": 2750,
+ "imageryCoverage": "outside",
+ "trigger": {
+ "mode": "phase",
+ "storyProgress": 0.065
+ },
+ "timing": {
+ "capturedAt": null,
+ "note": "capture time unverified"
+ },
+ "confidence": {
+ "location": "geolocated",
+ "time": "unverified"
+ },
+ "media": {
+ "sourceUrl": "https://www.youtube.com/watch?v=HTwd724Hs2g",
+ "posterUrl": "https://i.ytimg.com/vi/HTwd724Hs2g/hqdefault.jpg",
+ "platform": "YouTube",
+ "publisher": "YouTube source",
+ "width": 480,
+ "height": 360,
+ "orientation": "landscape",
+ "fit": "cover",
+ "rightsStatus": "Linked original source; media is not redistributed."
+ },
+ "provenance": {
+ "via": "GeoGeorgeShadrach public geolocation map",
+ "viaUrl": "https://x.com/geogeorgeology/status/2093632283442053371",
+ "rights": "third-party link only; media not redistributed"
+ }
+ },
+ {
+ "id": "second-landslide",
+ "sequence": 3,
+ "title": "Second landslide",
+ "shortTitle": "Second landslide",
+ "summary": "A short source-linked excerpt of the second upper-valley landslide.",
+ "phase": "secondary failure",
+ "lat": 28.3379706,
+ "lon": 85.4688823,
+ "fallbackElevationM": 2750,
+ "imageryCoverage": "outside",
+ "trigger": {
+ "mode": "phase",
+ "storyProgress": 0.13
+ },
+ "timing": {
+ "capturedAt": null,
+ "note": "capture time unverified"
+ },
+ "confidence": {
+ "location": "geolocated",
+ "time": "unverified"
+ },
+ "media": {
+ "sourceUrl": "https://www.youtube.com/watch?v=hfCz1z0u4pk",
+ "platform": "YouTube",
+ "publisher": "YouTube source",
+ "width": 240,
+ "height": 426,
+ "orientation": "portrait",
+ "fit": "contain",
+ "clipInSec": 0,
+ "clipOutSec": 9.3,
+ "rightsStatus": "Linked original source; media is not redistributed."
+ },
+ "provenance": {
+ "via": "GeoGeorgeShadrach public geolocation map",
+ "viaUrl": "https://x.com/geogeorgeology/status/2093632283442053371",
+ "rights": "third-party link only; media not redistributed"
+ }
+ },
+ {
+ "id": "gyirong-border-gate",
+ "sequence": 4,
+ "title": "Gyirong border gate",
+ "shortTitle": "Border gate",
+ "summary": "Public footage geolocated at the Nepal-China border crossing.",
+ "phase": "border arrival",
+ "lat": 28.2808338,
+ "lon": 85.3780248,
+ "fallbackElevationM": 1818,
+ "imageryCoverage": "within",
+ "trigger": {
+ "mode": "phase",
+ "storyProgress": 0.195
+ },
+ "timing": {
+ "capturedAt": null,
+ "note": "capture time unverified"
+ },
+ "confidence": {
+ "location": "geolocated",
+ "time": "unverified"
+ },
+ "media": {
+ "sourceUrl": "https://www.youtube.com/watch?v=qlE51Eu0thk",
+ "posterUrl": "https://i.ytimg.com/vi/qlE51Eu0thk/hqdefault.jpg",
+ "platform": "YouTube",
+ "publisher": "YouTube source",
+ "width": 480,
+ "height": 360,
+ "orientation": "landscape",
+ "fit": "cover",
+ "clipInSec": 26,
+ "rightsStatus": "Linked original source; media is not redistributed."
+ },
+ "provenance": {
+ "via": "GeoGeorgeShadrach public geolocation map; independently consistent with the linked GeoConfirmed witness",
+ "viaUrl": "https://x.com/geogeorgeology/status/2093632283442053371",
+ "rights": "third-party link only; media not redistributed"
+ }
+ },
+ {
+ "id": "timure-cluster",
+ "sequence": 5,
+ "title": "Timure evidence cluster",
+ "shortTitle": "Timure",
+ "summary": "Five geolocated public viewpoints document the flood passage.",
+ "phase": "propagation",
+ "lat": 28.2519361,
+ "lon": 85.3665833,
+ "fallbackElevationM": 1689,
+ "imageryCoverage": "within",
+ "trigger": {
+ "mode": "phase",
+ "storyProgress": 0.26
+ },
+ "timing": {
+ "capturedAt": null,
+ "note": "capture time unverified"
+ },
+ "confidence": {
+ "location": "cluster",
+ "time": "unverified"
+ },
+ "media": {
+ "sourceUrl": "https://www.youtube.com/watch?v=gb8cGELvskA",
+ "posterUrl": "https://i.ytimg.com/vi/gb8cGELvskA/hqdefault.jpg",
+ "platform": "YouTube",
+ "publisher": "YouTube source",
+ "width": 240,
+ "height": 426,
+ "orientation": "portrait",
+ "fit": "cover",
+ "clipInSec": 0,
+ "clipOutSec": 10.1,
+ "rightsStatus": "Linked original source; media is not redistributed."
+ },
+ "provenance": {
+ "via": "GeoGeorgeShadrach public geolocation map",
+ "viaUrl": "https://x.com/geogeorgeology/status/2093632283442053371",
+ "rights": "third-party link only; media not redistributed"
+ },
+ "corroboration": {
+ "kind": "multi-source geolocation cluster",
+ "sourceCount": 5,
+ "displayedSourceCount": 1,
+ "note": "Five distinct Timure placements are recorded in the public source map; this card opens one representative source."
+ }
+ },
+ {
+ "id": "syabru-besi",
+ "sequence": 6,
+ "title": "Syabru Besi",
+ "shortTitle": "Syabru Besi",
+ "summary": "A Facebook-sourced downstream viewpoint along the reconstructed corridor.",
+ "phase": "downstream passage",
+ "lat": 28.1602583333333,
+ "lon": 85.3325666666667,
+ "fallbackElevationM": 1417,
+ "imageryCoverage": "within",
+ "trigger": {
+ "mode": "phase",
+ "storyProgress": 0.325
+ },
+ "timing": {
+ "capturedAt": null,
+ "note": "capture time unverified"
+ },
+ "confidence": {
+ "location": "viewpoint geolocated",
+ "time": "unverified"
+ },
+ "media": {
+ "sourceUrl": "https://www.facebook.com/reel/1571491657757829",
+ "platform": "Facebook",
+ "publisher": "Facebook source",
+ "width": 240,
+ "height": 426,
+ "orientation": "portrait",
+ "fit": "contain",
+ "clipInSec": 0,
+ "clipOutSec": 10,
+ "rightsStatus": "Linked original source; media is not redistributed."
+ },
+ "provenance": {
+ "via": "GeoGeorgeShadrach public geolocation map, record 19",
+ "viaUrl": "https://x.com/geogeorgeology/status/2093632283442053371",
+ "rights": "third-party link only; media not redistributed"
+ }
+ },
+ {
+ "id": "dhunche",
+ "sequence": 7,
+ "title": "Dhunche",
+ "shortTitle": "Dhunche",
+ "summary": "Flood surge visible from a Dhunche hillside viewpoint.",
+ "phase": "downstream passage",
+ "lat": 28.1131861111111,
+ "lon": 85.2953305555556,
+ "fallbackElevationM": 1300,
+ "imageryCoverage": "outside",
+ "trigger": {
+ "mode": "phase",
+ "storyProgress": 0.39
+ },
+ "timing": {
+ "capturedAt": null,
+ "note": "capture time unverified"
+ },
+ "confidence": {
+ "location": "geolocated",
+ "time": "unverified"
+ },
+ "media": {
+ "sourceUrl": "https://www.youtube.com/watch?v=r5OYhYnBX6E",
+ "platform": "YouTube",
+ "publisher": "YouTube source",
+ "width": 240,
+ "height": 426,
+ "orientation": "portrait",
+ "fit": "contain",
+ "clipInSec": 0,
+ "clipOutSec": 14.43,
+ "rightsStatus": "Linked original source; media is not redistributed."
+ },
+ "provenance": {
+ "via": "GeoGeorgeShadrach public geolocation map",
+ "viaUrl": "https://x.com/geogeorgeology/status/2093632283442053371",
+ "rights": "third-party link only; media not redistributed"
+ }
+ },
+ {
+ "id": "mailung-upper-trishuli",
+ "sequence": 8,
+ "title": "Mailung / Upper Trishuli bridge",
+ "shortTitle": "Upper Trishuli",
+ "summary": "A bridge-side view of the surge between Dhunche and Mailung Bazzar.",
+ "phase": "downstream passage",
+ "lat": 28.090925,
+ "lon": 85.2322916666667,
+ "fallbackElevationM": 950,
+ "imageryCoverage": "outside",
+ "trigger": {
+ "mode": "phase",
+ "storyProgress": 0.455
+ },
+ "timing": {
+ "capturedAt": null,
+ "note": "capture time unverified"
+ },
+ "confidence": {
+ "location": "geolocated",
+ "time": "unverified"
+ },
+ "media": {
+ "sourceUrl": "https://www.facebook.com/watch/?v=1291517609615680",
+ "platform": "Facebook",
+ "publisher": "Facebook source",
+ "width": 240,
+ "height": 426,
+ "orientation": "portrait",
+ "fit": "contain",
+ "clipInSec": 0,
+ "clipOutSec": 12,
+ "rightsStatus": "Linked original source; media is not redistributed."
+ },
+ "provenance": {
+ "via": "GeoGeorgeShadrach public geolocation map",
+ "viaUrl": "https://x.com/geogeorgeology/status/2093632283442053371",
+ "rights": "third-party link only; media not redistributed"
+ }
+ },
+ {
+ "id": "mailung-bazzar",
+ "sequence": 9,
+ "title": "Mailung Bazzar, Dandaguan",
+ "shortTitle": "Mailung",
+ "summary": "A wave races along the Mailung Bazzar road corridor.",
+ "phase": "downstream passage",
+ "lat": 28.0761666666667,
+ "lon": 85.2125277777778,
+ "fallbackElevationM": 800,
+ "imageryCoverage": "outside",
+ "trigger": {
+ "mode": "phase",
+ "storyProgress": 0.52
+ },
+ "timing": {
+ "capturedAt": null,
+ "note": "capture time unverified"
+ },
+ "confidence": {
+ "location": "geolocated",
+ "time": "unverified"
+ },
+ "media": {
+ "sourceUrl": "https://www.youtube.com/watch?v=TREmjWjppII",
+ "embedEndAtSec": 7,
+ "platform": "YouTube",
+ "publisher": "YouTube source",
+ "width": 240,
+ "height": 426,
+ "orientation": "portrait",
+ "fit": "contain",
+ "clipInSec": 0,
+ "clipOutSec": 10,
+ "rightsStatus": "Linked original source; media is not redistributed."
+ },
+ "provenance": {
+ "via": "GeoGeorgeShadrach public geolocation map",
+ "viaUrl": "https://x.com/geogeorgeology/status/2093632283442053371",
+ "rights": "third-party link only; media not redistributed"
+ }
+ },
+ {
+ "id": "dandagaun",
+ "sequence": 10,
+ "title": "Dandagaun, Rasuwa",
+ "shortTitle": "Dandagaun",
+ "summary": "A compact excerpt of the Dandagaun slope-collapse sequence.",
+ "phase": "downstream passage",
+ "lat": 28.0751944444444,
+ "lon": 85.2076944444444,
+ "fallbackElevationM": 780,
+ "imageryCoverage": "outside",
+ "trigger": {
+ "mode": "phase",
+ "storyProgress": 0.585
+ },
+ "timing": {
+ "capturedAt": null,
+ "note": "capture time unverified"
+ },
+ "confidence": {
+ "location": "geolocated",
+ "time": "unverified"
+ },
+ "media": {
+ "sourceUrl": "https://www.youtube.com/watch?v=neheV9j5SI0",
+ "platform": "YouTube",
+ "publisher": "YouTube source",
+ "width": 240,
+ "height": 426,
+ "orientation": "portrait",
+ "fit": "contain",
+ "clipInSec": 0,
+ "clipOutSec": 13.37,
+ "rightsStatus": "Linked original source; media is not redistributed."
+ },
+ "provenance": {
+ "via": "GeoGeorgeShadrach public geolocation map",
+ "viaUrl": "https://x.com/geogeorgeology/status/2093632283442053371",
+ "rights": "third-party link only; media not redistributed"
+ }
+ },
+ {
+ "id": "dandagaun-viewpoint",
+ "sequence": 11,
+ "title": "Dandagaun viewpoint restaurant",
+ "shortTitle": "Dandagaun viewpoint",
+ "summary": "A wide valley view of the flash-flood surge from a restaurant.",
+ "phase": "downstream passage",
+ "lat": 28.0354,
+ "lon": 85.1894361111111,
+ "fallbackElevationM": 700,
+ "imageryCoverage": "outside",
+ "trigger": {
+ "mode": "phase",
+ "storyProgress": 0.65
+ },
+ "timing": {
+ "capturedAt": null,
+ "note": "capture time unverified"
+ },
+ "confidence": {
+ "location": "geolocated",
+ "time": "unverified"
+ },
+ "media": {
+ "sourceUrl": "https://www.youtube.com/watch?v=9mE8Zfj_o2I",
+ "platform": "YouTube",
+ "publisher": "YouTube source",
+ "width": 240,
+ "height": 426,
+ "orientation": "portrait",
+ "fit": "contain",
+ "clipInSec": 0,
+ "clipOutSec": 12,
+ "rightsStatus": "Linked original source; media is not redistributed."
+ },
+ "provenance": {
+ "via": "GeoGeorgeShadrach public geolocation map",
+ "viaUrl": "https://x.com/geogeorgeology/status/2093632283442053371",
+ "rights": "third-party link only; media not redistributed"
+ }
+ },
+ {
+ "id": "betrawati-bazaar",
+ "sequence": 12,
+ "title": "Betrawati Bazaar",
+ "shortTitle": "Betrawati",
+ "summary": "A landscape view of the Betrawati flood surge.",
+ "phase": "downstream consequence",
+ "lat": 27.9758992572246,
+ "lon": 85.1835331689033,
+ "fallbackElevationM": 600,
+ "imageryCoverage": "outside",
+ "trigger": {
+ "mode": "phase",
+ "storyProgress": 0.715
+ },
+ "timing": {
+ "capturedAt": null,
+ "note": "capture time unverified"
+ },
+ "confidence": {
+ "location": "geolocated",
+ "time": "unverified"
+ },
+ "media": {
+ "sourceUrl": "https://www.youtube.com/watch?v=qjApXYPD2Ow",
+ "platform": "YouTube",
+ "publisher": "YouTube source",
+ "width": 426,
+ "height": 240,
+ "orientation": "landscape",
+ "fit": "cover",
+ "clipInSec": 0,
+ "clipOutSec": 10.44,
+ "rightsStatus": "Linked original source; media is not redistributed."
+ },
+ "provenance": {
+ "via": "GeoGeorgeShadrach public geolocation map",
+ "viaUrl": "https://x.com/geogeorgeology/status/2093632283442053371",
+ "rights": "third-party link only; media not redistributed"
+ }
+ },
+ {
+ "id": "bhainse",
+ "sequence": 13,
+ "title": "Bhainse",
+ "shortTitle": "Bhainse",
+ "summary": "A source-linked clip placed at Bhainse in the public geolocation map.",
+ "phase": "downstream consequence",
+ "lat": 27.9623111111111,
+ "lon": 85.1739777777778,
+ "fallbackElevationM": 500,
+ "imageryCoverage": "outside",
+ "trigger": {
+ "mode": "phase",
+ "storyProgress": 0.747
+ },
+ "timing": {
+ "capturedAt": null,
+ "note": "capture time unverified"
+ },
+ "confidence": {
+ "location": "geolocated source-map placement",
+ "time": "unverified"
+ },
+ "media": {
+ "sourceUrl": "https://www.youtube.com/watch?v=nBbDsvYiUg8",
+ "platform": "YouTube",
+ "publisher": "YouTube source",
+ "width": 240,
+ "height": 426,
+ "orientation": "portrait",
+ "fit": "contain",
+ "clipInSec": 0,
+ "embedStartAtSec": 14,
+ "clipOutSec": 11.11,
+ "rightsStatus": "Linked original source; media is not redistributed."
+ },
+ "provenance": {
+ "via": "GeoGeorgeShadrach public geolocation map, record 44 source 1",
+ "viaUrl": "https://x.com/geogeorgeology/status/2093632283442053371",
+ "rights": "third-party link only; media not redistributed"
+ }
+ },
+ {
+ "id": "bidur-trishuli-bridge",
+ "sequence": 14,
+ "title": "Bidur - Trishuli bridge",
+ "shortTitle": "Bidur bridge",
+ "summary": "Geolocated downstream consequence well beyond the image comparison.",
+ "phase": "consequence",
+ "lat": 27.920858,
+ "lon": 85.148029,
+ "fallbackElevationM": 510,
+ "imageryCoverage": "outside",
+ "trigger": {
+ "mode": "phase",
+ "storyProgress": 0.78
+ },
+ "timing": {
+ "capturedAt": null,
+ "note": "capture time unverified"
+ },
+ "confidence": {
+ "location": "geolocated",
+ "time": "unverified"
+ },
+ "media": {
+ "sourceUrl": "https://www.youtube.com/watch?v=FE5HRRFhDQg",
+ "posterUrl": "https://i.ytimg.com/vi/FE5HRRFhDQg/hqdefault.jpg",
+ "platform": "YouTube",
+ "publisher": "YouTube source",
+ "width": 480,
+ "height": 360,
+ "orientation": "landscape",
+ "fit": "cover",
+ "rightsStatus": "Linked original source; media is not redistributed."
+ },
+ "provenance": {
+ "via": "GeoGeorgeShadrach public geolocation map",
+ "viaUrl": "https://x.com/geogeorgeology/status/2093632283442053371",
+ "rights": "third-party link only; media not redistributed"
+ }
+ },
+ {
+ "id": "devighat-taadi-khola-bridge",
+ "sequence": 15,
+ "title": "Devighat / Taadi Khola Bridge",
+ "shortTitle": "Devighat bridge",
+ "summary": "A downstream river view at the Devighat bridge area.",
+ "phase": "downstream consequence",
+ "lat": 27.8585278,
+ "lon": 85.1090318,
+ "fallbackElevationM": 350,
+ "imageryCoverage": "outside",
+ "trigger": {
+ "mode": "phase",
+ "storyProgress": 0.845
+ },
+ "timing": {
+ "capturedAt": null,
+ "note": "capture time unverified"
+ },
+ "confidence": {
+ "location": "geolocated",
+ "time": "unverified"
+ },
+ "media": {
+ "sourceUrl": "https://www.youtube.com/watch?v=nG9um3ksbR4",
+ "platform": "YouTube",
+ "publisher": "YouTube source",
+ "width": 240,
+ "height": 426,
+ "orientation": "portrait",
+ "fit": "contain",
+ "clipInSec": 0,
+ "clipOutSec": 11.11,
+ "rightsStatus": "Linked original source; media is not redistributed."
+ },
+ "provenance": {
+ "via": "GeoGeorgeShadrach public geolocation map",
+ "viaUrl": "https://x.com/geogeorgeology/status/2093632283442053371",
+ "rights": "third-party link only; media not redistributed"
+ }
+ },
+ {
+ "id": "charaudi",
+ "sequence": 16,
+ "title": "Charaudi",
+ "shortTitle": "Charaudi",
+ "summary": "Floodwater surges through the Charaudi built area.",
+ "phase": "downstream consequence",
+ "lat": 27.8032944444444,
+ "lon": 84.749375,
+ "fallbackElevationM": 300,
+ "imageryCoverage": "outside",
+ "trigger": {
+ "mode": "phase",
+ "storyProgress": 0.91
+ },
+ "timing": {
+ "capturedAt": null,
+ "note": "capture time unverified"
+ },
+ "confidence": {
+ "location": "geolocated",
+ "time": "unverified"
+ },
+ "media": {
+ "sourceUrl": "https://www.youtube.com/watch?v=55SrkQilcO8",
+ "platform": "YouTube",
+ "publisher": "YouTube source",
+ "width": 240,
+ "height": 426,
+ "orientation": "portrait",
+ "fit": "contain",
+ "clipInSec": 0,
+ "clipOutSec": 10,
+ "rightsStatus": "Linked original source; media is not redistributed."
+ },
+ "provenance": {
+ "via": "GeoGeorgeShadrach public geolocation map",
+ "viaUrl": "https://x.com/geogeorgeology/status/2093632283442053371",
+ "rights": "third-party link only; media not redistributed"
+ }
+ }
+ ]
+}
diff --git a/public/events/bhote-koshi-2026/post.webp b/public/events/bhote-koshi-2026/post.webp
new file mode 100644
index 000000000..b6df1cdf0
Binary files /dev/null and b/public/events/bhote-koshi-2026/post.webp differ
diff --git a/public/events/bhote-koshi-2026/pre.webp b/public/events/bhote-koshi-2026/pre.webp
new file mode 100644
index 000000000..3bca10941
Binary files /dev/null and b/public/events/bhote-koshi-2026/pre.webp differ
diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json
index 87e7a8c41..643edce10 100644
--- a/scripts/package-boundaries.json
+++ b/scripts/package-boundaries.json
@@ -593,6 +593,7 @@
"src/ui/styles/radio.css",
"src/ui/styles/cctv.css",
"src/ui/styles/scenes.css",
+ "src/ui/styles/bhote-koshi.css",
"src/ui/styles/recording.css",
"src/ui/styles/responsive.css",
"src/ui/styles/command-dock.css",
@@ -608,6 +609,11 @@
"scenes": {
"exports": ["./scenes"],
"modules": [
+ "src/scenes/nepalEvidencePack.js",
+ "src/data/bhoteKoshiIncidentPlaces.js",
+ "src/data/bhoteKoshiShotPaths.js",
+ "src/data/bhoteKoshiFloodPath.js",
+ "src/maps/catalog.js",
"src/scenes/director.js",
"src/scenes/recipes.js",
"src/scenes/scenePolicy.js",
@@ -1123,6 +1129,13 @@
"./standalone/catalog"
],
"modules": [
+ "src/scenes/nepalEvidencePack.js",
+ "src/data/bhoteKoshiEvent.js",
+ "src/data/bhoteKoshiEmbeddedMedia.js",
+ "src/data/bhoteKoshiLocator.js",
+ "src/data/bhoteKoshiIncidentPlaces.js",
+ "src/data/bhoteKoshiFloodPath.js",
+ "src/data/bhoteKoshiShotPaths.js",
"src/annotations/annotationEngine.js",
"src/annotations/annotationResolver.js",
"src/annotations/hybridAnnotationRenderer.js",
@@ -1697,6 +1710,12 @@
"application-layer-construction": {
"exports": ["./application/layers"],
"modules": [
+ "src/data/bhoteKoshiEvent.js",
+ "src/data/bhoteKoshiEmbeddedMedia.js",
+ "src/data/bhoteKoshiLocator.js",
+ "src/data/bhoteKoshiIncidentPlaces.js",
+ "src/data/bhoteKoshiFloodPath.js",
+ "src/data/bhoteKoshiShotPaths.js",
"src/annotations/annotationResolver.js",
"src/annotations/resolver.js",
"src/app/catalog.js",
diff --git a/scripts/track-regression.mjs b/scripts/track-regression.mjs
index f6e6d1121..b5f6286e5 100644
--- a/scripts/track-regression.mjs
+++ b/scripts/track-regression.mjs
@@ -2860,6 +2860,55 @@ async function main() {
tick();
setTimeout(() => { done = true; res(); }, ms);
});
+ // A timer can expire while the camera is new but the model/cache still
+ // belongs to the old frame. Observe the first completed exit frame,
+ // including a wrong result; never wait for visibility or height to pass.
+ window.__dfObserveTrackedExit = async ({ scene, trackedEntity, trackedModel,
+ getTrackedModel, getTrackedEntity, getTrackedId, getCameraHeight,
+ exitHeight, read, now = Date.now, setTimer = setTimeout, clearTimer = clearTimeout }) => {
+ const deadline = now() + 5000;
+ let removePre, removePost, removeError, timer;
+ let preFrame = null;
+ try {
+ return await new Promise((resolve) => {
+ let finished = false;
+ const finish = (result) => { if (!finished) { finished = true; resolve(result); } };
+ const valid = () => {
+ try {
+ if (now() > deadline) return 'tracked exit observation exceeded its 5000 ms deadline';
+ if (getTrackedEntity() !== trackedEntity || getTrackedId() !== 'aaa097') return 'tracked exit fixture identity changed';
+ if (!trackedModel || getTrackedModel() !== trackedModel) return 'tracked exit model identity changed';
+ if (!(getCameraHeight() > exitHeight)) return 'tracked exit camera left the exit regime';
+ return null;
+ } catch { return 'tracked exit fixture validation failed'; }
+ };
+ removePre = scene.preUpdate.addEventListener(() => {
+ if (finished) return;
+ const error = valid();
+ if (error) { finish({ error }); return; }
+ if (preFrame == null) preFrame = scene.frameState.frameNumber;
+ });
+ removePost = scene.postRender.addEventListener(() => {
+ if (finished || preFrame == null) return;
+ const error = valid();
+ if (error) { finish({ error }); return; }
+ if (scene.frameState.frameNumber === preFrame) {
+ finish({ error: 'tracked exit observation has no new rendered frame' }); return;
+ }
+ try {
+ const snapshot = read();
+ finish(snapshot ? { ...snapshot, observationFrame: scene.frameState.frameNumber }
+ : { error: 'tracked exit snapshot missing' });
+ } catch { finish({ error: 'tracked exit snapshot failed' }); }
+ });
+ removeError = scene.renderError.addEventListener(() => finish({ error: 'tracked exit render failed' }));
+ timer = setTimer(() => finish({ error: 'tracked exit update did not complete within 5000 ms' }), 5000);
+ scene.requestRender();
+ });
+ } finally {
+ clearTimer(timer); removePre?.(); removePost?.(); removeError?.();
+ }
+ };
// Cesium Models in the scene, split by visibility (duck-typed the same
// way the harness excludes them from height probes).
//
@@ -3378,10 +3427,30 @@ async function main() {
// the billboard — which MUST be floored, or a tracked grounded contact
// renders buried at any distance the operator pulls out to. This is the
// coverage the toggle used to reach; the zoom regime reaches it now.
+ const findOwnModel = () => {
+ let found = null;
+ const walk = (collection) => {
+ for (let i = 0; i < collection.length; i++) {
+ const item = collection.get(i);
+ if (typeof item?.get === 'function' && typeof item.length === 'number') walk(item);
+ else if (item?.id === 'aaa097' && item.activeAnimations !== undefined
+ && item.minimumPixelSize !== undefined) found = item;
+ }
+ };
+ walk(v.scene.primitives);
+ return found;
+ };
+ const trackedEntity = v.trackedEntity;
+ const trackedModel = findOwnModel();
const achievedOut = await window.__dfZoomAbove(window.__dfRegime.exit + 5000);
- await window.__dfSettle(1500);
- const zoomedOut = readTracked();
- if (!zoomedOut) return { error: 'tracked entity has no position (zoomed out)' };
+ const zoomedOut = await window.__dfObserveTrackedExit({
+ scene: v.scene, trackedEntity, trackedModel, getTrackedModel: findOwnModel,
+ getTrackedEntity: () => v.trackedEntity,
+ getTrackedId: () => fl.getTrackedInfo()?.icao24,
+ getCameraHeight: window.__dfCamH, exitHeight: window.__dfRegime.exit,
+ read: readTracked,
+ });
+ if (zoomedOut.error) return { error: zoomedOut.error };
const out = {
rawH: d0.h,
@@ -3643,11 +3712,10 @@ async function main() {
}
// ---- F3: the LOADING window is billboard-owned, not model-owned ------
- // A fleet model is registered with Cesium's default show=true the instant
- // its glTF resolves, but the handoff waits for `ready`; and a tracked
- // model is null for the whole load even though the regime is already
- // active. Both windows leave the BILLBOARD as the visual, so both must
- // stay floored — ownership means actually rendering.
+ // Stress the fleet ownership predicate with synthetic show=true/ready=false
+ // flags. Production admission keeps the model hidden; these shadows do
+ // not simulate Cesium's internal loading/draw state. The tracked check
+ // below separately samples the no-rendering-model window.
const dfLoading = await evalPage(async () => {
const v = window.__godsEyeView.viewer;
const Cesium = await import('/node_modules/cesium/Build/Cesium/index.js');
@@ -3663,13 +3731,8 @@ async function main() {
};
const out = {};
- // (a) FLEET: reach the real admitted-but-not-ready window. A fleet model
- // is registered in `_models` with Cesium's default show=true the moment
- // its glTF resolves, while the billboard handoff waits for `ready` — so
- // for a tick the model claims the visual it is not drawing. That window
- // is ~one fleet tick wide, so hold the state open by shadowing the two
- // flags on the instance (the same defineProperty trick the ground-3d
- // group uses on `tilesLoaded`).
+ // (a) FLEET: retain the adversarial flags until this contact has passed
+ // through its installed fleet update and that frame has completed.
const findFleetModel = (id) => {
let found = null;
const walk = (coll) => {
@@ -3698,8 +3761,26 @@ async function main() {
// assigns `model.show = false` for a not-ready model, and assigning to a
// non-writable own property throws in strict mode — which lands inside
// Cesium's render loop and stops rendering for the rest of the run.
+ const priorReady = Object.getOwnPropertyDescriptor(fleetModel, 'ready');
+ const priorShow = Object.getOwnPropertyDescriptor(fleetModel, 'show');
+ let armed = false;
+ let processedFrame = null;
+ let sample = null;
+ let removePostRender = null;
+ try {
Object.defineProperty(fleetModel, 'ready', { get: () => false, set: () => {}, configurable: true });
- Object.defineProperty(fleetModel, 'show', { get: () => true, set: () => {}, configurable: true });
+ Object.defineProperty(fleetModel, 'show', {
+ get: () => true,
+ set: (value) => {
+ // Both the not-ready handoff and horizon cull clear show AFTER
+ // computing this contact's display floor. Neither a timer nor an
+ // unrelated rendered frame proves that this aircraft was processed.
+ if (armed && value === false && processedFrame == null) {
+ processedFrame = v.scene.frameState.frameNumber;
+ }
+ },
+ configurable: true,
+ });
const bb = window.__dfFindBB('aaa097');
if (!bb) return { error: 'aaa097 billboard missing' };
bb.show = true; // the handoff would not have hidden it: the model is not ready
@@ -3713,21 +3794,45 @@ async function main() {
fl._clearDisplayFloorStateForTest();
const seededFleet = d0.h + 45;
floorAround(d0, seededFleet);
- // reportMeshFloorCell is a direct test seam and does not schedule a
- // Cesium frame. Force the CallbackProperty to re-evaluate before the
- // assertion, matching the request-render fix in the retained-model
- // scenario above.
- v.scene.requestRender();
- await window.__dfSettle(900);
- const bbAfter = window.__dfFindBB('aaa097');
- out.fleetModelReady = fleetModel.ready;
- out.fleetModelShow = fleetModel.show;
- out.fleetBillboardVisible = !!bbAfter?.show;
- out.fleetSeeded = seededFleet;
- out.fleetH = bbAfter ? window.__dfCarto(bbAfter.position).h : null;
- delete fleetModel.ready;
- delete fleetModel.show;
- fl.setParams({ models3d: false });
+ const tickDeadline = Date.now() + 5000;
+ removePostRender = v.scene.postRender.addEventListener(() => {
+ if (sample || processedFrame == null || v.scene.frameState.frameNumber < processedFrame) return;
+ if (Date.now() > tickDeadline) {
+ sample = { error: 'fleet loading fixture completed after its 5000 ms deadline' };
+ return;
+ }
+ const currentBb = window.__dfFindBB('aaa097');
+ const currentModel = findFleetModel('aaa097');
+ sample = {
+ fleetTickCompleted: true,
+ fleetFrame: v.scene.frameState.frameNumber,
+ fleetModelReady: fleetModel.ready,
+ fleetModelShow: fleetModel.show,
+ fleetBillboardVisible: !!currentBb?.show,
+ fleetSeeded: seededFleet,
+ fleetH: currentBb ? window.__dfCarto(currentBb.position).h : null,
+ };
+ if (currentBb !== bb || currentModel !== fleetModel || fleetModel.isDestroyed()
+ || fl.getTrackedInfo()) sample.error = 'fleet loading fixture changed before its completed frame';
+ });
+ armed = true;
+ // This is a setup-completion deadline, not a visual latency budget.
+ // Take the FIRST completed fleet result, even when its height is wrong;
+ // never poll the height until the assertion happens to pass.
+ do {
+ await window.__dfSettle(50);
+ } while (!sample && Date.now() < tickDeadline);
+ if (!sample) return { error: 'fleet loading fixture did not complete an aircraft update within 5000 ms' };
+ Object.assign(out, sample);
+ } finally {
+ armed = false;
+ removePostRender?.();
+ if (priorReady) Object.defineProperty(fleetModel, 'ready', priorReady);
+ else delete fleetModel.ready;
+ if (priorShow) Object.defineProperty(fleetModel, 'show', priorShow);
+ else delete fleetModel.show;
+ fl.setParams({ models3d: false });
+ }
await window.__dfSettle(400);
// (b) TRACKED, regime ACTIVE but no model yet. Turning 3D on while
@@ -3771,7 +3876,8 @@ async function main() {
skip('display-floor/loading: fleet model loading window', dfLoading.skipped);
} else {
record('display-floor/loading: a fleet model that is shown-but-not-ready does not own the visual',
- !dfLoading.error && dfLoading.fleetModelShow === true && dfLoading.fleetModelReady === false
+ !dfLoading.error && dfLoading.fleetTickCompleted === true
+ && dfLoading.fleetModelShow === true && dfLoading.fleetModelReady === false
&& dfLoading.fleetBillboardVisible === true
&& Number.isFinite(dfLoading.fleetH)
&& dfLoading.fleetH >= dfLoading.fleetSeeded + DISPLAY_FLOOR_LIFT_M - 0.5,
@@ -3955,6 +4061,7 @@ async function main() {
delete window.__dfPriorTilesLoadedDescriptor;
delete window.__dfSkinSamples;
delete window.__dfSkinM;
+ delete window.__dfObserveTrackedExit;
await fl.update(v);
});
diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js
index cf4f095a0..47ffe5658 100644
--- a/src/app/constructCatalog.js
+++ b/src/app/constructCatalog.js
@@ -20,6 +20,8 @@ import { createApplicationEarthquakes } from './layers/earthquakes.js';
import { createApplicationCables } from './layers/submarineCables.js';
import { createInfrastructureLayers } from '../data/infrastructure.js';
import { localGeoJsonServices } from './localGeojsonServices.js';
+import { createBhoteKoshiEventLayer } from '../data/bhoteKoshiEvent.js';
+import { createBhoteKoshiLocatorLayer } from '../data/bhoteKoshiLocator.js';
const SOURCE_METHODS = Object.freeze({
flights: ['getSnapshot'],
@@ -55,6 +57,7 @@ export function createApplicationCatalog({
metadata = LAYER_STATE_REGISTRY,
vesselOptions,
resolveAsset,
+ nepalBoundaryResolver,
}) {
if (!signal?.addEventListener)
throw new TypeError('An application lifetime signal is required');
@@ -101,6 +104,10 @@ export function createApplicationCatalog({
});
const catalog = createLayerCatalog(
[
+ createBhoteKoshiEventLayer(),
+ createBhoteKoshiLocatorLayer({
+ boundaryResolver: nepalBoundaryResolver,
+ }),
flights,
military,
createApplicationEarthquakes({ source: sources.earthquakes }),
diff --git a/src/app/constructCatalog.test.mjs b/src/app/constructCatalog.test.mjs
index ba434ef61..9de176605 100644
--- a/src/app/constructCatalog.test.mjs
+++ b/src/app/constructCatalog.test.mjs
@@ -4,6 +4,7 @@ import assert from 'node:assert/strict';
import { createApplicationCatalog } from './constructCatalog.js';
import { createStandaloneLayerSources } from '../standalone/layerSources.js';
import { catalogControlServices } from './catalog.js';
+import { LayerLifecycle } from '../data/lifecycle.js';
function fixtureSources(ids, calls) {
const sources = createStandaloneLayerSources();
@@ -38,7 +39,27 @@ test('catalogs construct distinct layers and classification from their supplied
signal: b.signal,
surface: fixtureSurface(b.signal),
});
- assert.equal(first.layers.length, 19);
+ assert.equal(first.layers.length, 21);
+ assert.ok(first.get('transit'));
+ assert.ok(first.get('bhote-koshi-2026'));
+ assert.ok(first.get('bhote-koshi-locator'));
+ const lifecycle = new LayerLifecycle({});
+ for (const layer of first.layers) lifecycle.register(layer);
+ const rows = lifecycle.getAll();
+ for (const id of ['bhote-koshi-2026', 'bhote-koshi-locator']) {
+ assert.equal(
+ rows.find((row) => row.id === id)?.showInTogglePanel,
+ false,
+ `${id} remains registered for Scenes but is absent from Data Layers`,
+ );
+ assert.equal(typeof first.get(id).enable, 'function');
+ assert.equal(typeof first.get(id).setParams, 'function');
+ }
+ assert.equal(
+ rows.find((row) => row.id === 'flights')?.showInTogglePanel,
+ true,
+ 'ordinary data layer entries remain visible',
+ );
assert.deepEqual(
first.layers.map(({ id }) => id),
second.layers.map(({ id }) => id),
diff --git a/src/app/data.js b/src/app/data.js
index 257b14583..77975b84f 100644
--- a/src/app/data.js
+++ b/src/app/data.js
@@ -2,7 +2,7 @@ import { LayerLifecycle } from '../data/lifecycle.js';
import { LayerPresentation } from './layerPresentation.js';
/** Register the application layer catalog before allowing state restoration. */
export function createApplicationData({
- scene: { viewer },
+ scene: { viewer, mapStackController },
controls: { styleManager },
catalog,
allowQaRegistration,
@@ -27,6 +27,8 @@ export function createApplicationData({
throw new TypeError('An application layer catalog is required');
for (const layer of catalog.layers) dataManager.register(layer);
for (const layer of catalog.layers) layer.attachDataManager?.(dataManager);
+ for (const layer of catalog.layers)
+ layer.attachMapStackController?.(mapStackController);
// Restoration starts only after the caller's complete registry is sealed.
dataManager.finalizeRegistrations(catalog.metadata);
if (allowQaRegistration) {
diff --git a/src/app/tools.js b/src/app/tools.js
index 956a26bb6..87b51a5f3 100644
--- a/src/app/tools.js
+++ b/src/app/tools.js
@@ -27,7 +27,13 @@ export function createApplicationTools({
const { viewer, tileset, mapStackController, operations } = scene;
const { styleManager, weatherEffects, cockpitCloudEffects } = controls;
const { dataManager } = data;
- const sceneDirector = new SceneDirector(viewer, styleManager, dataManager);
+ const sceneDirector = new SceneDirector(viewer, styleManager, dataManager, {
+ isMapStackAvailable: (id) =>
+ mapStackController?.isStackAvailable(id) === true,
+ });
+ dataManager.layers
+ .get('bhote-koshi-2026')
+ ?.module.attachSceneController(sceneDirector);
defer(() => sceneDirector.destroy());
onSceneDirector?.(sceneDirector);
const annotations = initAnnotations({
diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs
index 371c0bc2c..20cb1ecf5 100644
--- a/src/cockpitMarkup.test.mjs
+++ b/src/cockpitMarkup.test.mjs
@@ -255,7 +255,7 @@ test('Cockpit owns a focused shared Display portal and compact Radio controls',
css,
/body\.scene-playback-mode\s*:is\([\s\S]*?#clear-selected-layers,[\s\S]*?#tilt-map-view,[\s\S]*?#north-up-view,[\s\S]*?#reset-globe-view[\s\S]*?\)\s*\{\s*display:\s*none !important;/,
);
- assert.match(sceneDirector, /this\._running = true;\s*this\._setPlaybackActive\(true\);/);
+ assert.match(sceneDirector, /this\._running = true;\s*this\._previewRun = preview;\s*if \(preview\) this\._setPlaybackActive\(true\);/);
assert.match(sceneDirector, /styleManager\.setRecordingMode\(false\);\s*this\._setPlaybackActive\(false\);/);
assert.match(SceneControls.prototype.setPlaybackActive.toString(), /document\.body\.classList\.toggle\('scene-playback-mode', active\)/);
assert.equal((html.match(/id="hud-toggle"/g) || []).length, 1, 'HUD control must have one stateful DOM owner');
diff --git a/src/data/bhoteKoshiEmbeddedMedia.js b/src/data/bhoteKoshiEmbeddedMedia.js
new file mode 100644
index 000000000..b00f265a8
--- /dev/null
+++ b/src/data/bhoteKoshiEmbeddedMedia.js
@@ -0,0 +1,1089 @@
+import * as Cesium from 'cesium';
+
+const YOUTUBE_HOSTS = new Set([
+ 'youtube.com',
+ 'www.youtube.com',
+ 'm.youtube.com',
+ 'youtube-nocookie.com',
+ 'www.youtube-nocookie.com',
+ 'youtu.be',
+]);
+const FACEBOOK_HOSTS = new Set([
+ 'facebook.com',
+ 'www.facebook.com',
+ 'm.facebook.com',
+ 'web.facebook.com',
+ 'fb.watch',
+ 'www.fb.watch',
+]);
+const X_HOSTS = new Set([
+ 'x.com',
+ 'www.x.com',
+ 'mobile.x.com',
+ 'twitter.com',
+ 'www.twitter.com',
+ 'mobile.twitter.com',
+]);
+const X_WIDGET_URL = 'https://platform.twitter.com/widgets.js';
+const FACEBOOK_SDK_ID = 'facebook-jssdk';
+const FACEBOOK_SDK_URL =
+ 'https://connect.facebook.net/en_US/sdk.js#xfbml=0&version=v24.0';
+const EMBED_TIMEOUT_MS = 15000;
+const CARD_EXIT_DURATION_MS = 620;
+const MEDIA_PRECONNECT_ORIGINS = Object.freeze({
+ youtube: Object.freeze([
+ 'https://www.youtube-nocookie.com',
+ 'https://www.youtube.com',
+ 'https://i.ytimg.com',
+ ]),
+ facebook: Object.freeze(['https://www.facebook.com']),
+});
+
+let _xWidgetsPromise = null;
+let _facebookSdkPromise = null;
+let _facebookEmbedSequence = 0;
+const youtubeApis = new WeakMap();
+
+function loadYouTubeApi(documentRef, globalRef) {
+ if (typeof globalRef.YT?.Player === 'function')
+ return Promise.resolve(globalRef.YT);
+ if (youtubeApis.has(documentRef)) return youtubeApis.get(documentRef);
+ const pending = new Promise((resolve, reject) => {
+ const previous = globalRef.onYouTubeIframeAPIReady;
+ let timer;
+ let settled = false;
+ const script = documentRef.createElement('script');
+ const finish = (error) => {
+ if (settled) return;
+ settled = true;
+ globalRef.clearTimeout(timer);
+ if (globalRef.onYouTubeIframeAPIReady === ready)
+ globalRef.onYouTubeIframeAPIReady = previous;
+ if (error) {
+ script.remove();
+ reject(error);
+ } else resolve(globalRef.YT);
+ };
+ const ready = () => {
+ try {
+ previous?.();
+ } finally {
+ finish(
+ typeof globalRef.YT?.Player === 'function'
+ ? null
+ : new Error('YouTube API unavailable'),
+ );
+ }
+ };
+ globalRef.onYouTubeIframeAPIReady = ready;
+ script.src = 'https://www.youtube.com/iframe_api';
+ script.async = true;
+ script.addEventListener(
+ 'error',
+ () => finish(new Error('YouTube API unavailable')),
+ { once: true },
+ );
+ timer = globalRef.setTimeout(
+ () => finish(new Error('YouTube API timed out')),
+ 5000,
+ );
+ documentRef.head.append(script);
+ }).catch((error) => {
+ youtubeApis.delete(documentRef);
+ throw error;
+ });
+ youtubeApis.set(documentRef, pending);
+ return pending;
+}
+
+/** A trimmed clip follows provider time, never time spent loading its iframe. */
+export function createTrimmedYouTubePlayback({
+ iframe,
+ endAtSec,
+ startAtSec = 0,
+ loadApi,
+ globalRef = globalThis,
+ isCurrent = () => true,
+ onStatus = () => {},
+}) {
+ let player = null;
+ let phase = 'starting';
+ let active = true;
+ let started = false;
+ let currentTime = startAtSec;
+ let poll = null;
+ let deadline = null;
+ const live = () =>
+ active && isCurrent() && ['starting', 'playing'].includes(phase);
+ const clear = () => {
+ globalRef.clearTimeout(deadline);
+ globalRef.clearTimeout(poll);
+ deadline = poll = null;
+ };
+ const finish = (outcome) => {
+ if (!live()) return;
+ phase = outcome;
+ clear();
+ try {
+ player?.pauseVideo?.();
+ } catch {
+ /* Removed provider frame. */
+ }
+ onStatus(outcome);
+ };
+ const sample = (stateHint) => {
+ if (!live()) return;
+ globalRef.clearTimeout(poll);
+ poll = null;
+ try {
+ const previousTime = currentTime;
+ const time = player?.getCurrentTime?.();
+ if (Number.isFinite(time)) currentTime = time;
+ const state = stateHint ?? player?.getPlayerState?.();
+ if (
+ currentTime >= endAtSec ||
+ (state === 0 && currentTime >= endAtSec - 0.1)
+ ) {
+ finish('completed');
+ return;
+ }
+ if (state === 0) {
+ finish('unavailable');
+ return;
+ }
+ // Autoplay can begin before the API attaches, without a new PLAYING edge.
+ if (!started && (state === 1 || currentTime > previousTime)) {
+ started = true;
+ phase = 'playing';
+ globalRef.clearTimeout(deadline);
+ deadline = globalRef.setTimeout(
+ () => finish('timeout'),
+ (endAtSec - startAtSec) * 1000 + 5000,
+ );
+ }
+ } catch {
+ finish('unavailable');
+ return;
+ }
+ poll = globalRef.setTimeout(() => sample(), 100);
+ };
+ deadline = globalRef.setTimeout(() => finish('timeout'), 5000);
+ Promise.resolve()
+ .then(loadApi)
+ .then((api) => {
+ if (!live()) return;
+ player = new api.Player(iframe, {
+ events: {
+ onReady(event) {
+ if (!live()) return;
+ player = event.target;
+ sample();
+ if (!live()) return;
+ try {
+ event.target.mute();
+ event.target.playVideo();
+ } catch {
+ finish('unavailable');
+ }
+ },
+ onStateChange(event) {
+ if (!live()) return;
+ if (event.target) player = event.target;
+ sample(event.data);
+ },
+ onError: () => finish('unavailable'),
+ onAutoplayBlocked: () => finish('blocked'),
+ },
+ });
+ })
+ .catch(() => finish('unavailable'));
+ return {
+ getState: () => ({ phase, currentTime }),
+ destroy() {
+ active = false;
+ phase = 'cancelled';
+ clear();
+ try {
+ player?.destroy?.();
+ } catch {
+ /* Detached frame already released. */
+ }
+ player = null;
+ },
+ };
+}
+
+function facebookSdkReady(api) {
+ return (
+ typeof api?.XFBML?.parse === 'function' &&
+ typeof api?.Event?.subscribe === 'function'
+ );
+}
+
+function loadFacebookSdk(documentRef, globalRef = globalThis) {
+ if (facebookSdkReady(globalRef.FB)) return Promise.resolve(globalRef.FB);
+ if (_facebookSdkPromise) return _facebookSdkPromise;
+ _facebookSdkPromise = new Promise((resolve, reject) => {
+ let settled = false;
+ let timer = null;
+ const prior = globalRef.fbAsyncInit;
+ const finish = (error) => {
+ if (settled) return;
+ settled = true;
+ globalRef.clearTimeout?.(timer);
+ if (globalRef.fbAsyncInit === ready) globalRef.fbAsyncInit = prior;
+ if (error) reject(error);
+ else resolve(globalRef.FB);
+ };
+ const ready = () => {
+ try {
+ prior?.();
+ } finally {
+ if (facebookSdkReady(globalRef.FB)) finish();
+ else finish(new Error('Facebook player API did not initialize.'));
+ }
+ };
+ globalRef.fbAsyncInit = ready;
+ let script = documentRef.getElementById?.(FACEBOOK_SDK_ID);
+ if (!script) {
+ script = documentRef.createElement('script');
+ script.id = FACEBOOK_SDK_ID;
+ script.async = true;
+ script.defer = true;
+ script.crossOrigin = 'anonymous';
+ script.src = FACEBOOK_SDK_URL;
+ documentRef.head?.append(script);
+ }
+ script.addEventListener?.(
+ 'error',
+ () => finish(new Error('Facebook player API could not load.')),
+ { once: true },
+ );
+ timer =
+ globalRef.setTimeout?.(
+ () => finish(new Error('Facebook player API timed out.')),
+ EMBED_TIMEOUT_MS,
+ ) ?? null;
+ }).catch((error) => {
+ _facebookSdkPromise = null;
+ throw error;
+ });
+ return _facebookSdkPromise;
+}
+
+/** Accept a public HTTPS media URL without allowing iframe markup or credentials. */
+export function publicEmbeddedMediaUrl(value) {
+ if (typeof value !== 'string' || value.length > 4096) return null;
+ let url;
+ try {
+ url = new URL(value.trim());
+ } catch {
+ return null;
+ }
+ const host = url.hostname.toLowerCase();
+ if (
+ url.protocol !== 'https:' ||
+ url.username ||
+ url.password ||
+ (url.port && url.port !== '443') ||
+ !host.includes('.') ||
+ host.includes(':') ||
+ /^\d+(\.\d+){3}$/.test(host) ||
+ /(^|\.)(localhost|local|internal|test|invalid)$/.test(host)
+ )
+ return null;
+ return url;
+}
+
+function youtubeVideoId(url) {
+ const host = url.hostname.toLowerCase();
+ if (!YOUTUBE_HOSTS.has(host)) return null;
+ if (host === 'youtu.be')
+ return /^\/([\w-]{11})\/?$/.exec(url.pathname)?.[1] || null;
+ if (url.pathname === '/watch') return url.searchParams.get('v');
+ return /^\/(?:embed|shorts)\/([\w-]{11})\/?$/.exec(url.pathname)?.[1] || null;
+}
+
+function isFacebookVideoUrl(url) {
+ const host = url.hostname.toLowerCase();
+ if (!FACEBOOK_HOSTS.has(host)) return false;
+ if (host === 'fb.watch' || host === 'www.fb.watch')
+ return /^\/[\w.-]+\/?$/.test(url.pathname);
+ return (
+ /^\/reel\/[\w.-]+\/?$/.test(url.pathname) ||
+ (url.pathname === '/watch/' && Boolean(url.searchParams.get('v'))) ||
+ /\/videos\/[\w.-]+\/?$/.test(url.pathname) ||
+ /^\/share\/(?:v|r)\/[\w.-]+\/?$/.test(url.pathname)
+ );
+}
+
+function xPostId(url) {
+ if (!X_HOSTS.has(url.hostname.toLowerCase())) return null;
+ return (
+ /^\/[A-Za-z0-9_]{1,15}\/status\/(\d+)\/?$/.exec(url.pathname)?.[1] || null
+ );
+}
+
+/** Resolve only providers that can render inside a Bhote Koshi callout. */
+export function resolveEmbeddedMediaSource(value) {
+ const url = publicEmbeddedMediaUrl(value);
+ if (!url) return null;
+ const youtubeId = youtubeVideoId(url);
+ if (youtubeId && /^[\w-]{11}$/.test(youtubeId)) {
+ return {
+ provider: 'youtube',
+ id: youtubeId,
+ url: `https://www.youtube.com/watch?v=${youtubeId}`,
+ };
+ }
+ if (isFacebookVideoUrl(url)) {
+ return { provider: 'facebook', url: url.href };
+ }
+ const postId = xPostId(url);
+ if (postId) {
+ return { provider: 'x', id: postId, url: url.href };
+ }
+ return null;
+}
+
+/** Provider-owned iframe URL for YouTube or Facebook callouts. */
+export function embeddedMediaFrameUrl(
+ source,
+ { autoplay = false, startAtSec = 0, endAtSec = null } = {},
+) {
+ if (source?.provider === 'youtube') {
+ const url = new URL(`https://www.youtube-nocookie.com/embed/${source.id}`);
+ url.searchParams.set('playsinline', '1');
+ url.searchParams.set('rel', '0');
+ const startSeconds = Math.max(0, Math.floor(Number(startAtSec) || 0));
+ if (startSeconds > 0) url.searchParams.set('start', String(startSeconds));
+ const endSeconds = Math.floor(Number(endAtSec));
+ if (Number.isFinite(endSeconds) && endSeconds > startSeconds) {
+ url.searchParams.set('end', String(endSeconds));
+ }
+ if (autoplay) {
+ url.searchParams.set('autoplay', '1');
+ url.searchParams.set('mute', '1');
+ }
+ return url.href;
+ }
+ if (source?.provider === 'facebook') {
+ const url = new URL('https://www.facebook.com/plugins/video.php');
+ url.searchParams.set('href', source.url);
+ url.searchParams.set('show_text', 'false');
+ url.searchParams.set('width', '500');
+ url.searchParams.set('autoplay', autoplay ? 'true' : 'false');
+ url.searchParams.set('mute', autoplay ? 'true' : 'false');
+ return url.href;
+ }
+ return null;
+}
+
+function xWidgetsReady(api) {
+ return typeof api?.widgets?.createTweet === 'function';
+}
+
+function loadXWidgets(documentRef, globalRef = globalThis) {
+ if (xWidgetsReady(globalRef.twttr)) return Promise.resolve(globalRef.twttr);
+ if (_xWidgetsPromise) return _xWidgetsPromise;
+ _xWidgetsPromise = new Promise((resolve, reject) => {
+ let settled = false;
+ let timer = null;
+ const finish = (error) => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timer);
+ if (error) reject(error);
+ else resolve(globalRef.twttr);
+ };
+ const ready = () => {
+ if (xWidgetsReady(globalRef.twttr)) finish();
+ else finish(new Error('X embed API did not initialize.'));
+ };
+ let script = documentRef.querySelector?.(`script[src="${X_WIDGET_URL}"]`);
+ if (!script) {
+ script = documentRef.createElement('script');
+ script.src = X_WIDGET_URL;
+ script.async = true;
+ script.charset = 'utf-8';
+ script.dataset.gevXWidgets = 'true';
+ documentRef.head?.append(script);
+ }
+ script.addEventListener?.(
+ 'error',
+ () => finish(new Error('X embed could not load.')),
+ {
+ once: true,
+ },
+ );
+ const waitForApi = () => {
+ if (xWidgetsReady(globalRef.twttr)) {
+ finish();
+ return;
+ }
+ if (typeof globalRef.twttr?.ready === 'function')
+ globalRef.twttr.ready(ready);
+ else ready();
+ };
+ script.addEventListener?.('load', waitForApi, { once: true });
+ if (typeof globalRef.twttr?.ready === 'function')
+ globalRef.twttr.ready(ready);
+ if (script.dataset?.gevXLoaded === 'true') waitForApi();
+ else
+ script.addEventListener?.(
+ 'load',
+ () => {
+ script.dataset.gevXLoaded = 'true';
+ },
+ {
+ once: true,
+ },
+ );
+ timer = setTimeout(
+ () => finish(new Error('X embed timed out.')),
+ EMBED_TIMEOUT_MS,
+ );
+ }).catch((error) => {
+ _xWidgetsPromise = null;
+ throw error;
+ });
+ return _xWidgetsPromise;
+}
+
+function providerLabel(provider) {
+ return (
+ { youtube: 'YOUTUBE', facebook: 'FACEBOOK', x: 'X' }[provider] || 'SOURCE'
+ );
+}
+
+function isPortraitMedia(media = {}) {
+ const width = Number(media.width);
+ const height = Number(media.height);
+ return (
+ String(media.orientation || '').toLowerCase() === 'portrait' ||
+ (Number.isFinite(width) && Number.isFinite(height) && height > width)
+ );
+}
+
+function ensureProviderConnections(documentRef, provider) {
+ for (const origin of MEDIA_PRECONNECT_ORIGINS[provider] || []) {
+ if (
+ documentRef.querySelector?.(`link[data-gev-media-preconnect="${origin}"]`)
+ )
+ continue;
+ const link = documentRef.createElement('link');
+ link.rel = 'preconnect';
+ link.href = origin;
+ link.crossOrigin = 'anonymous';
+ link.dataset.gevMediaPreconnect = origin;
+ documentRef.head?.append(link);
+ }
+}
+
+function anchorOnScreen(viewer, anchor, root) {
+ if (!viewer?.scene || !anchor || !root) return null;
+ const camera = viewer.camera;
+ if (camera?.positionWC && camera?.directionWC) {
+ const delta = Cesium.Cartesian3.subtract(
+ anchor,
+ camera.positionWC,
+ new Cesium.Cartesian3(),
+ );
+ if (Cesium.Cartesian3.dot(delta, camera.directionWC) <= 0) return null;
+ }
+ const pixel = Cesium.SceneTransforms.worldToWindowCoordinates(
+ viewer.scene,
+ anchor,
+ );
+ if (!pixel) return null;
+ const canvasBounds = viewer.canvas?.getBoundingClientRect?.();
+ const rootBounds = root.getBoundingClientRect?.();
+ if (!canvasBounds || !rootBounds) return null;
+ const x = pixel.x + canvasBounds.left - rootBounds.left;
+ const y = pixel.y + canvasBounds.top - rootBounds.top;
+ if (
+ x < -40 ||
+ y < -40 ||
+ x > rootBounds.width + 40 ||
+ y > rootBounds.height + 40
+ )
+ return null;
+ return { x, y, width: rootBounds.width, height: rootBounds.height };
+}
+
+function placeCard(card, leader, anchor) {
+ const margin = 18;
+ const gap = 42;
+ const width = card.offsetWidth || 392;
+ const height = card.offsetHeight || 360;
+ const rootBounds = card.parentElement?.getBoundingClientRect?.();
+ const documentRef = card.ownerDocument;
+ let laneLeft = margin;
+ let laneRight = anchor.width - margin;
+ let laneBottom = anchor.height - margin;
+ if (rootBounds && documentRef?.querySelector) {
+ const leftPanel = documentRef
+ .querySelector('#scene-panel')
+ ?.getBoundingClientRect?.();
+ const rightRail = documentRef
+ .querySelector('#right-context-rail')
+ ?.getBoundingClientRect?.();
+ const commandDock = documentRef
+ .querySelector('#command-dock')
+ ?.getBoundingClientRect?.();
+ if (leftPanel?.width > 0)
+ laneLeft = Math.max(laneLeft, leftPanel.right - rootBounds.left + margin);
+ if (rightRail?.width > 0)
+ laneRight = Math.min(
+ laneRight,
+ rightRail.left - rootBounds.left - margin,
+ );
+ if (commandDock?.height > 0)
+ laneBottom = Math.min(
+ laneBottom,
+ commandDock.top - rootBounds.top - margin,
+ );
+ if (laneRight - laneLeft < width) {
+ laneLeft = margin;
+ laneRight = anchor.width - margin;
+ }
+ }
+ let left = anchor.x + gap;
+ if (left + width > laneRight) left = anchor.x - width - gap;
+ left = Math.max(laneLeft, Math.min(laneRight - width, left));
+ const belowAnchor = anchor.y + gap;
+ const prefersBelow =
+ card.classList.contains('is-youtube') ||
+ card.classList.contains('is-portrait');
+ const desiredTop =
+ prefersBelow && belowAnchor + height <= laneBottom
+ ? belowAnchor
+ : anchor.y - height * 0.42;
+ const top = Math.max(margin, Math.min(laneBottom - height, desiredTop));
+ card.style.left = `${left}px`;
+ card.style.top = `${top}px`;
+ const localX = anchor.x - left;
+ const localY = anchor.y - top;
+ const edgeX = localX < 0 ? 0 : width;
+ const edgeY = Math.max(16, Math.min(height - 16, localY));
+ leader
+ .querySelector('path')
+ ?.setAttribute('d', `M ${localX} ${localY} L ${edgeX} ${edgeY}`);
+ const dot = leader.querySelector('circle');
+ dot?.setAttribute('cx', String(localX));
+ dot?.setAttribute('cy', String(localY));
+}
+
+function createRoot(documentRef, viewer) {
+ const root = documentRef.createElement('div');
+ root.id = 'bhote-koshi-embedded-media-root';
+ root.setAttribute('aria-live', 'polite');
+ (viewer?.container || documentRef.body)?.append(root);
+ return root;
+}
+
+/**
+ * Render one provider-owned player/post in a geographically anchored DOM card.
+ * YouTube and Facebook sources use their provider player; approved local clips
+ * remain available to bhoteKoshiEvent as a fallback when no embed is supported.
+ */
+export function createBhoteKoshiEmbeddedMedia({
+ viewer,
+ documentRef = globalThis.document,
+ globalRef = globalThis,
+ xLoader = loadXWidgets,
+ facebookLoader = loadFacebookSdk,
+ youtubeLoader = loadYouTubeApi,
+} = {}) {
+ if (!documentRef?.createElement || !viewer) {
+ return {
+ warm: () => false,
+ show: () => false,
+ play: () => false,
+ pause: () => false,
+ hide: () => {},
+ destroy: () => {},
+ };
+ }
+ const root = createRoot(documentRef, viewer);
+ let record = null;
+ let warmRecord = null;
+ let generation = 0;
+ const leavingRecords = new Set();
+
+ const prefersReducedMotion = () =>
+ globalRef.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ===
+ true;
+ const scheduleFrame = (callback) => {
+ if (typeof globalRef.requestAnimationFrame === 'function') {
+ return { kind: 'frame', id: globalRef.requestAnimationFrame(callback) };
+ }
+ if (typeof globalRef.setTimeout === 'function') {
+ return { kind: 'timer', id: globalRef.setTimeout(callback, 0) };
+ }
+ callback();
+ return null;
+ };
+ const cancelScheduledFrame = (scheduled) => {
+ if (!scheduled) return;
+ if (scheduled.kind === 'frame')
+ globalRef.cancelAnimationFrame?.(scheduled.id);
+ else globalRef.clearTimeout?.(scheduled.id);
+ };
+
+ function unsubscribeFacebook(session) {
+ if (!session?.readyListener) return;
+ try {
+ session.api?.Event?.unsubscribe?.('xfbml.ready', session.readyListener);
+ } catch {
+ // Provider teardown is best effort.
+ }
+ session.readyListener = null;
+ }
+
+ function setFacebookSessionPlayback(session, playing) {
+ if (!session?.active) return false;
+ session.shouldPlay = playing === true;
+ if (!session.player) return false;
+ try {
+ if (session.shouldPlay) {
+ session.player.mute?.();
+ const result = session.player.play?.();
+ result?.catch?.(() => {
+ if (!session.active || !session.shouldPlay) return;
+ session.onPlaybackBlocked?.();
+ });
+ } else {
+ session.player.pause?.();
+ }
+ return true;
+ } catch {
+ if (session.shouldPlay) session.onPlaybackBlocked?.();
+ return false;
+ }
+ }
+
+ function destroyFacebookSession(session) {
+ if (!session) return;
+ session.shouldPlay = false;
+ globalRef.clearTimeout?.(session.timer);
+ session.timer = null;
+ try {
+ session.player?.pause?.();
+ } catch {
+ // Provider teardown is best effort.
+ }
+ session.active = false;
+ unsubscribeFacebook(session);
+ session.host?.remove?.();
+ session.player = null;
+ }
+
+ function createFacebookSession(source, host) {
+ const session = {
+ active: true,
+ api: null,
+ embed: null,
+ host,
+ player: null,
+ ready: false,
+ error: null,
+ readyListener: null,
+ shouldPlay: false,
+ timer: null,
+ onReady: null,
+ onError: null,
+ onPlaybackBlocked: null,
+ };
+ const embed = documentRef.createElement('div');
+ embed.id = `bhote-koshi-facebook-${++_facebookEmbedSequence}`;
+ embed.className = 'fb-video';
+ embed.dataset.href = source.url;
+ embed.dataset.width = '500';
+ embed.dataset.showText = 'false';
+ embed.dataset.allowfullscreen = 'true';
+ embed.dataset.autoplay = 'false';
+ session.embed = embed;
+ host.append(embed);
+ void facebookLoader(documentRef, globalRef)
+ .then((api) => {
+ if (!session.active) return;
+ session.api = api;
+ session.readyListener = (message) => {
+ if (
+ !session.active ||
+ message?.type !== 'video' ||
+ (message.id && message.id !== embed.id)
+ )
+ return;
+ globalRef.clearTimeout?.(session.timer);
+ session.timer = null;
+ session.player = message.instance;
+ session.ready = true;
+ session.error = null;
+ session.onReady?.();
+ setFacebookSessionPlayback(session, session.shouldPlay);
+ };
+ api.Event.subscribe('xfbml.ready', session.readyListener);
+ api.XFBML.parse(session.host);
+ session.timer =
+ globalRef.setTimeout?.(() => {
+ if (!session.active || session.ready) return;
+ session.error = new Error(
+ 'Facebook embed unavailable. The post may be private, removed, region-restricted, or blocked.',
+ );
+ session.onError?.(session.error);
+ }, EMBED_TIMEOUT_MS) ?? null;
+ })
+ .catch((error) => {
+ if (!session.active) return;
+ session.error = error;
+ session.onError?.(error);
+ });
+ return session;
+ }
+
+ function removeRecord(current) {
+ if (!current) return;
+ cancelScheduledFrame(current.enterFrame);
+ if (current.exitTimer != null) globalRef.clearTimeout?.(current.exitTimer);
+ destroyFacebookSession(current.facebookSession);
+ current.youtubeSession?.destroy();
+ current.card?.remove?.();
+ leavingRecords.delete(current);
+ }
+
+ const position = () => {
+ if (!record) return;
+ const projected = anchorOnScreen(viewer, record.anchor, root);
+ record.card.hidden = !projected;
+ if (projected) {
+ placeCard(record.card, record.leader, projected);
+ if (!record.entered) {
+ record.entered = true;
+ const current = record;
+ const token = generation;
+ current.enterFrame = scheduleFrame(() => {
+ current.enterFrame = null;
+ if (token !== generation || record !== current) return;
+ current.card.classList.add('is-entering');
+ });
+ }
+ }
+ };
+ const removePostRender =
+ viewer.scene?.postRender?.addEventListener?.(position);
+
+ function hide({ immediate = false, preserveWarm = false } = {}) {
+ if (!preserveWarm) clearWarm();
+ generation += 1;
+ const current = record;
+ record = null;
+ if (!current) return;
+ current.youtubeSession?.destroy();
+ setFacebookSessionPlayback(current.facebookSession, false);
+ cancelScheduledFrame(current.enterFrame);
+ current.enterFrame = null;
+ if (immediate || current.card.hidden || prefersReducedMotion()) {
+ removeRecord(current);
+ return;
+ }
+ current.card.classList.remove('is-entering');
+ current.card.classList.add('is-leaving');
+ leavingRecords.add(current);
+ current.exitTimer =
+ globalRef.setTimeout?.(
+ () => removeRecord(current),
+ CARD_EXIT_DURATION_MS,
+ ) ?? null;
+ }
+
+ function clearWarm(key = null) {
+ if (!warmRecord || (key && warmRecord.key !== key)) return;
+ if (warmRecord.facebookSession)
+ destroyFacebookSession(warmRecord.facebookSession);
+ else warmRecord.iframe?.remove?.();
+ warmRecord = null;
+ }
+
+ function takeWarmFacebook(key, host) {
+ if (warmRecord?.key !== key || !warmRecord.facebookSession) return null;
+ const session = warmRecord.facebookSession;
+ warmRecord = null;
+ const warmHost = session.host;
+ warmHost.classList.remove('bhote-embedded-media-warmup');
+ warmHost.classList.add('bhote-facebook-player-host');
+ warmHost.removeAttribute?.('aria-hidden');
+ host.append(warmHost);
+ return session;
+ }
+
+ function warm({ observation, sourceUrl } = {}) {
+ const media = observation?.media || {};
+ const source = resolveEmbeddedMediaSource(sourceUrl || media.sourceUrl);
+ if (!source || source.provider === 'x') {
+ clearWarm();
+ return false;
+ }
+ const startAtSec =
+ source.provider === 'youtube'
+ ? Math.max(0, Number(media.embedStartAtSec ?? media.clipInSec) || 0)
+ : 0;
+ ensureProviderConnections(documentRef, source.provider);
+ const endAtSec = media.embedEndAtSec;
+ const key = `${source.provider}:${source.id || source.url}:${startAtSec}:${endAtSec ?? ''}`;
+ if (warmRecord?.key === key) return true;
+ clearWarm();
+ if (source.provider === 'facebook') {
+ const host = documentRef.createElement('div');
+ host.className = 'bhote-embedded-media-warmup';
+ host.setAttribute('aria-hidden', 'true');
+ root.append(host);
+ const facebookSession = createFacebookSession(source, host);
+ warmRecord = { key, facebookSession };
+ return true;
+ }
+ const iframe = documentRef.createElement('iframe');
+ iframe.className = 'bhote-embedded-media-warmup';
+ iframe.src = embeddedMediaFrameUrl(source, {
+ autoplay: false,
+ startAtSec,
+ endAtSec,
+ });
+ iframe.title = `Preloading ${providerLabel(source.provider)} media`;
+ iframe.tabIndex = -1;
+ iframe.setAttribute('aria-hidden', 'true');
+ iframe.loading = 'eager';
+ iframe.referrerPolicy = 'strict-origin-when-cross-origin';
+ root.append(iframe);
+ warmRecord = { key, iframe };
+ return true;
+ }
+
+ async function mountX(current, token) {
+ current.status.textContent = 'LOADING X POST…';
+ try {
+ const api = await xLoader(documentRef, globalRef);
+ if (token !== generation || record !== current) return;
+ const widget = await api.widgets.createTweet(
+ current.source.id,
+ current.player,
+ {
+ align: 'center',
+ conversation: 'none',
+ dnt: true,
+ theme: 'dark',
+ width: 390,
+ },
+ );
+ if (token !== generation || record !== current) return;
+ if (!widget)
+ throw new Error('The X post is unavailable or cannot be embedded.');
+ current.status.textContent = 'X POST · PROVIDER CONTROLS';
+ position();
+ } catch (error) {
+ if (token !== generation || record !== current) return;
+ current.card.classList.add('is-unavailable');
+ current.status.textContent = `${String(error?.message || 'X embed unavailable')} OPEN ORIGINAL.`;
+ position();
+ }
+ }
+
+ function bindFacebook(current, token, warmKey, autoplay) {
+ const session =
+ takeWarmFacebook(warmKey, current.player) ||
+ createFacebookSession(current.source, current.player);
+ current.facebookSession = session;
+ session.shouldPlay = autoplay === true;
+ session.onReady = () => {
+ if (token !== generation || record !== current) return;
+ current.status.textContent = 'FACEBOOK · PROVIDER CONTROLS';
+ current.card.classList.remove('is-unavailable');
+ position();
+ };
+ session.onError = (error) => {
+ if (token !== generation || record !== current) return;
+ current.card.classList.add('is-unavailable');
+ current.status.textContent = `${String(error?.message || 'Facebook embed unavailable')} OPEN ORIGINAL.`;
+ position();
+ };
+ session.onPlaybackBlocked = () => {
+ if (token !== generation || record !== current) return;
+ current.status.textContent = 'AUTOPLAY BLOCKED · USE PROVIDER CONTROLS';
+ };
+ if (session.ready) session.onReady();
+ else if (session.error) session.onError(session.error);
+ setFacebookSessionPlayback(session, autoplay);
+ }
+
+ function setPlayback(playing) {
+ if (!playing) clearWarm();
+ // Removing an uncontrolled provider frame also cancels delayed autoplay.
+ // Provider commands alone cannot stop a frame that has not loaded yet.
+ if (!playing && record && !record.facebookSession) {
+ hide({ immediate: true });
+ return true;
+ }
+ if (!record?.facebookSession) return false;
+ return setFacebookSessionPlayback(record.facebookSession, playing);
+ }
+
+ function show({ observation, anchor, autoplay = false, sourceUrl } = {}) {
+ const media = observation?.media || {};
+ const source = resolveEmbeddedMediaSource(sourceUrl || media.sourceUrl);
+ if (!source || !anchor) {
+ hide();
+ return false;
+ }
+ const startAtSec =
+ source.provider === 'youtube'
+ ? Math.max(0, Number(media.embedStartAtSec ?? media.clipInSec) || 0)
+ : 0;
+ ensureProviderConnections(documentRef, source.provider);
+ const endAtSec = media.embedEndAtSec;
+ const warmKey = `${source.provider}:${source.id || source.url}:${startAtSec}:${endAtSec ?? ''}`;
+ const key =
+ source.provider === 'facebook'
+ ? warmKey
+ : `${warmKey}:${autoplay ? 1 : 0}`;
+ if (record?.key === key) {
+ record.anchor = anchor;
+ if (source.provider === 'facebook') setPlayback(autoplay);
+ position();
+ return true;
+ }
+ if (warmRecord && warmRecord.key !== warmKey) clearWarm();
+ hide({ preserveWarm: true });
+ const token = ++generation;
+ const card = documentRef.createElement('section');
+ const portraitClass = isPortraitMedia(media) ? ' is-portrait' : '';
+ card.className = `bhote-embedded-callout is-${source.provider}${portraitClass}`;
+ card.dataset.provider = source.provider;
+ card.setAttribute(
+ 'aria-label',
+ `Embedded ${providerLabel(source.provider)} media for ${observation?.title || 'witness source'}`,
+ );
+ const leader = documentRef.createElementNS(
+ 'http://www.w3.org/2000/svg',
+ 'svg',
+ );
+ leader.classList.add('bhote-embedded-callout-leader');
+ leader.setAttribute('aria-hidden', 'true');
+ leader.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 _splitLine = null; + let _splitHandle = null; + let _splitDragging = false; + let _splitPointerId = 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 _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 _previousMapStack = null; + let _eventMapGeneration = null; + let _previousSplitPosition = null; + let _introTimer = null; + let _introGeneration = 0; + let _lastSurgePosition = null; + let _lastSurgeVisible = null; + let _lastSplitCssValue = 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; + } + + function sceneEvidenceMediaAutoplays() { + return ( + _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)) { + 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; + 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 = + _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 ensureSplitLine(event) { + _splitLine = document.createElement('div'); + _splitLine.id = 'bhote-koshi-split-line'; + const beforeLabel = document.createElement('span'); + const afterLabel = document.createElement('span'); + const splitHandle = document.createElement('button'); + beforeLabel.className = 'before'; + afterLabel.className = 'after'; + beforeLabel.textContent = 'A'; + beforeLabel.title = event.imagery.before.label; + afterLabel.textContent = 'B'; + afterLabel.title = event.imagery.after.label; + splitHandle.type = 'button'; + splitHandle.className = 'bhote-koshi-split-handle'; + splitHandle.setAttribute('role', 'slider'); + splitHandle.setAttribute( + 'aria-label', + 'Historical reference and post-event image divider', + ); + splitHandle.setAttribute('aria-valuemin', '0'); + splitHandle.setAttribute('aria-valuemax', '100'); + splitHandle.setAttribute('aria-orientation', 'horizontal'); + splitHandle.textContent = '↔'; + splitHandle.addEventListener('pointerdown', handleSplitPointerDown); + splitHandle.addEventListener('pointermove', handleSplitPointerMove); + splitHandle.addEventListener('pointerup', finishSplitPointerDrag); + splitHandle.addEventListener('pointercancel', finishSplitPointerDrag); + splitHandle.addEventListener('keydown', handleSplitKeyDown); + _splitHandle = splitHandle; + _splitLine.append(beforeLabel, splitHandle, afterLabel); + document.body.appendChild(_splitLine); + } + + function setSplitFromClientX(clientX) { + const viewportWidth = + Number(document.documentElement?.clientWidth) || + Number(window.innerWidth) || + 0; + if (viewportWidth <= 0 || !Number.isFinite(Number(clientX))) return; + setSplit(Number(clientX) / viewportWidth); + } + + function handleSplitPointerDown(event) { + if (event.button != null && event.button !== 0) return; + event.preventDefault?.(); + _splitDragging = true; + _splitPointerId = event.pointerId ?? null; + _splitHandle?.setPointerCapture?.(event.pointerId); + setSplitFromClientX(event.clientX); + } + + function handleSplitPointerMove(event) { + if (!_splitDragging) return; + if (_splitPointerId != null && event.pointerId !== _splitPointerId) return; + event.preventDefault?.(); + setSplitFromClientX(event.clientX); + } + + function finishSplitPointerDrag(event) { + if (!_splitDragging) return; + if (_splitPointerId != null && event.pointerId !== _splitPointerId) return; + _splitHandle?.releasePointerCapture?.(event.pointerId); + _splitDragging = false; + _splitPointerId = null; + } + + function handleSplitKeyDown(event) { + if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return; + event.preventDefault?.(); + const step = event.shiftKey ? 0.05 : 0.01; + if (event.key === 'Home') setSplit(0); + else if (event.key === 'End') setSplit(1); + else setSplit(_split + (event.key === 'ArrowRight' ? step : -step)); + } + + 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; + if (_splitLine) _splitLine.hidden = !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); + if (_viewer && _viewer.scene.splitPosition !== _split) + _viewer.scene.splitPosition = _split; + const cssValue = `${_split * 100}%`; + if (_lastSplitCssValue !== cssValue) { + document.documentElement.style.setProperty( + '--bhote-koshi-split', + cssValue, + ); + _lastSplitCssValue = cssValue; + } + if (_splitHandle) { + const beforePercent = Math.round(_split * 100); + _splitHandle.setAttribute('aria-valuenow', String(beforePercent)); + _splitHandle.setAttribute( + 'aria-valuetext', + `A historical reference ${beforePercent} percent, B post-event ${100 - beforePercent} percent`, + ); + } + 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; + _previousSplitPosition = viewer.scene.splitPosition; + // 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); + _previousMapStack = _mapStackController?.getActiveId?.() || null; + if ( + !sceneDirected && + _mapStackController && + _previousMapStack !== 'esri-imagery' + ) { + await _mapStackController.setStack('esri-imagery'); + } + _eventMapGeneration = sceneDirected + ? null + : (_mapStackController?.getSwitchGeneration?.() ?? null); + throwIfEnableCancelled(signal, _enabled); + await createImagery(event); + throwIfEnableCancelled(signal, _enabled); + await syncTerrainComparison(); + throwIfEnableCancelled(signal, _enabled); + createFloodDataSource(); + ensureSplitLine(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() { + _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; + _splitDragging = false; + _splitPointerId = null; + _splitLine?.remove(); + _splitLine = null; + _splitHandle = null; + document.documentElement.style.removeProperty('--bhote-koshi-split'); + _lastSplitCssValue = null; + if (_viewer?.scene && _previousSplitPosition != null) { + _viewer.scene.splitPosition = _previousSplitPosition; + } + _previousSplitPosition = 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 previousMapStack = _previousMapStack; + const eventMapGeneration = _eventMapGeneration; + _previousMapStack = null; + _eventMapGeneration = null; + try { + if ( + _mapStackController && + previousMapStack && + previousMapStack !== 'esri-imagery' && + _mapStackController.getActiveId?.() === 'esri-imagery' && + _mapStackController.getSwitchGeneration?.() === eventMapGeneration + ) { + await _mapStackController.setStack(previousMapStack); + } + } 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, + 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; diff --git a/src/data/bhoteKoshiEvent.test.mjs b/src/data/bhoteKoshiEvent.test.mjs new file mode 100644 index 000000000..bd69cd55e --- /dev/null +++ b/src/data/bhoteKoshiEvent.test.mjs @@ -0,0 +1,2818 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; +import { claimPointer, releasePointer } from './inputOwnership.js'; +import * as Cesium from 'cesium'; +import { SOURCE_PATH_BEAT_IDS } from './bhoteKoshiShotPaths.js'; +import { + BHOTE_KOSHI_LAYER_ID, + activeEvidenceIndex, + adjacentEvidenceIndex, + buildCinematicKeyframes, + buildEvidenceTimeline, + clampUnit, + createBhoteKoshiEventLayer, + evidenceCardLayout, + createEvidenceFrame, + elapsedLabel, + evidenceBeatProgress, + evidenceBeatPresentation, + evidenceCorroborationLabel, + evidenceSequenceWindow, + evidenceSummaryLines, + floodProgressForStoryProgress, + interpolateHeadingDegrees, + sampleCinematicPose, + updateEvidenceFrame, + updateCorridorPositionCache, + usesProviderEmbeddedEvidence, + visibleSegmentCount, +} from './bhoteKoshiEvent.js'; +import { SCENE_RECIPES } from '../scenes/recipes.js'; +import { LayerLifecycle } from './lifecycle.js'; + +const eventUrl = new URL('../../public/events/bhote-koshi-2026/event.json', import.meta.url); +const eventModuleUrl = new URL('./bhoteKoshiEvent.js', import.meta.url); + +class FakeElement { + constructor(tagName = 'div') { + this.tagName = tagName; + this.children = []; + this.removed = false; + this._value = ''; + this._textContent = ''; + this._disabled = false; + this._writeCount = 0; + this._queryCount = 0; + this._attributes = new Map(); + this._selectors = new Map(); + this._listeners = new Map(); + } + + set value(value) { + if (this._value === value) return; + this._value = value; + this._writeCount += 1; + } + + get value() { return this._value; } + + set textContent(value) { + if (this._textContent === value) return; + this._textContent = value; + this._writeCount += 1; + } + + get textContent() { return this._textContent; } + + set disabled(value) { + if (this._disabled === value) return; + this._disabled = value; + this._writeCount += 1; + } + + get disabled() { return this._disabled; } + + setAttribute(name, value) { + if (this._attributes.get(name) === value) return; + this._attributes.set(name, value); + this._writeCount += 1; + } + + getAttribute(name) { return this._attributes.get(name) ?? null; } + addEventListener(type, handler) { + this._listeners.set(type, handler); + } + + removeEventListener(type, handler) { + if (this._listeners.get(type) === handler) this._listeners.delete(type); + } + + dispatch(type, event = { target: this }) { + return this._listeners.get(type)?.(event); + } + + append(...children) { + this.children.push(...children); + } + + appendChild(child) { + this.children.push(child); + return child; + } + + querySelector(selector) { + this._queryCount += 1; + if (!this._selectors.has(selector)) this._selectors.set(selector, new FakeElement()); + return this._selectors.get(selector); + } + + remove() { + this.removed = true; + } + + set innerHTML(value) { + this._innerHTML = value; + } + + get innerHTML() { + return this._innerHTML || ''; + } +} + +function installEventDom() { + const body = new FakeElement('body'); + const removedProperties = []; + globalThis.document = { + body, + createElement: (tagName) => new FakeElement(tagName), + documentElement: { + clientWidth: 1000, + style: { + setProperty() {}, + removeProperty(name) { removedProperties.push(name); }, + }, + }, + }; + return { body, removedProperties }; +} + +function eventViewer(splitPosition = 0.37) { + const imagery = []; + const removedImagery = []; + const dataSources = []; + const removedDataSources = []; + const canvas = new FakeElement('canvas'); + const cameraLooks = []; + const cameraTransforms = []; + return { + scene: { splitPosition, canvas }, + camera: { + cancelFlight() {}, + lookAt(target, offset) { + cameraLooks.push({ + target: { x: target.x, y: target.y, z: target.z }, + offset: { heading: offset.heading, pitch: offset.pitch, range: offset.range }, + }); + }, + lookAtTransform(transform) { cameraTransforms.push(transform); }, + }, + imageryLayers: { + addImageryProvider(provider) { + const layer = { provider }; + imagery.push(layer); + return layer; + }, + remove(layer) { removedImagery.push(layer); }, + }, + dataSources: { + add(source) { dataSources.push(source); }, + remove(source) { removedDataSources.push(source); }, + }, + _test: { + imagery, + removedImagery, + dataSources, + removedDataSources, + canvas, + cameraLooks, + cameraTransforms, + }, + }; +} + +test('public lifecycle restores Nepal scene ownership without forwarding origin to enable', async () => { + const previous = { document: globalThis.document, window: globalThis.window }; + const event = JSON.parse(await readFile(eventUrl, 'utf8')); + try { + for (const initialMap of ['photoreal', 'esri-imagery']) { + for (const origin of ['scene', 'local-restore', 'share-restore']) { + installEventDom(); + const timers = []; + globalThis.window = { + setTimeout(handler, delay) { timers.push({ handler, delay }); return timers.length; }, + clearTimeout() {}, + }; + const viewer = eventViewer(); + viewer.camera.flyToBoundingSphere = () => viewer._test.cameraLooks.push('corridor'); + let activeMap = initialMap; + const mapCalls = []; + const layer = createBhoteKoshiEventLayer({ + eventLoader: async () => event, + imageryProviderFactory: async (url) => ({ url }), + terrainSampler: async (_viewer, observations) => observations.map(() => ({ height: 800 })), + mediaLoader: async () => { const error = new Error('No bundled clip'); error.name = 'AbortError'; throw error; }, + overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} }, + renderHost: { request() {}, hold() {}, release() {} }, + }); + layer.attachMapStackController({ + getActiveId: () => activeMap, + getSwitchGeneration: () => 1, + async setStack(id) { mapCalls.push(id); activeMap = id; }, + }); + const manager = new LayerLifecycle(viewer); + manager.register(layer); + manager.finalizeRegistrations([{ id: layer.id, disposition: 'enabled-only' }]); + try { + const result = await manager.restoreLayerState(layer.id, { + enabled: true, + params: { presentation: 'scene-beat', sceneSurface: 'panel-only', progress: 0.34 }, + }, { origin }); + assert.equal(result.succeeded, true); + assert.equal(layer.getPlaybackState().enabled, true); + assert.deepEqual(mapCalls, [], `${initialMap}/${origin}: preserve the shot map`); + assert.equal(activeMap, initialMap); + const floodSource = viewer._test.dataSources.at(-1); + for (const id of ['bhote-koshi-flood-halo', 'bhote-koshi-flood-core']) { + const line = floodSource.entities.getById(id).polyline; + assert.equal(line.clampToGround.getValue(), true, 'dynamic trail follows the rendered surface'); + assert.equal(line.classificationType.getValue(), initialMap === 'photoreal' + ? Cesium.ClassificationType.BOTH : Cesium.ClassificationType.TERRAIN); + assert.equal(line.depthFailMaterial, undefined, 'do not rely on unsupported dynamic depth-fail rendering'); + } + assert.equal(floodSource.entities.getById('bhote-koshi-surge-front').point.heightReference.getValue(), + Cesium.HeightReference.CLAMP_TO_GROUND); + assert.deepEqual(viewer._test.cameraLooks, [], 'enable must not focus the corridor'); + assert.deepEqual(timers, [], 'enable must not schedule independent playback'); + assert.equal(layer.getPlaybackState().playing, false); + assert.equal(await manager.setEnabled(layer.id, false, { origin: 'user' }), true); + assert.deepEqual(mapCalls, [], 'disable must not restore a map it never owned'); + } finally { + await manager.destroyLayer(layer.id); + } + } + } + } finally { + Object.assign(globalThis, previous); + } +}); + +test('trimmed scene media holds through provider startup, playback and fade, then releases the camera', async () => { + const previous = { document: globalThis.document, window: globalThis.window }; + installEventDom(); + const timers = new Map(); + let nextId = 0; + globalThis.window = { + setTimeout(fn, delay) { const id = ++nextId; timers.set(id, { fn, delay }); return id; }, + clearTimeout(id) { timers.delete(id); }, + }; + let phase = 'starting'; + let hidden = 0; + const event = JSON.parse(await readFile(eventUrl, 'utf8')); + const layer = createBhoteKoshiEventLayer({ + eventLoader: async () => event, + imageryProviderFactory: async (url) => ({ url }), + terrainSampler: async (_viewer, observations) => observations.map(() => ({ height: 800 })), + mediaLoader: async () => { const error = new Error('No bundled clip'); error.name = 'AbortError'; throw error; }, + overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} }, + renderHost: { request() {}, hold() {}, release() {} }, + embeddedMediaFactory: () => ({ warm: () => true, show: () => true, play() {}, pause() {}, + getPlaybackState: () => ({ phase }), hide() { hidden++; }, destroy() {} }), + }); + const fire = (delay) => { + const entry = [...timers].find(([, value]) => value.delay === delay); + assert.ok(entry, `expected ${delay}ms owned timer`); + timers.delete(entry[0]); entry[1].fn(); + }; + try { + const viewer = eventViewer(); + await layer.init(viewer); + layer.setParams({ presentation: 'scene-beat', sceneSurface: 'evidence-beat', beatId: 'mailung-bazzar', + sceneContext: { holdSec: 7.65 }, + sceneControls: { evidenceMediaAutoplay: true, cameraSettled: true, mediaExitDurationSec: 0.65 } }, { origin: 'scene' }); + await layer.enable(viewer, { origin: 'scene' }); + assert.equal(layer.getSceneShotMediaHold('mailung-bazzar').pending, true); + assert.equal(layer.getSceneShotMediaHold('different-beat'), null); + fire(100); + assert.equal(layer.getSceneShotMediaHold('mailung-bazzar').pending, true, 'startup is not treated as playback'); + phase = 'playing'; fire(100); + assert.equal(layer.getSceneShotMediaHold('mailung-bazzar').pending, true); + const hiddenBeforeEnd = hidden; + phase = 'completed'; fire(100); + assert.ok(hidden > hiddenBeforeEnd); + assert.equal(layer.getSceneShotMediaHold('mailung-bazzar').pending, true, 'the fade still owns the hold'); + fire(650); + assert.equal(layer.getSceneShotMediaHold('mailung-bazzar').pending, false); + await layer.disable(); + assert.equal(layer.getSceneShotMediaHold('mailung-bazzar'), null); + } finally { + await layer.destroy(); + globalThis.document = previous.document; globalThis.window = previous.window; + } +}); + +test('Bhote Koshi timeline helpers clamp untrusted UI values', () => { + assert.equal(clampUnit(-1), 0); + assert.equal(clampUnit(0.42), 0.42); + assert.equal(clampUnit(3), 1); + assert.equal(elapsedLabel(0.5, 4170), '34:45'); + assert.equal(elapsedLabel(1, 4170), '69:30'); +}); + +test('Bhote Koshi flood reveal is monotonic and bounded', () => { + assert.equal(visibleSegmentCount(0, 144), 0); + assert.equal(visibleSegmentCount(Number.NaN, 144), 0); + assert.equal(visibleSegmentCount(0.001, 144), 1); + assert.equal(visibleSegmentCount(0.5, 144), 72); + assert.equal(visibleSegmentCount(2, 144), 144); +}); + +test('Bhote Koshi flood corridor grows continuously to the animated surge head', () => { + const terrainPositions = Array.from( + { length: 11 }, + (_, index) => new Cesium.Cartesian3(index, 0, 0), + ); + const cache = { visibleCount: -1, positions: [] }; + + assert.equal(updateCorridorPositionCache(0.201, terrainPositions, cache), true); + const firstPositions = cache.positions; + assert.equal(cache.visibleCount, 3); + assert.equal(firstPositions.length, 4); + assert.ok(Math.abs(cache.headPosition.x - 2.01) < 1e-9); + assert.equal(firstPositions.at(-1).x, cache.headPosition.x); + + assert.equal(updateCorridorPositionCache(0.299, terrainPositions, cache), true); + assert.notEqual(cache.positions, firstPositions, 'the trail advances within the segment'); + assert.ok(Math.abs(cache.headPosition.x - 2.99) < 1e-9); + assert.equal(cache.positions.at(-1).x, cache.headPosition.x); + + const repeatedPositions = cache.positions; + assert.equal(updateCorridorPositionCache(0.299, terrainPositions, cache), false); + assert.equal(cache.positions, repeatedPositions, 'an unchanged frame reuses its positions'); + + assert.equal(updateCorridorPositionCache(0.301, terrainPositions, cache), true); + assert.equal(cache.visibleCount, 4); + assert.equal(cache.positions.length, 5); + assert.ok(Math.abs(cache.headPosition.x - 3.01) < 1e-9); + assert.equal(cache.positions.at(-1).x, cache.headPosition.x); +}); + +test('Bhote Koshi story clock holds the flood for the cause beats then completes the corridor', () => { + assert.equal(floodProgressForStoryProgress(0), 0); + assert.equal(floodProgressForStoryProgress(0.11), 0); + assert.equal(floodProgressForStoryProgress(0.22), 0); + assert.ok(floodProgressForStoryProgress(0.5) > 0.4); + assert.equal(floodProgressForStoryProgress(0.84), 1); + assert.equal(floodProgressForStoryProgress(1), 1); +}); + +test('Bhote Koshi camera holds its evidence beat, then travels by the next activation', () => { + assert.equal(interpolateHeadingDegrees(350, 10, 0.5), 0); + const reusableTarget = { x: 99, y: 99, z: 99 }; + const result = { target: reusableTarget }; + const keyframes = [ + { + id: 'cause', + progress: 0, + target: { x: 0, y: 0, z: 0 }, + headingDeg: 350, + pitchDeg: -50, + rangeM: 1000, + }, + { + id: 'border', + progress: 1, + target: { x: 10, y: 20, z: 30 }, + headingDeg: 10, + pitchDeg: -30, + rangeM: 3000, + }, + ]; + const held = sampleCinematicPose(0.5, keyframes, result); + assert.deepEqual(held.target, { x: 0, y: 0, z: 0 }); + assert.equal(held.headingDeg, 350); + assert.equal(held.pitchDeg, -50); + assert.equal(held.rangeM, 1000); + + const sampled = sampleCinematicPose(0.81, keyframes, result); + assert.equal(sampled, result); + assert.equal(sampled.target, reusableTarget); + assert.ok(Math.abs(sampled.target.x - 5) < 1e-9); + assert.ok(Math.abs(sampled.target.y - 10) < 1e-9); + assert.ok(Math.abs(sampled.target.z - 15) < 1e-9); + assert.equal(sampled.headingDeg, 0); + assert.equal(sampled.pitchDeg, -40); + assert.equal(sampled.rangeM, 2000); +}); + +test('Bhote Koshi card summaries wrap on words instead of clipping mid-sentence', () => { + assert.deepEqual( + evidenceSummaryLines('Five geolocated public viewpoints document the flood passage.', 34, 2), + ['Five geolocated public viewpoints', 'document the flood passage.'], + ); + assert.deepEqual(evidenceSummaryLines('one two three four five', 9, 1), ['one two…']); +}); + +test('Bhote Koshi evidence timeline is ordered by phase and corridor chainage with one active beat', async () => { + const event = JSON.parse(await readFile(eventUrl, 'utf8')); + const timeline = buildEvidenceTimeline(event.evidenceSpine, event.reconstruction.corridor); + assert.deepEqual( + timeline.map((beat) => beat.observation.id), + [ + 'immediate-collapse-viewpoint', + 'debris-dammed-lake', + 'second-landslide', + 'gyirong-border-gate', + 'timure-cluster', + 'syabru-besi', + 'dhunche', + 'mailung-upper-trishuli', + 'mailung-bazzar', + 'dandagaun', + 'dandagaun-viewpoint', + 'betrawati-bazaar', + 'bhainse', + 'bidur-trishuli-bridge', + 'devighat-taadi-khola-bridge', + 'charaudi', + ], + ); + assert.equal(timeline[0].activationProgress, 0); + assert.equal(timeline[1].activationProgress, 0.065); + assert.equal(timeline[2].activationProgress, 0.13); + assert.ok(timeline[3].activationProgress > timeline[2].activationProgress); + assert.ok(timeline[4].activationProgress > timeline[3].activationProgress); + assert.equal(timeline[5].activationProgress, 0.325); + for (let index = 0; index < timeline.length; index += 1) { + assert.equal(activeEvidenceIndex(timeline[index].activationProgress, timeline), index); + const midpoint = ( + timeline[index].activationProgress + Math.min(1, timeline[index].deactivationProgress) + ) / 2; + assert.equal(activeEvidenceIndex(midpoint, timeline), index); + const dwellSeconds = ( + Math.min(1, timeline[index].deactivationProgress) - timeline[index].activationProgress + ) * 90; + assert.ok(dwellSeconds >= 2, `${timeline[index].observation.id} dwell is too short`); + } + + const anchors = timeline.map((_, index) => ({ x: index, y: index + 1, z: index + 2 })); + const cameraFrames = buildCinematicKeyframes(timeline, anchors); + assert.deepEqual(cameraFrames.slice(0, 16).map((frame) => frame.id), timeline.map((beat) => beat.observation.id)); + assert.deepEqual(cameraFrames.slice(0, 16).map((frame) => frame.progress), timeline.map((beat) => beat.activationProgress)); + assert.equal(cameraFrames.at(-1).id, 'charaudi'); + assert.equal(cameraFrames.at(-1).progress, 1); +}); + +test('Bhote Koshi scene beats resolve stable ids inside their reveal windows', async () => { + const event = JSON.parse(await readFile(eventUrl, 'utf8')); + const timeline = buildEvidenceTimeline(event.evidenceSpine, event.reconstruction.corridor); + for (const beat of timeline) { + const progress = evidenceBeatProgress(beat.observation.id, timeline, 0.36); + assert.ok(progress > beat.activationProgress || beat.deactivationProgress <= beat.activationProgress); + assert.ok(progress < Math.min(1.000001, beat.deactivationProgress)); + assert.equal(activeEvidenceIndex(progress, timeline), timeline.indexOf(beat)); + } + assert.equal(evidenceBeatProgress('missing-beat', timeline), null); +}); + +test('Bhote Koshi scene shots continue the standalone evidence clock from Border Gate onward', async () => { + const event = JSON.parse(await readFile(eventUrl, 'utf8')); + const timeline = buildEvidenceTimeline(event.evidenceSpine, event.reconstruction.corridor); + const border = evidenceSequenceWindow('gyirong-border-gate', timeline); + assert.deepEqual(border, { + startProgress: timeline[3].activationProgress, + targetProgress: timeline[3].deactivationProgress, + }); + + for (const beatId of [ + 'timure-cluster', + 'syabru-besi', + 'bidur-trishuli-bridge', + ]) { + const beat = timeline.find(({ observation }) => observation.id === beatId); + const window = evidenceSequenceWindow(beatId, timeline); + assert.equal(window.startProgress, beat.activationProgress); + assert.equal(window.targetProgress, Math.min(1, beat.deactivationProgress)); + assert.ok(window.targetProgress > window.startProgress); + } + assert.equal(evidenceSequenceWindow('missing-beat', timeline), null); +}); + +test('Bhote Koshi evidence presentation reveals dot, leader, and card then tears down in reverse', () => { + const beat = { activationProgress: 0.2, deactivationProgress: 0.4 }; + assert.deepEqual(evidenceBeatPresentation(0.2, beat), { + alpha: 0, + scale: 0.96, + leaderProgress: 0, + contentAlpha: 0, + localProgress: 0, + }); + const revealed = evidenceBeatPresentation(0.2075, beat); + assert.equal(revealed.alpha, 1); + assert.equal(revealed.leaderProgress, 1); + assert.ok(revealed.contentAlpha > 0 && revealed.contentAlpha < 1); + assert.ok(revealed.scale > 0.96 && revealed.scale < 1); + const held = evidenceBeatPresentation(0.3, beat); + assert.equal(held.alpha, 1); + assert.equal(held.contentAlpha, 1); + assert.equal(held.scale, 1); + const receding = evidenceBeatPresentation(0.35, beat); + assert.equal(receding.alpha, 1, 'the terrain dot remains after card recession starts'); + assert.ok(receding.contentAlpha < receding.leaderProgress); + assert.ok(receding.scale < held.scale); + const late = evidenceBeatPresentation(0.375, beat); + assert.equal(late.contentAlpha, 0, 'card content clears first'); + assert.ok(late.leaderProgress > 0 && late.leaderProgress < late.alpha); + assert.equal(evidenceBeatPresentation(0.4, beat).alpha, 0); +}); + +test('Bhote Koshi evidence cards freeze useful landscape, portrait, and link-only footprints', () => { + assert.equal(evidenceCardLayout({ + media: { posterUrl: 'poster.jpg', width: 1920, height: 1080 }, + }).kind, 'landscape'); + assert.deepEqual( + { + kind: evidenceCardLayout({ + media: { videoPath: 'portrait.mp4', width: 240, height: 422 }, + }).kind, + width: evidenceCardLayout({ + media: { videoPath: 'portrait.mp4', width: 240, height: 422 }, + }).thumbnailWidth, + height: evidenceCardLayout({ + media: { videoPath: 'portrait.mp4', width: 240, height: 422 }, + }).thumbnailHeight, + }, + { kind: 'portrait', width: 168, height: 260 }, + ); + assert.equal(evidenceCardLayout({ media: { posterUrl: null } }).kind, 'link'); +}); + +test('Bhote Koshi gives YouTube and Facebook players priority over local review clips', () => { + assert.equal(usesProviderEmbeddedEvidence({ + media: { + sourceUrl: 'https://www.youtube.com/watch?v=DbqRexFxv3k', + videoPath: 'evidence-01.mp4', + }, + }), true); + assert.equal(usesProviderEmbeddedEvidence({ + media: { + sourceUrl: 'https://www.facebook.com/reel/1571491657757829', + videoPath: 'evidence-15.mp4', + }, + }), true); + assert.equal(usesProviderEmbeddedEvidence({ + media: { + sourceUrl: 'https://example.com/evidence', + videoPath: 'evidence-01.mp4', + }, + }), false); +}); + +test('Bhote Koshi beat navigation is deterministic in both directions and clamps at its ends', async () => { + const event = JSON.parse(await readFile(eventUrl, 'utf8')); + const timeline = buildEvidenceTimeline(event.evidenceSpine, event.reconstruction.corridor); + assert.equal(adjacentEvidenceIndex(0, timeline, -1), 0); + assert.equal(adjacentEvidenceIndex(0, timeline, 1), 1); + assert.equal(adjacentEvidenceIndex(timeline[3].activationProgress, timeline, -1), 2); + assert.equal(adjacentEvidenceIndex(timeline[3].activationProgress, timeline, 1), 4); + assert.equal(adjacentEvidenceIndex(1, timeline, 1), 15); + assert.equal(adjacentEvidenceIndex(0.5, [], 1), -1); +}); + +test('Bhote Koshi evidence fallback stays media-first and source-linked without card copy overload', async () => { + const event = JSON.parse(await readFile(eventUrl, 'utf8')); + const timure = event.evidenceSpine.find((observation) => observation.id === 'timure-cluster'); + const originalDocument = globalThis.document; + const contexts = []; + globalThis.document = { + createElement() { + const text = []; + const context = { + text, + beginPath() {}, + clearRect() {}, + moveTo() {}, + lineTo() {}, + stroke() {}, + fillRect() {}, + fillText(value) { text.push(String(value)); }, + drawImage() { throw new Error('fixture poster decode failure'); }, + }; + contexts.push(context); + return { width: 0, height: 0, getContext: () => context }; + }, + }; + + try { + const missingPosterFrame = createEvidenceFrame(timure); + const failedPosterFrame = createEvidenceFrame(timure, { width: 640, height: 400 }); + assert.ok(missingPosterFrame); + assert.ok(failedPosterFrame); + for (const context of contexts) { + assert.ok(context.text.includes('NO PREVIEW')); + assert.ok(context.text.includes('TIMURE')); + assert.ok(context.text.includes('YOUTUBE SOURCE / OPEN')); + assert.doesNotMatch(context.text.join(' '), /CAPTURE TIME|GEOLOCATED|public viewpoints/); + } + assert.match(timure.media.sourceUrl, /^https:\/\//); + } finally { + globalThis.document = originalDocument; + } +}); + +test('Bhote Koshi evidence compositor contains portrait video from its decoded dimensions', async () => { + const event = JSON.parse(await readFile(eventUrl, 'utf8')); + const observation = event.evidenceSpine[0]; + const drawCalls = []; + const context = { + beginPath() {}, + clearRect() {}, + moveTo() {}, + lineTo() {}, + stroke() {}, + fillRect() {}, + fillText() {}, + drawImage(...args) { drawCalls.push(args); }, + }; + const layout = evidenceCardLayout(observation); + const canvas = { + width: layout.canvasWidth, + height: layout.canvasHeight, + _bhoteEvidenceLayout: layout, + getContext: () => context, + }; + const video = { videoWidth: 240, videoHeight: 422, width: 0, height: 0 }; + + assert.equal(updateEvidenceFrame(canvas, observation, video, { videoActive: true }), true); + assert.equal(drawCalls.length, 1); + const [, destX, destY, destWidth, destHeight] = drawCalls[0]; + assert.ok(destX > 0, 'portrait video keeps narrow side gutters instead of being cropped'); + assert.equal(destY, 0); + assert.ok(destWidth < layout.canvasWidth); + assert.equal(destHeight, layout.mediaHeight); + assert.equal(context.textAlign, 'left', 'repeat paints cannot inherit the outside badge alignment'); + assert.equal(updateEvidenceFrame(canvas, observation, video, { videoActive: true }), true); + assert.equal(context.textAlign, 'left'); +}); + +test('Bhote Koshi failed direct enable restores the prior map and shared split', async () => { + const event = JSON.parse(await readFile(eventUrl, 'utf8')); + const mapCalls = []; + const overlayCalls = []; + const originalDocument = globalThis.document; + const originalWindow = globalThis.window; + let activeMap = 'photoreal'; + let generation = 7; + const viewer = { + scene: { splitPosition: 0.37 }, + imageryLayers: { remove() {} }, + dataSources: { remove() {} }, + }; + const layer = createBhoteKoshiEventLayer({ + eventLoader: async () => event, + imageryProviderFactory: async () => { throw new Error('fixture image failure'); }, + overlayHost: { + setEntries() {}, + setVisible(id, visible) { overlayCalls.push(['visible', id, visible]); }, + clearSource(id) { overlayCalls.push(['clear', id]); }, + }, + renderHost: { request() {}, hold() {}, release() {} }, + }); + layer.attachMapStackController({ + getActiveId: () => activeMap, + getSwitchGeneration: () => generation, + async setStack(id) { + activeMap = id; + generation += 1; + mapCalls.push(id); + }, + }); + globalThis.document = { documentElement: { style: { removeProperty() {} } } }; + globalThis.window = { clearTimeout() {} }; + + try { + await assert.rejects( + layer.enable(viewer, { origin: 'scene' }), + /fixture image failure/, + ); + assert.deepEqual(mapCalls, ['esri-imagery', 'photoreal']); + assert.equal(viewer.scene.splitPosition, 0.37); + assert.deepEqual(layer.getPlaybackState(), { + enabled: false, + playing: false, + progress: 0, + split: 0.5, + }); + assert.ok(overlayCalls.some(([kind]) => kind === 'clear')); + } finally { + globalThis.document = originalDocument; + globalThis.window = originalWindow; + } +}); + +test('Bhote Koshi cancellation during the map switch restores the prior stack', async () => { + const event = JSON.parse(await readFile(eventUrl, 'utf8')); + const originalDocument = globalThis.document; + const originalWindow = globalThis.window; + const controller = new AbortController(); + const viewer = eventViewer(); + let activeMap = 'photoreal'; + let generation = 12; + let finishSwitch; + const switchGate = new Promise((resolve) => { finishSwitch = resolve; }); + const mapCalls = []; + const layer = createBhoteKoshiEventLayer({ + eventLoader: async () => event, + overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} }, + renderHost: { request() {}, hold() {}, release() {} }, + }); + layer.attachMapStackController({ + getActiveId: () => activeMap, + getSwitchGeneration: () => generation, + async setStack(id) { + activeMap = id; + generation += 1; + mapCalls.push(id); + if (id === 'esri-imagery') await switchGate; + }, + }); + installEventDom(); + globalThis.window = { clearTimeout() {} }; + + try { + const enabling = layer.enable(viewer, { origin: 'scene', signal: controller.signal }); + await new Promise((resolve) => setTimeout(resolve, 0)); + controller.abort(); + finishSwitch(); + await assert.rejects(enabling, (error) => error?.name === 'AbortError'); + assert.deepEqual(mapCalls, ['esri-imagery', 'photoreal']); + assert.equal(viewer.scene.splitPosition, 0.37); + assert.equal(layer.getPlaybackState().enabled, false); + } finally { + globalThis.document = originalDocument; + globalThis.window = originalWindow; + } +}); + +test('Bhote Koshi disable preserves an operator-selected map and tears down every surface', async () => { + const event = JSON.parse(await readFile(eventUrl, 'utf8')); + const originalDocument = globalThis.document; + const originalWindow = globalThis.window; + const { body, removedProperties } = installEventDom(); + const clearedTimeouts = []; + globalThis.window = { + setTimeout: () => 71, + clearTimeout: (id) => clearedTimeouts.push(id), + }; + const viewer = eventViewer(); + let activeMap = 'photoreal'; + let generation = 20; + const mapCalls = []; + const overlayCalls = []; + const renderCalls = []; + const layer = createBhoteKoshiEventLayer({ + eventLoader: async () => event, + imageryProviderFactory: async (url) => ({ url }), + overlayHost: { + setEntries() {}, + setVisible(id, visible) { overlayCalls.push(['visible', id, visible]); }, + clearSource(id) { overlayCalls.push(['clear', id]); }, + }, + renderHost: { + request(reason) { renderCalls.push(reason); }, + hold() {}, + release() {}, + }, + }); + layer.attachMapStackController({ + getActiveId: () => activeMap, + getSwitchGeneration: () => generation, + async setStack(id) { + activeMap = id; + generation += 1; + mapCalls.push(id); + }, + }); + + try { + assert.equal(await layer.enable(viewer, { origin: 'scene' }), true); + assert.equal(viewer._test.imagery.length, 2); + assert.equal(viewer._test.dataSources.length, 1); + assert.equal(body.children.length, 2); + + activeMap = 'operator-choice'; + generation += 1; + await layer.disable(); + + assert.deepEqual(mapCalls, ['esri-imagery']); + assert.equal(viewer._test.removedImagery.length, 2); + assert.equal(viewer._test.removedDataSources.length, 1); + assert.ok(body.children.every((element) => element.removed)); + assert.equal(viewer.scene.splitPosition, 0.37); + assert.deepEqual(clearedTimeouts, [71]); + assert.ok(overlayCalls.some(([kind]) => kind === 'clear')); + assert.ok(removedProperties.includes('--bhote-koshi-split')); + assert.ok(renderCalls.includes('bhote-koshi-disable')); + } finally { + globalThis.document = originalDocument; + globalThis.window = originalWindow; + } +}); + +test('Bhote Koshi disable stops active playback and releases its global render hold', async () => { + const event = JSON.parse(await readFile(eventUrl, 'utf8')); + const originalDocument = globalThis.document; + const originalWindow = globalThis.window; + const originalPerformance = globalThis.performance; + const originalRequestAnimationFrame = globalThis.requestAnimationFrame; + const originalCancelAnimationFrame = globalThis.cancelAnimationFrame; + const { body } = installEventDom(); + const holds = []; + const releases = []; + const cancelledFrames = []; + globalThis.window = { setTimeout: () => 71, clearTimeout() {} }; + globalThis.performance = { now: () => 1000 }; + globalThis.requestAnimationFrame = () => 33; + globalThis.cancelAnimationFrame = (id) => cancelledFrames.push(id); + const viewer = eventViewer(); + const layer = createBhoteKoshiEventLayer({ + eventLoader: async () => event, + imageryProviderFactory: async (url) => ({ url }), + overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} }, + renderHost: { + request() {}, + hold(id) { holds.push(id); }, + release(id) { releases.push(id); }, + }, + }); + layer.attachMapStackController({ + getActiveId: () => 'esri-imagery', + getSwitchGeneration: () => 1, + async setStack() {}, + }); + + try { + await layer.enable(viewer, { origin: 'scene' }); + const panel = body.children.find((element) => element.tagName === 'aside'); + assert.equal(panel.querySelector('[data-action="play-scene"]').hidden, true); + assert.equal(await panel.querySelector('[data-action="play-scene"]').dispatch('click'), false); + panel.querySelector('[data-action="play"]').dispatch('click'); + assert.equal(layer.getPlaybackState().playing, true); + + await layer.disable(); + + assert.deepEqual(holds, ['bhote-koshi-playback']); + assert.deepEqual(releases, ['bhote-koshi-playback']); + assert.deepEqual(cancelledFrames, [33]); + assert.deepEqual(layer.getPlaybackState(), { + enabled: false, + playing: false, + progress: 0, + split: 0.5, + }); + } finally { + globalThis.document = originalDocument; + globalThis.window = originalWindow; + globalThis.performance = originalPerformance; + globalThis.requestAnimationFrame = originalRequestAnimationFrame; + globalThis.cancelAnimationFrame = originalCancelAnimationFrame; + } +}); + +test('Bhote Koshi passive restores do not start off-screen playback', async () => { + const event = JSON.parse(await readFile(eventUrl, 'utf8')); + const originalDocument = globalThis.document; + const originalWindow = globalThis.window; + installEventDom(); + const timers = []; + globalThis.window = { + setTimeout(handler, delay) { + timers.push({ handler, delay }); + return timers.length; + }, + clearTimeout() {}, + }; + const viewer = eventViewer(); + const layer = createBhoteKoshiEventLayer({ + eventLoader: async () => event, + imageryProviderFactory: async (url) => ({ url }), + embeddedMediaFactory: () => ({ show() {}, hide() {}, pause() {}, destroy() {} }), + overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} }, + renderHost: { request() {}, hold() {}, release() {} }, + }); + layer.attachMapStackController({ + getActiveId: () => 'esri-imagery', + getSwitchGeneration: () => 1, + async setStack() {}, + }); + + const manager = new LayerLifecycle(viewer); + manager.register(layer); + try { + assert.equal(await manager.setEnabled(layer.id, true, { origin: 'local-restore' }), true); + assert.deepEqual(timers, []); + assert.deepEqual(viewer._test.cameraLooks, []); + assert.equal(layer.getPlaybackState().playing, false); + } finally { + await manager.destroyLayer(layer.id); + globalThis.document = originalDocument; + globalThis.window = originalWindow; + } +}); + +test('Bhote Koshi standalone Scene keeps its Esri cinematic contract', async () => { + const event = JSON.parse(await readFile(eventUrl, 'utf8')); + const originalDocument = globalThis.document; + const originalWindow = globalThis.window; + const originalPerformance = globalThis.performance; + const originalRequestAnimationFrame = globalThis.requestAnimationFrame; + const originalCancelAnimationFrame = globalThis.cancelAnimationFrame; + installEventDom(); + const timers = []; + globalThis.window = { + setTimeout(handler, delay) { + timers.push({ handler, delay }); + return timers.length; + }, + clearTimeout() {}, + }; + globalThis.performance = { now: () => 1000 }; + globalThis.requestAnimationFrame = () => 44; + globalThis.cancelAnimationFrame = () => {}; + const viewer = eventViewer(); + let activeMap = 'photoreal'; + const mapCalls = []; + const layer = createBhoteKoshiEventLayer({ + eventLoader: async () => event, + imageryProviderFactory: async (url) => ({ url }), + terrainSampler: async (_viewer, observations) => ( + observations.map((observation) => ({ height: observation.fallbackElevationM })) + ), + mediaLoader: async () => { + const error = new Error('fixture media intentionally unavailable'); + error.name = 'AbortError'; + throw error; + }, + overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} }, + renderHost: { request() {}, hold() {}, release() {} }, + }); + layer.attachMapStackController({ + getActiveId: () => activeMap, + getSwitchGeneration: () => mapCalls.length, + async setStack(id) { + activeMap = id; + mapCalls.push(id); + }, + }); + + try { + await layer.enable(viewer, { origin: 'scene' }); + assert.deepEqual(mapCalls, ['esri-imagery']); + assert.equal(viewer._test.imagery.length, 2); + assert.equal(timers.length, 1); + assert.equal(timers[0].delay, 2400); + timers[0].handler(); + assert.equal(layer.getPlaybackState().playing, true); + assert.equal(layer.getCinematicState().active, true); + assert.equal(viewer._test.cameraLooks.length, 1); + await layer.disable(); + assert.deepEqual(mapCalls, ['esri-imagery', 'photoreal']); + } finally { + globalThis.document = originalDocument; + globalThis.window = originalWindow; + globalThis.performance = originalPerformance; + globalThis.requestAnimationFrame = originalRequestAnimationFrame; + globalThis.cancelAnimationFrame = originalCancelAnimationFrame; + } +}); + +test('Bhote Koshi scene beats preserve photoreal and never take camera ownership', async () => { + const event = JSON.parse(await readFile(eventUrl, 'utf8')); + const originalDocument = globalThis.document; + const originalWindow = globalThis.window; + const originalPerformance = globalThis.performance; + const originalRequestAnimationFrame = globalThis.requestAnimationFrame; + const originalCancelAnimationFrame = globalThis.cancelAnimationFrame; + const { body } = installEventDom(); + const timers = []; + const openedUrls = []; + const cancelledFrames = []; + globalThis.window = { + setTimeout(handler, delay) { + timers.push({ handler, delay }); + return timers.length; + }, + clearTimeout() {}, + open(url) { + openedUrls.push(url); + return { opener: 'fixture' }; + }, + }; + globalThis.performance = { now: () => 1000 }; + globalThis.requestAnimationFrame = () => 91; + globalThis.cancelAnimationFrame = (id) => cancelledFrames.push(id); + const viewer = eventViewer(); + let activeMap = 'photoreal'; + const mapCalls = []; + const overlayPublications = []; + const overlayVisibility = []; + const layer = createBhoteKoshiEventLayer({ + eventLoader: async () => event, + imageryProviderFactory: async (url) => ({ url }), + terrainSampler: async (_viewer, observations) => ( + observations.map((observation) => ({ height: observation.fallbackElevationM })) + ), + mediaLoader: async () => { + const error = new Error('fixture media intentionally unavailable'); + error.name = 'AbortError'; + throw error; + }, + overlayHost: { + setEntries(id, entries, options) { + overlayPublications.push({ id, entries, options }); + }, + setVisible(id, visible) { + overlayVisibility.push({ id, visible }); + }, + clearSource() {}, + }, + renderHost: { request() {}, hold() {}, release() {} }, + }); + layer.attachMapStackController({ + getActiveId: () => activeMap, + getSwitchGeneration: () => 1, + async setStack(id) { + activeMap = id; + mapCalls.push(id); + }, + }); + const sceneCalls = []; + let finishScene; + let publishSceneClock; + layer.attachSceneController({ + subscribeSceneClock(listener) { + publishSceneClock = listener; + return () => { publishSceneClock = null; }; + }, + continueScene(sceneId, shotId) { + sceneCalls.push({ action: 'scene', sceneId, shotId }); + return new Promise((resolve) => { finishScene = resolve; }); + }, + replayShot(sceneId, shotId) { + sceneCalls.push({ action: 'play', sceneId, shotId }); + return true; + }, + loadAdjacentShot(sceneId, shotId, direction) { + sceneCalls.push({ action: 'adjacent', sceneId, shotId, direction }); + return true; + }, + seekScene(sceneId, progress) { + sceneCalls.push({ action: 'seek', sceneId, progress }); + return true; + }, + }); + + try { + assert.equal(layer.setParams({ + presentation: 'scene-beat', + sceneSurface: 'panel-only', + progress: 0.34, + split: 0.62, + cinematic: true, + autoPlay: true, + sceneContext: { + sceneId: 'nepal-scene', + sceneTitle: 'Nepal Flood Incident', + shotId: 'global-context', + shotTitle: 'Global Incident Context', + shotIndex: 0, + shotCount: 14, + durationSec: 4, + holdSec: 0.9, + sceneElapsedSec: 0, + sceneDurationSec: 120, + sceneProgress: 0, + }, + }, { origin: 'scene' }), true); + await layer.enable(viewer, { origin: 'scene' }); + assert.deepEqual(mapCalls, []); + assert.equal(activeMap, 'photoreal'); + assert.deepEqual(timers, []); + assert.equal(viewer._test.imagery.length, 2, 'the event surface keeps its cached imagery pair'); + assert.ok(viewer._test.imagery.every((imageryLayer) => imageryLayer.show === false)); + const panel = body.children.find((element) => element.tagName === 'aside'); + assert.ok(panel); + const splitLine = body.children.find((element) => element.id === 'bhote-koshi-split-line'); + assert.ok(splitLine); + assert.equal(splitLine.hidden, true); + assert.equal(panel.querySelector('[data-role="imagery-comparison"]').hidden, true); + assert.match(panel.querySelector('[data-role="caveat"]').textContent, /SCHEMATIC CORRIDOR/i); + assert.doesNotMatch(panel.querySelector('[data-role="caveat"]').textContent, /historical/i); + assert.equal(panel.querySelector('[data-role="imagery-cloud-note"]').hidden, true); + const sceneClock = panel.querySelector('[data-role="progress"]'); + assert.equal(sceneClock.hidden, false); + assert.equal(sceneClock.disabled, false); + assert.equal(sceneClock.getAttribute('aria-label'), 'Seek Nepal scene clock'); + assert.equal(panel.querySelector('[data-role="timeline-label"]').textContent, 'NEPAL SCENE CLOCK'); + assert.equal(panel.querySelector('[data-role="time"]').textContent, '~0:00 / 2:00'); + publishSceneClock({ + sceneId: 'nepal-scene', + shotId: 'global-context', + shotIndex: 0, + shotCount: 14, + sceneElapsedSec: 30, + sceneDurationSec: 120, + sceneProgress: 0.25, + running: true, + }); + assert.equal(sceneClock.value, '250'); + assert.equal(panel.querySelector('[data-role="time"]').textContent, '~0:30 / 2:00'); + assert.equal(panel.querySelector('[data-role="beat-index"]').textContent, '01 / 14'); + assert.equal(panel.querySelector('[data-role="beat-title"]').textContent, 'Global Incident Context'); + assert.equal(panel.querySelector('[data-action="play"]').textContent, '▶ PLAY SHOT'); + const playScene = panel.querySelector('[data-action="play-scene"]'); + assert.equal(playScene.hidden, false); + assert.equal(playScene.disabled, false); + assert.equal(playScene.textContent, '▶ PLAY SCENE'); + assert.equal(playScene.getAttribute('aria-label'), 'Continue Nepal Flood Incident from next shot'); + assert.equal(panel.querySelector('[data-action="cinematic"]').hidden, true); + assert.equal(panel.querySelector('[data-action="cinematic"]').disabled, true); + assert.equal(panel.querySelector('[data-action="corridor"]').hidden, false); + assert.equal(panel.querySelector('[data-action="corridor"]').disabled, false); + assert.doesNotMatch(panel.innerHTML, /data-action="witness"|◎ WITNESS/); + assert.match(panel.innerHTML, /data-action="open-source"/); + const fullStory = panel.querySelector('[data-action="story-replay"]'); + assert.equal(fullStory.textContent, '↺ FULL STORY'); + assert.equal(fullStory.hidden, true, 'PLAY SCENE owns authored scene playback'); + assert.equal(fullStory.disabled, true); + assert.equal(panel.querySelector('[data-action="open-source"]').hidden, true); + assert.deepEqual(layer.getParams(), { + presentation: 'scene-beat', + sceneSurface: 'panel-only', + progress: 0.34, + split: 0.62, + }); + assert.equal(viewer.scene.splitPosition, 0.62); + layer.setParams({ + presentation: 'scene-beat', + sceneSurface: 'evidence-beat', + beatId: 'timure-cluster', + sceneControls: { imageryComparison: true }, + }, { origin: 'scene' }); + assert.equal(splitLine.hidden, false); + assert.equal(panel.querySelector('[data-role="imagery-comparison"]').hidden, false); + assert.ok(viewer._test.imagery.every((imageryLayer) => imageryLayer.show === true)); + const splitHandle = splitLine.children.find( + (element) => element.className === 'bhote-koshi-split-handle', + ); + assert.ok(splitHandle); + assert.equal(splitHandle.textContent, '↔'); + assert.equal(splitHandle.getAttribute('aria-valuenow'), '62'); + splitHandle.dispatch('pointerdown', { + button: 0, + clientX: 250, + pointerId: 4, + preventDefault() {}, + }); + splitHandle.dispatch('pointermove', { + clientX: 750, + pointerId: 4, + preventDefault() {}, + }); + splitHandle.dispatch('pointerup', { pointerId: 4 }); + assert.equal(viewer.scene.splitPosition, 0.75); + assert.equal(splitHandle.getAttribute('aria-valuenow'), '75'); + splitHandle.dispatch('keydown', { + key: 'ArrowLeft', + shiftKey: true, + preventDefault() {}, + }); + assert.equal(viewer.scene.splitPosition, 0.7); + layer.setParams({ + presentation: 'scene-beat', + sceneSurface: 'panel-only', + progress: 0.34, + split: 0.62, + sceneControls: {}, + }, { origin: 'scene' }); + assert.equal(splitLine.hidden, true); + assert.ok(viewer._test.imagery.every((imageryLayer) => imageryLayer.show === false)); + assert.equal(viewer.scene.splitPosition, 0.62); + assert.equal(overlayVisibility.at(-1).visible, false); + for (const id of [ + 'bhote-koshi-flood-halo', + 'bhote-koshi-flood-core', + 'bhote-koshi-surge-front', + ]) { + assert.equal(viewer._test.dataSources[0].entities.getById(id).show, false); + } + panel.querySelector('[data-action="play"]').dispatch('click'); + await Promise.resolve(); + panel.querySelector('[data-action="next-beat"]').dispatch('click'); + await Promise.resolve(); + assert.deepEqual(sceneCalls, [ + { action: 'play', sceneId: 'nepal-scene', shotId: 'global-context' }, + { action: 'adjacent', sceneId: 'nepal-scene', shotId: 'global-context', direction: 1 }, + ]); + const run = playScene.dispatch('click'); + assert.equal(playScene.disabled, true); + assert.equal(playScene.textContent, '… PLAYING SCENE'); + assert.equal(panel.querySelector('[data-action="play"]').disabled, true); + assert.equal(panel.querySelector('[data-action="play"]').textContent, '▶ PLAY SHOT'); + await playScene.dispatch('click'); + assert.deepEqual(sceneCalls.at(-1), { + action: 'scene', sceneId: 'nepal-scene', shotId: 'global-context', + }); + assert.equal(sceneCalls.length, 3, 'duplicate clicks cannot start another scene run'); + finishScene({ started: true }); + await run; + assert.equal(playScene.disabled, false); + assert.equal(playScene.textContent, '▶ PLAY SCENE'); + sceneClock.value = '500'; + sceneClock.dispatch('input'); + assert.equal(panel.querySelector('[data-role="time"]').textContent, '~1:00 / 2:00'); + sceneClock.dispatch('change'); + await Promise.resolve(); + assert.deepEqual(sceneCalls.at(-1), { action: 'seek', sceneId: 'nepal-scene', progress: 0.5 }); + assert.equal(sceneCalls.length, 4); + layer.attachSceneController({ continueScene: async () => ({ started: false, reason: 'already-running' }) }); + assert.deepEqual(await playScene.dispatch('click'), { started: false, reason: 'already-running' }); + assert.equal(playScene.disabled, false, 'a refused start releases the button'); + layer.attachSceneController({ continueScene: async () => { throw new Error('test scene failure'); } }); + const originalWarn = console.warn; + const warnings = []; + try { + console.warn = (...args) => warnings.push(args); + assert.equal(await playScene.dispatch('click'), false); + assert.equal(playScene.disabled, false, 'a rejected start releases the button'); + assert.equal(warnings.length, 1); + } finally { + console.warn = originalWarn; + } + layer.attachSceneController(null); + assert.equal(playScene.disabled, true); + await playScene.dispatch('click'); + assert.equal(sceneCalls.length, 4); + assert.equal(layer.getPlaybackState().playing, false); + for (const reportButton of panel.querySelector('.bhote-event-report-list').children) { + reportButton.dispatch('click'); + } + panel.querySelector('[data-action="geolocation-map"]').dispatch('click'); + assert.deepEqual(openedUrls, [ + ...event.fieldReports.map(({ url }) => url), + event.geolocationMap.sourceUrl, + ]); + assert.equal(layer.getPlaybackState().playing, false); + assert.equal(layer.getCinematicState().active, false); + assert.equal(viewer._test.cameraLooks.length, 0); + assert.deepEqual(mapCalls, []); + assert.equal(body.children.filter((element) => element.tagName === 'aside').length, 1); + assert.equal(viewer.scene.splitPosition, 0.62); + for (const beatId of [ + 'debris-dammed-lake', + 'gyirong-border-gate', + 'timure-cluster', + 'syabru-besi', + 'bidur-trishuli-bridge', + ]) { + layer.setParams({ + presentation: 'scene-beat', + sceneSurface: 'evidence-beat', + beatId, + beatReveal: 0.36, + }, { origin: 'scene' }); + assert.equal(layer.getParams().beatId, beatId); + assert.equal(layer.getParams().sceneSurface, 'evidence-beat'); + assert.equal(panel.querySelector('[data-action="open-source"]').hidden, false); + assert.equal(overlayVisibility.at(-1).visible, true); + const entries = overlayPublications.at(-1).entries; + const visibleCards = entries.filter((entry) => ( + entry.id.startsWith('evidence-card-') && entry.position() != null + )); + assert.deepEqual(visibleCards.map(({ id }) => id), [`evidence-card-${beatId}`]); + assert.ok(entries + .filter((entry) => entry.id.startsWith('evidence-breadcrumb-')) + .every((entry) => entry.position() == null)); + assert.equal(viewer._test.dataSources.filter((source) => ( + !viewer._test.removedDataSources.includes(source) + )).length, 1, 'surface transitions leave exactly one active event surface'); + assert.equal(viewer._test.cameraLooks.length, 0, 'consecutive beats never write the camera'); + } + panel.querySelector('[data-action="open-source"]').dispatch('click'); + assert.equal(openedUrls.at(-1), event.evidenceSpine.find(({ id }) => id === 'bidur-trishuli-bridge').media.sourceUrl); + layer.setParams({ + presentation: 'scene-beat', + progress: 0.2, + }, { origin: 'scene' }); + assert.equal(layer.getParams().sceneSurface, 'panel-only', 'missing ownership fails closed'); + assert.equal(panel.querySelector('[data-action="open-source"]').hidden, true); + assert.equal(overlayVisibility.at(-1).visible, false); + await layer.disable(); + assert.deepEqual( + cancelledFrames, + [91], + 'disable revokes the pending real-time scene-clock scrub frame', + ); + assert.deepEqual(mapCalls, []); + assert.equal(viewer._test.removedDataSources.length, viewer._test.dataSources.length); + } finally { + globalThis.document = originalDocument; + globalThis.window = originalWindow; + globalThis.performance = originalPerformance; + globalThis.requestAnimationFrame = originalRequestAnimationFrame; + globalThis.cancelAnimationFrame = originalCancelAnimationFrame; + } +}); + +test('Nepal hybrid comparison preserves native split and terrain-clamped flood trail', async () => { + const event = JSON.parse(await readFile(eventUrl, 'utf8')); + const originalDocument = globalThis.document; + const originalWindow = globalThis.window; + installEventDom(); + globalThis.window = { setTimeout: () => 71, clearTimeout() {} }; + const viewer = eventViewer(); + const mapCalls = []; + const comparisonCalls = []; + const layer = createBhoteKoshiEventLayer({ + eventLoader: async () => event, + imageryProviderFactory: async (url) => ({ url }), + terrainSampler: async (_viewer, observations) => ( + observations.map((observation) => ({ height: observation.fallbackElevationM })) + ), + mediaLoader: async () => { + const error = new Error('fixture media intentionally unavailable'); + error.name = 'AbortError'; + throw error; + }, + overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} }, + renderHost: { request() {}, hold() {}, release() {} }, + }); + layer.attachMapStackController({ + getActiveId: () => 'photoreal', + getSwitchGeneration: () => 1, + async setStack(id) { mapCalls.push(id); }, + async setTerrainComparison(rectangle) { comparisonCalls.push(rectangle); return true; }, + clearTerrainComparison() { comparisonCalls.push(null); }, + }); + + try { + layer.setParams({ + presentation: 'scene-beat', + sceneSurface: 'evidence-beat', + beatId: 'gyirong-border-gate', + sceneControls: { imageryComparison: true }, + }, { origin: 'scene' }); + await layer.enable(viewer, { origin: 'scene' }); + + assert.deepEqual(mapCalls, [], 'comparison does not switch the authored Google base'); + assert.deepEqual(comparisonCalls, [event.imagery.rectangle]); + assert.equal(viewer._test.imagery.length, 2); + assert.ok(viewer._test.imagery.every((imageryLayer) => imageryLayer.show === true)); + assert.equal(viewer.scene.splitPosition, 0.5); + assert.equal(viewer._test.imagery[0].splitDirection, Cesium.SplitDirection.LEFT); + assert.equal(viewer._test.imagery[1].splitDirection, Cesium.SplitDirection.RIGHT); + layer.setParams({ split: 0.25 }); + assert.equal(viewer.scene.splitPosition, 0.25); + const flood = viewer._test.dataSources[0].entities.getById('bhote-koshi-flood-core'); + const surge = viewer._test.dataSources[0].entities.getById('bhote-koshi-surge-front'); + assert.equal(flood.polyline.clampToGround.getValue(), true); + assert.equal( + surge.point.heightReference.getValue(), + Cesium.HeightReference.CLAMP_TO_GROUND, + ); + + layer.setParams({ + presentation: 'scene-beat', + sceneSurface: 'evidence-beat', + beatId: 'timure', + sceneControls: { imageryComparison: false }, + }, { origin: 'scene' }); + assert.ok(viewer._test.imagery.every((imageryLayer) => imageryLayer.show === false)); + assert.equal(comparisonCalls.at(-1), null); + + const tileFlood = viewer._test.dataSources.at(-1).entities.getById('bhote-koshi-flood-core'); + assert.equal(tileFlood.polyline.clampToGround.getValue(), true); + assert.equal(tileFlood.polyline.classificationType.getValue(), Cesium.ClassificationType.BOTH); + + await layer.disable(); + assert.equal(viewer._test.removedImagery.length, 2); + } finally { + globalThis.document = originalDocument; + globalThis.window = originalWindow; + } +}); + +test('Bhote Koshi event pack keeps observations separate from reconstruction', async () => { + const event = JSON.parse(await readFile(eventUrl, 'utf8')); + assert.equal(BHOTE_KOSHI_LAYER_ID, event.id); + assert.equal(event.imagery.before.observedAt, '2021-10-16'); + assert.equal(event.imagery.after.observedAt, '2026-08-27'); + assert.equal( + event.reconstruction.label, + 'SCHEMATIC DOWNSTREAM PROGRESSION, NOT MODELED ARRIVAL TIME', + ); + assert.match(event.imagery.before.label, /^2021 HISTORICAL REFERENCE/); + assert.match(event.imagery.after.label, /^2026 POST-EVENT/); + assert.equal( + event.geolocationMap.sourceUrl, + 'https://x.com/geogeorgeology/status/2093632283442053371', + ); + assert.ok(event.reconstruction.corridor.length >= 100); + assert.equal(event.witnessAnchors.length, 1); + assert.ok(Number.isFinite(event.witnessAnchors[0].elevationM)); + assert.match(event.witnessAnchors[0].title, /SOURCE GEO/); + assert.equal(event.viewpoints.witness.imagery, 'before'); + assert.equal( + event.fieldReports.filter((report) => report.status.includes('WORLD-PINNED')).length, + 1, + ); + assert.ok(event.fieldReports.some((report) => report.status.includes('UNVERIFIED'))); + assert.equal(event.evidenceSpine.length, 16); + assert.equal(new Set(event.evidenceSpine.map(({ id }) => id)).size, 16); + assert.deepEqual(event.evidenceSpine.map(({ sequence }) => sequence), + Array.from({ length: 16 }, (_, index) => index + 1)); + assert.equal(floodProgressForStoryProgress(0.195, event.reconstruction.storyFloodWindow), 0); + assert.equal(floodProgressForStoryProgress(0.39, event.reconstruction.storyFloodWindow), 1); + assert.equal(floodProgressForStoryProgress(0.9, event.reconstruction.storyFloodWindow), 1); + for (const { media } of event.evidenceSpine) { + for (const asset of [media.videoPath, media.posterPath].filter(Boolean)) { + assert.match(asset, /^evidence-\d+\.(mp4|jpg)$/); + assert.ok((await readFile(new URL(asset, eventUrl))).length > 0, asset); + } + } + assert.ok(event.evidenceSpine.every((observation) => observation.timing.capturedAt === null)); + assert.ok(event.evidenceSpine.every((observation) => ( + observation.timing.note === 'capture time unverified' + ))); + assert.ok(event.evidenceSpine.every((observation) => /^https:\/\//.test(observation.media.sourceUrl))); + assert.ok(event.evidenceSpine.filter((observation) => observation.media.posterUrl).every((observation) => ( + observation.media.posterUrl.startsWith('https://i.ytimg.com/') + ))); + assert.equal( + event.evidenceSpine.find(({ id }) => id === 'gyirong-border-gate').media.clipInSec, + 26, + ); + assert.ok(event.evidenceSpine.every(({ media }) => !media.videoPath && !media.posterPath), + 'Public event records must not reference privately cached social clips'); + assert.equal(event.evidenceSpine.filter((observation) => ( + observation.imageryCoverage === 'outside' + )).length, 13); + const timure = event.evidenceSpine.find((observation) => observation.id === 'timure-cluster'); + assert.equal(timure.corroboration.kind, 'multi-source geolocation cluster'); + assert.equal(timure.corroboration.sourceCount, 5); + assert.equal(timure.corroboration.displayedSourceCount, 1); + assert.equal(evidenceCorroborationLabel(timure), '5 GEOLOCATED SOURCE-MAP PLACEMENTS'); + assert.match(event.reconstruction.caveat, /2021 historical reference versus 2026 post-event/i); + assert.match(event.reconstruction.caveat, /not modeled arrival time/i); + assert.match(event.reconstruction.caveat, /2025 Rasuwagadhi flood/i); + assert.equal(SCENE_RECIPES.some((recipe) => recipe.id === 'bhote-koshi-flood'), false); + assert.ok(SCENE_RECIPES.some((recipe) => recipe.id === 'bhote-koshi-nepal-scene')); +}); + +test('Bhote Koshi publishes the evidence spine once and scrubs through cards and breadcrumbs', async () => { + const event = JSON.parse(await readFile(eventUrl, 'utf8')); + const originalDocument = globalThis.document; + const originalWindow = globalThis.window; + const { body } = installEventDom(); + globalThis.window = { setTimeout: () => 71, clearTimeout() {} }; + const viewer = eventViewer(); + const publications = []; + const layer = createBhoteKoshiEventLayer({ + eventLoader: async () => event, + imageryProviderFactory: async (url) => ({ url }), + terrainSampler: async (_viewer, observations) => ( + observations.map((observation) => ({ height: observation.fallbackElevationM })) + ), + mediaLoader: async () => { + const error = new Error('fixture media intentionally unavailable'); + error.name = 'AbortError'; + throw error; + }, + evidenceFrameFactory: (observation) => ({ fixture: observation.id }), + overlayHost: { + setEntries(id, entries, options) { publications.push({ id, entries, options }); }, + setVisible() {}, + clearSource() {}, + }, + renderHost: { request() {}, hold() {}, release() {} }, + }); + layer.attachMapStackController({ + getActiveId: () => 'esri-imagery', + getSwitchGeneration: () => 1, + async setStack() {}, + }); + + try { + await layer.enable(viewer, { origin: 'local-restore' }); + assert.equal(publications.length, 1); + assert.equal(publications[0].entries.length, 32); + const cards = publications[0].entries.filter((entry) => entry.variant === 'thumbnail'); + const breadcrumbs = publications[0].entries.filter((entry) => entry.variant === 'label'); + assert.equal(cards.filter((entry) => entry.position()).length, 1); + assert.match(cards.find((entry) => entry.position()).id, /immediate-collapse/); + assert.equal(breadcrumbs.filter((entry) => entry.position()).length, 0); + assert.ok(cards.every((entry) => entry.requireImage && entry.image.stamp === 1)); + + const panel = body.children.find((element) => element.tagName === 'aside'); + const progress = panel.querySelector('[data-role="progress"]'); + progress.value = '285'; + progress.dispatch('input'); + + assert.equal(publications.length, 1); + assert.equal(cards.filter((entry) => entry.position()).length, 1); + assert.match(cards.find((entry) => entry.position()).id, /timure-cluster/); + assert.equal(breadcrumbs.filter((entry) => entry.position()).length, 4); + await layer.disable(); + } finally { + globalThis.document = originalDocument; + globalThis.window = originalWindow; + } +}); + +test('Bhote Koshi keeps one local fallback video active and unloads it on beat change and disable', async () => { + const event = JSON.parse(await readFile(eventUrl, 'utf8')); + // Synthetic clips exercise the player; public event data never bundles these. + event.evidenceSpine.find(({ id }) => id === 'immediate-collapse-viewpoint').media.videoPath = 'evidence-01.mp4'; + event.evidenceSpine.find(({ id }) => id === 'timure-cluster').media.videoPath = 'evidence-04.mp4'; + event.evidenceSpine.find(({ id }) => id === 'immediate-collapse-viewpoint').media.sourceUrl = + 'https://example.com/immediate-collapse'; + event.evidenceSpine.find(({ id }) => id === 'timure-cluster').media.sourceUrl = + 'https://example.com/timure'; + const originalDocument = globalThis.document; + const originalWindow = globalThis.window; + const { body } = installEventDom(); + globalThis.window = { setTimeout: () => 71, clearTimeout() {} }; + const viewer = eventViewer(); + const videos = []; + const videoFactory = () => { + const listeners = new Map(); + const video = { + muted: false, + playsInline: false, + preload: '', + loop: false, + paused: true, + currentTime: 0, + duration: 10, + playbackRate: 1, + pauseCalls: 0, + playCalls: 0, + loadCalls: 0, + removedSources: 0, + addEventListener(type, handler) { listeners.set(type, handler); }, + load() { this.loadCalls += 1; }, + pause() { this.pauseCalls += 1; this.paused = true; }, + play() { this.playCalls += 1; this.paused = false; return Promise.resolve(); }, + removeAttribute(name) { + if (name === 'src') { + this.src = ''; + this.removedSources += 1; + } + }, + dispatch(type) { listeners.get(type)?.(); }, + }; + videos.push(video); + return video; + }; + const layer = createBhoteKoshiEventLayer({ + eventLoader: async () => event, + imageryProviderFactory: async (url) => ({ url }), + terrainSampler: async (_viewer, observations) => ( + observations.map((observation) => ({ height: observation.fallbackElevationM })) + ), + mediaLoader: async () => { + const error = new Error('fixture media intentionally unavailable'); + error.name = 'AbortError'; + throw error; + }, + evidenceFrameFactory: (observation) => ({ fixture: observation.id }), + evidenceFrameUpdater: () => true, + videoFactory, + overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} }, + renderHost: { request() {}, hold() {}, release() {} }, + }); + layer.attachMapStackController({ + getActiveId: () => 'esri-imagery', + getSwitchGeneration: () => 1, + async setStack() {}, + }); + + try { + await layer.enable(viewer, { origin: 'local-restore' }); + assert.equal(videos.length, 1); + assert.match(videos[0].src, /evidence-01\.mp4$/); + videos[0].dispatch('loadeddata'); + assert.equal(layer.getCinematicState().mediaReady, true); + + const panel = body.children.find((element) => element.tagName === 'aside'); + const progress = panel.querySelector('[data-role="progress"]'); + progress.value = '285'; + progress.dispatch('input'); + + assert.equal(videos.length, 2, 'the new beat replaces rather than stacks a decoder'); + assert.equal(videos[0].removedSources, 1); + assert.ok(videos[0].pauseCalls >= 1); + assert.match(videos[1].src, /evidence-04\.mp4$/); + + await layer.disable(); + assert.equal(videos[1].removedSources, 1); + assert.ok(videos[1].pauseCalls >= 1); + assert.equal(layer.getCinematicState().mediaEvidenceId, null); + } finally { + globalThis.document = originalDocument; + globalThis.window = originalWindow; + } +}); + +test('Upper Valley Collapse autoplays its muted local fallback without starting the event clock', async () => { + const event = JSON.parse(await readFile(eventUrl, 'utf8')); + event.evidenceSpine.find(({ id }) => id === 'immediate-collapse-viewpoint').media.videoPath = 'evidence-01.mp4'; + event.evidenceSpine.find(({ id }) => id === 'immediate-collapse-viewpoint').media.sourceUrl = + 'https://example.com/immediate-collapse'; + const originalDocument = globalThis.document; + const originalWindow = globalThis.window; + const { body } = installEventDom(); + globalThis.window = { setTimeout: () => 71, clearTimeout() {} }; + const viewer = eventViewer(); + const videos = []; + let frameUpdates = 0; + const videoFactory = () => { + const listeners = new Map(); + const frameCallbacks = new Map(); + const video = { + muted: false, + playsInline: false, + preload: '', + loop: false, + paused: true, + currentTime: 0, + duration: 8.6, + playbackRate: 1, + playCalls: 0, + pauseCalls: 0, + cancelledFrameHandles: [], + addEventListener(type, handler) { listeners.set(type, handler); }, + load() {}, + pause() { this.pauseCalls += 1; this.paused = true; }, + play() { this.playCalls += 1; this.paused = false; return Promise.resolve(); }, + removeAttribute(name) { if (name === 'src') this.src = ''; }, + requestVideoFrameCallback(callback) { + const handle = frameCallbacks.size + 1; + frameCallbacks.set(handle, callback); + return handle; + }, + cancelVideoFrameCallback(handle) { + this.cancelledFrameHandles.push(handle); + frameCallbacks.delete(handle); + }, + dispatch(type) { listeners.get(type)?.(); }, + drawFrame(handle = [...frameCallbacks.keys()][0]) { + const callback = frameCallbacks.get(handle); + frameCallbacks.delete(handle); + callback?.(); + }, + }; + videos.push(video); + return video; + }; + const layer = createBhoteKoshiEventLayer({ + eventLoader: async () => event, + imageryProviderFactory: async (url) => ({ url }), + terrainSampler: async (_viewer, observations) => ( + observations.map((observation) => ({ height: observation.fallbackElevationM })) + ), + mediaLoader: async () => { + const error = new Error('fixture media intentionally unavailable'); + error.name = 'AbortError'; + throw error; + }, + evidenceFrameFactory: (observation) => ({ fixture: observation.id }), + evidenceFrameUpdater: () => { frameUpdates += 1; return true; }, + videoFactory, + overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} }, + renderHost: { request() {}, hold() {}, release() {} }, + }); + layer.attachMapStackController({ + getActiveId: () => 'photoreal', + getSwitchGeneration: () => 1, + async setStack() {}, + }); + + try { + layer.setParams({ + presentation: 'scene-beat', + sceneSurface: 'evidence-beat', + sceneControls: { evidenceMediaAutoplay: true }, + beatId: 'immediate-collapse-viewpoint', + beatReveal: 0.36, + }, { origin: 'scene' }); + await layer.enable(viewer, { origin: 'scene' }); + assert.equal(videos.length, 1); + const video = videos[0]; + assert.equal(video.muted, true); + assert.equal(video.loop, true); + assert.equal(video.playCalls, 0, 'play waits until decoded media is ready'); + video.dispatch('loadeddata'); + assert.equal(video.playCalls, 1); + assert.equal(video.playbackRate, 1); + assert.equal(layer.getPlaybackState().playing, false); + const updatesBeforeFrame = frameUpdates; + video.currentTime = 1.2; + video.drawFrame(); + assert.ok(frameUpdates > updatesBeforeFrame, 'decoded frames repaint the anchored callout'); + + layer.setParams({ presentation: 'scene-beat', progress: 0.2 }, { origin: 'scene' }); + assert.ok(video.pauseCalls >= 1); + assert.ok(video.cancelledFrameHandles.length >= 1); + assert.equal(layer.getCinematicState().mediaEvidenceId, null); + assert.equal(body.children.find((element) => element.tagName === 'aside')?.removed, false); + await layer.disable(); + } finally { + globalThis.document = originalDocument; + globalThis.window = originalWindow; + } +}); + +test('Debris-Dammed Lake card opens its source and is armed for approved local video autoplay', async () => { + const event = JSON.parse(await readFile(eventUrl, 'utf8')); + const originalDocument = globalThis.document; + const originalWindow = globalThis.window; + installEventDom(); + const opened = []; + globalThis.window = { + setTimeout: () => 71, + clearTimeout() {}, + open(...args) { + opened.push(args); + return { opener: 'fixture' }; + }, + }; + const viewer = eventViewer(); + let activeCard = null; + const layer = createBhoteKoshiEventLayer({ + eventLoader: async () => event, + imageryProviderFactory: async (url) => ({ url }), + terrainSampler: async (_viewer, observations) => ( + observations.map((observation) => ({ height: observation.fallbackElevationM })) + ), + mediaLoader: async () => { + const error = new Error('fixture media intentionally unavailable'); + error.name = 'AbortError'; + throw error; + }, + overlayHost: { + setEntries(_id, entries) { + activeCard = entries.find((entry) => entry.id === 'evidence-card-debris-dammed-lake'); + }, + setVisible() {}, + clearSource() {}, + hitTest(_x, _y, options) { + if (!activeCard || options?.filter?.(activeCard) === false) return null; + return { entry: activeCard }; + }, + }, + renderHost: { request() {}, hold() {}, release() {} }, + }); + layer.attachMapStackController({ + getActiveId: () => 'photoreal', + getSwitchGeneration: () => 1, + async setStack() {}, + }); + + try { + layer.setParams({ + presentation: 'scene-beat', + sceneSurface: 'evidence-beat', + sceneControls: { evidenceMediaAutoplay: true }, + beatId: 'debris-dammed-lake', + beatReveal: 0.36, + }, { origin: 'scene' }); + await layer.enable(viewer, { origin: 'scene' }); + const lease = claimPointer('draw'); + try { + viewer._test.canvas.dispatch('click', { offsetX: 640, offsetY: 420 }); + assert.equal(opened.length, 0, 'drawing owns the pointer over evidence cards'); + } finally { + releasePointer(lease); + } + viewer._test.canvas.dispatch('click', { offsetX: 640, offsetY: 420 }); + assert.deepEqual(opened, [[ + event.evidenceSpine[1].media.sourceUrl, + '_blank', + 'noopener,noreferrer', + ]]); + assert.equal(opened.length, 1); + await layer.disable(); + viewer._test.canvas.dispatch('click', { offsetX: 640, offsetY: 420 }); + assert.equal(opened.length, 1, 'disabled layer releases card click ownership'); + } finally { + globalThis.document = originalDocument; + globalThis.window = originalWindow; + } +}); + +test('Nepal evidence shots resume the standalone callout and flood sequence from Border Gate', async () => { + const event = JSON.parse(await readFile(eventUrl, 'utf8')); + const timeline = buildEvidenceTimeline(event.evidenceSpine, event.reconstruction.corridor); + const originalDocument = globalThis.document; + const originalWindow = globalThis.window; + const originalPerformance = globalThis.performance; + const originalRequestAnimationFrame = globalThis.requestAnimationFrame; + const originalCancelAnimationFrame = globalThis.cancelAnimationFrame; + installEventDom(); + let now = 1000; + let nextFrameId = 0; + const frames = new Map(); + const cancelledFrames = []; + globalThis.window = { setTimeout: () => 71, clearTimeout() {} }; + globalThis.performance = { now: () => now }; + globalThis.requestAnimationFrame = (callback) => { + const id = ++nextFrameId; + frames.set(id, callback); + return id; + }; + globalThis.cancelAnimationFrame = (id) => { + cancelledFrames.push(id); + frames.delete(id); + }; + const viewer = eventViewer(); + let entries = []; + const embeddedCalls = []; + const holds = []; + const releases = []; + const layer = createBhoteKoshiEventLayer({ + eventLoader: async () => event, + imageryProviderFactory: async (url) => ({ url }), + terrainSampler: async (_viewer, observations) => ( + observations.map((observation) => ({ height: observation.fallbackElevationM })) + ), + mediaLoader: async () => { + const error = new Error('fixture media intentionally unavailable'); + error.name = 'AbortError'; + throw error; + }, + overlayHost: { + setEntries(_id, published) { entries = published; }, + setVisible() {}, + clearSource() {}, + }, + renderHost: { + request() {}, + hold(id) { holds.push(id); }, + release(id) { releases.push(id); }, + }, + embeddedMediaFactory: () => ({ + warm(options) { + embeddedCalls.push(['warm', options?.observation?.id || null]); + return Boolean(options?.observation); + }, + show(options) { + embeddedCalls.push(['show', options?.observation?.id || null]); + return false; + }, + hide() { embeddedCalls.push(['hide']); }, + destroy() {}, + }), + }); + layer.attachMapStackController({ + getActiveId: () => 'photoreal', + getSwitchGeneration: () => 1, + async setStack() {}, + }); + const params = { + presentation: 'scene-beat', + sceneSurface: 'evidence-beat', + beatId: 'gyirong-border-gate', + beatReveal: 0.36, + sceneControls: { + evidenceSequence: true, + evidenceSequenceDurationSec: 0.9, + deferEvidenceUntilCameraSettled: true, + evidenceRevealDurationSec: 0.9, + cameraSettled: false, + }, + }; + + try { + await layer.init(viewer); + layer.setParams(params, { origin: 'scene' }); + await layer.enable(viewer, { origin: 'scene' }); + const card = entries.find(({ id }) => id === 'evidence-card-gyirong-border-gate'); + const flood = viewer._test.dataSources[0].entities.getById('bhote-koshi-flood-core'); + const surge = viewer._test.dataSources[0].entities.getById('bhote-koshi-surge-front'); + assert.equal(card.position(), null, 'the evidence card is absent during the camera flight'); + assert.equal(flood.show, false); + assert.equal(surge.show, false); + assert.ok( + embeddedCalls.some(([action, id]) => action === 'warm' && id === 'gyirong-border-gate'), + 'the upcoming Border Gate provider player warms while the camera is travelling', + ); + assert.equal( + embeddedCalls.some(([action]) => action === 'show'), + false, + 'warming does not expose or autoplay the embedded card before camera settle', + ); + + layer.setParams({ + ...params, + sceneControls: { ...params.sceneControls, cameraSettled: true }, + }, { origin: 'scene' }); + assert.ok( + embeddedCalls.some(([action, id]) => action === 'show' && id === 'gyirong-border-gate'), + 'camera settle promotes the warmed source into the visible callout', + ); + assert.ok(card.position(), 'camera settle publishes the anchored card'); + assert.equal(card.sourceAlpha(), 0, 'the card starts from a hidden reveal state'); + assert.ok(holds.includes('bhote-koshi-scene-beat-reveal')); + + now = 1050; + const earlyReveal = frames.get(Math.max(...frames.keys())); + frames.delete(Math.max(...frames.keys())); + earlyReveal(now); + assert.ok(card.sourceAlpha() > 0 && card.sourceAlpha() < 1); + assert.equal(flood.show, true, 'the amber corridor grows with the callout reveal'); + assert.equal(surge.show, true, 'the amber surge circle advances with the corridor'); + + now = 1900; + const finalFrameId = Math.max(...frames.keys()); + const finalFrame = frames.get(finalFrameId); + frames.delete(finalFrameId); + finalFrame(now); + assert.equal( + layer.getPlaybackState().progress, + timeline.find(({ observation }) => observation.id === 'timure-cluster').activationProgress, + 'the Border Gate surge reaches the next evidence point', + ); + assert.equal(card.sourceAlpha(), 0, 'the Border Gate card recedes at the next beat'); + assert.ok(releases.includes('bhote-koshi-scene-beat-reveal')); + + const timureBeat = timeline.find(({ observation }) => observation.id === 'timure-cluster'); + layer.setParams({ + presentation: 'scene-beat', + sceneSurface: 'evidence-beat', + beatId: 'timure-cluster', + beatReveal: 0.36, + sceneControls: { + imageryComparison: true, + evidenceSequence: true, + evidenceSequenceDurationSec: 4, + }, + }, { origin: 'scene' }); + const timureCard = entries.find(({ id }) => id === 'evidence-card-timure-cluster'); + assert.equal(layer.getPlaybackState().progress, timureBeat.activationProgress); + assert.equal(timureCard.sourceAlpha(), 0, 'the next callout begins below its activation'); + + now = 3900; + const timureReveal = frames.get(Math.max(...frames.keys())); + frames.delete(Math.max(...frames.keys())); + timureReveal(now); + assert.ok(timureCard.sourceAlpha() > 0, 'the Timure callout enters during its authored shot'); + + now = 5900; + const timureFinalId = Math.max(...frames.keys()); + const timureFinal = frames.get(timureFinalId); + frames.delete(timureFinalId); + timureFinal(now); + assert.equal(layer.getPlaybackState().progress, timureBeat.deactivationProgress); + assert.equal(timureCard.sourceAlpha(), 0); + await layer.disable(); + } finally { + globalThis.document = originalDocument; + globalThis.window = originalWindow; + globalThis.performance = originalPerformance; + globalThis.requestAnimationFrame = originalRequestAnimationFrame; + globalThis.cancelAnimationFrame = originalCancelAnimationFrame; + } +}); + +test('Bhote Koshi beat controls keep cards, flood, camera, and panel on the one clock', async () => { + const event = JSON.parse(await readFile(eventUrl, 'utf8')); + const timeline = buildEvidenceTimeline(event.evidenceSpine, event.reconstruction.corridor); + const originalDocument = globalThis.document; + const originalWindow = globalThis.window; + const originalPerformance = globalThis.performance; + const originalRequestAnimationFrame = globalThis.requestAnimationFrame; + const originalCancelAnimationFrame = globalThis.cancelAnimationFrame; + const { body } = installEventDom(); + const openedUrls = []; + const cancelledFrames = []; + globalThis.window = { + setTimeout: () => 71, + clearTimeout() {}, + open(url) { + openedUrls.push(url); + return { opener: 'fixture' }; + }, + }; + globalThis.performance = { now: () => 1000 }; + globalThis.requestAnimationFrame = () => 91; + globalThis.cancelAnimationFrame = (id) => cancelledFrames.push(id); + const viewer = eventViewer(); + const publications = []; + const layer = createBhoteKoshiEventLayer({ + eventLoader: async () => event, + imageryProviderFactory: async (url) => ({ url }), + terrainSampler: async (_viewer, observations) => ( + observations.map((observation) => ({ height: observation.fallbackElevationM })) + ), + mediaLoader: async () => { + const error = new Error('fixture media intentionally unavailable'); + error.name = 'AbortError'; + throw error; + }, + evidenceFrameFactory: (observation) => ({ fixture: observation.id }), + overlayHost: { + setEntries(id, entries, options) { publications.push({ id, entries, options }); }, + setVisible() {}, + clearSource() {}, + }, + renderHost: { request() {}, hold() {}, release() {} }, + }); + layer.attachMapStackController({ + getActiveId: () => 'esri-imagery', + getSwitchGeneration: () => 1, + async setStack() {}, + }); + + try { + await layer.enable(viewer, { origin: 'local-restore' }); + const panel = body.children.find((element) => element.tagName === 'aside'); + assert.match(panel.innerHTML, /2021 HISTORICAL REFERENCE \/ 2026 POST-EVENT/); + assert.match(panel.innerHTML, /RECONSTRUCTION CLOCK/); + assert.match(panel.innerHTML, /OPEN PUBLIC MAP/); + const entries = publications[0].entries; + const cards = entries.filter((entry) => entry.variant === 'thumbnail'); + const breadcrumbs = entries.filter((entry) => entry.variant === 'label'); + const next = panel.querySelector('[data-action="next-beat"]'); + const previous = panel.querySelector('[data-action="previous-beat"]'); + + next.dispatch('click'); + assert.equal(layer.getPlaybackState().progress, timeline[1].activationProgress); + assert.equal(layer.getPlaybackState().playing, false); + assert.equal(layer.getCinematicState().active, true); + assert.match(cards.find((entry) => entry.position()).id, /debris-dammed-lake/); + assert.equal(breadcrumbs.filter((entry) => entry.position()).length, 1); + assert.equal(panel.querySelector('[data-role="beat-index"]').textContent, '02 / 16'); + assert.equal(panel.querySelector('[data-role="beat-title"]').textContent, 'Dammed lake'); + + next.dispatch('click'); + next.dispatch('click'); + next.dispatch('click'); + assert.equal(layer.getPlaybackState().progress, timeline[4].activationProgress); + assert.equal(layer.getCinematicState().evidenceId, 'timure-cluster'); + assert.match(cards.find((entry) => entry.position()).id, /timure-cluster/); + assert.equal(breadcrumbs.filter((entry) => entry.position()).length, 4); + assert.equal( + panel.querySelector('[data-role="beat-meta"]').textContent, + 'PROPAGATION / TIME UNVERIFIED / 5 SOURCES', + ); + const flood = viewer._test.dataSources[0].entities.getById('bhote-koshi-flood-core'); + assert.ok(flood.polyline.positions.getValue().length > 1); + assert.equal( + body.children.some((element) => element.id === 'bhote-koshi-story-strap'), + false, + ); + panel.querySelector('[data-action="open-source"]').dispatch('click'); + assert.deepEqual(openedUrls, [timeline[4].observation.media.sourceUrl]); + panel.querySelector('[data-action="geolocation-map"]').dispatch('click'); + assert.deepEqual(openedUrls, [ + timeline[4].observation.media.sourceUrl, + event.geolocationMap.sourceUrl, + ]); + + previous.dispatch('click'); + assert.equal(layer.getPlaybackState().progress, timeline[3].activationProgress); + assert.equal(layer.getCinematicState().evidenceId, 'gyirong-border-gate'); + assert.equal(panel.querySelector('[data-role="beat-index"]').textContent, '04 / 16'); + assert.equal(publications.length, 1, 'beat navigation never republishes overlay entries'); + + viewer._test.canvas.dispatch('pointerdown'); + assert.equal(layer.getCinematicState().active, false); + const cameraLookCount = viewer._test.cameraLooks.length; + const progress = panel.querySelector('[data-role="progress"]'); + progress.value = '500'; + progress.dispatch('input'); + assert.equal(layer.getCinematicState().active, false, 'manual release survives slider changes'); + assert.equal(viewer._test.cameraLooks.length, cameraLookCount); + + previous.dispatch('click'); + assert.equal(layer.getCinematicState().active, true, 'explicit beat navigation reclaims camera'); + assert.equal(layer.getCinematicState().evidenceId, 'dhunche'); + panel.querySelector('[data-action="story-replay"]').dispatch('click'); + assert.equal(layer.getPlaybackState().progress, 0); + assert.equal(layer.getPlaybackState().playing, true); + assert.equal(layer.getCinematicState().active, true); + assert.equal(layer.getCinematicState().evidenceId, 'immediate-collapse-viewpoint'); + assert.equal(panel.querySelector('[data-role="beat-index"]').textContent, '01 / 16'); + assert.equal(publications.length, 1); + + await layer.disable(); + assert.deepEqual(cancelledFrames, [91]); + assert.equal(viewer._test.canvas._listeners.size, 0); + } finally { + globalThis.document = originalDocument; + globalThis.window = originalWindow; + globalThis.performance = originalPerformance; + globalThis.requestAnimationFrame = originalRequestAnimationFrame; + globalThis.cancelAnimationFrame = originalCancelAnimationFrame; + } +}); + +test('Bhote Koshi repeated event frames perform no panel lookup, DOM write, or camera churn', async () => { + const event = JSON.parse(await readFile(eventUrl, 'utf8')); + const originalDocument = globalThis.document; + const originalWindow = globalThis.window; + const originalPerformance = globalThis.performance; + const originalRequestAnimationFrame = globalThis.requestAnimationFrame; + const originalCancelAnimationFrame = globalThis.cancelAnimationFrame; + const { body } = installEventDom(); + globalThis.window = { setTimeout: () => 71, clearTimeout() {} }; + globalThis.performance = { now: () => 1000 }; + globalThis.requestAnimationFrame = () => 93; + globalThis.cancelAnimationFrame = () => {}; + const viewer = eventViewer(); + const layer = createBhoteKoshiEventLayer({ + eventLoader: async () => event, + imageryProviderFactory: async (url) => ({ url }), + terrainSampler: async (_viewer, observations) => ( + observations.map((observation) => ({ height: observation.fallbackElevationM })) + ), + mediaLoader: async () => { + const error = new Error('fixture media intentionally unavailable'); + error.name = 'AbortError'; + throw error; + }, + overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} }, + renderHost: { request() {}, hold() {}, release() {} }, + }); + layer.attachMapStackController({ + getActiveId: () => 'esri-imagery', + getSwitchGeneration: () => 1, + async setStack() {}, + }); + + try { + await layer.enable(viewer, { origin: 'local-restore' }); + const panel = body.children.find((element) => element.tagName === 'aside'); + const cinematic = panel.querySelector('[data-action="cinematic"]'); + const progress = panel.querySelector('[data-role="progress"]'); + cinematic.dispatch('click'); + progress.value = '500'; + progress.dispatch('input'); + + const queryCount = panel._queryCount; + const writeCount = [...panel._selectors.values()].reduce((sum, element) => ( + sum + element._writeCount + ), 0); + const cameraLookCount = viewer._test.cameraLooks.length; + for (let index = 0; index < 20; index += 1) progress.dispatch('input'); + + assert.equal(panel._queryCount, queryCount, 'hot path uses cached panel references'); + assert.equal( + [...panel._selectors.values()].reduce((sum, element) => sum + element._writeCount, 0), + writeCount, + 'unchanged frames do not rewrite panel state', + ); + assert.equal( + viewer._test.cameraLooks.length, + cameraLookCount, + 'unchanged progress does not reapply the cinematic camera object', + ); + await layer.disable(); + } finally { + globalThis.document = originalDocument; + globalThis.window = originalWindow; + globalThis.performance = originalPerformance; + globalThis.requestAnimationFrame = originalRequestAnimationFrame; + globalThis.cancelAnimationFrame = originalCancelAnimationFrame; + } +}); + +test('Bhote Koshi frame path keeps evidence lookup scalar and wrapper-free', async () => { + const source = await readFile(eventModuleUrl, 'utf8'); + assert.doesNotMatch(source, /#ffd166/i, 'event UI no longer publishes the prototype yellow'); + assert.doesNotMatch( + source, + /function\s+activeEvidenceEntry\s*\(/, + 'the event frame path must not restore an object-returning evidence wrapper', + ); + assert.doesNotMatch( + source, + /return\s*\{\s*(?:index\s*,\s*observation|observation\s*,\s*index)\s*\}/, + 'evidence lookup must not allocate an index/observation pair', + ); + assert.match( + source, + /const beatIndex = currentEvidenceIndex\(\);\s*const sceneBeatIndex = sceneDirected\s*\? _evidenceTimeline\.findIndex\(\s*\(\{ observation: item \}\) => item\.id === _sceneBeatId,?\s*\)\s*:\s*beatIndex;\s*const observation =\s*_evidenceTimeline\[sceneBeatIndex\]\?\.observation \|\| null;/, + 'syncPanel must resolve authored scene beats to one scalar index before reading the observation', + ); +}); + +test('Bhote Koshi cinematic camera stays synchronized through pause and deterministic scrubbing', async () => { + const event = JSON.parse(await readFile(eventUrl, 'utf8')); + const originalDocument = globalThis.document; + const originalWindow = globalThis.window; + const originalPerformance = globalThis.performance; + const originalRequestAnimationFrame = globalThis.requestAnimationFrame; + const originalCancelAnimationFrame = globalThis.cancelAnimationFrame; + const { body } = installEventDom(); + globalThis.window = { setTimeout: () => 71, clearTimeout() {} }; + globalThis.performance = { now: () => 1000 }; + globalThis.requestAnimationFrame = () => 33; + globalThis.cancelAnimationFrame = () => {}; + const viewer = eventViewer(); + const layer = createBhoteKoshiEventLayer({ + eventLoader: async () => event, + imageryProviderFactory: async (url) => ({ url }), + terrainSampler: async (_viewer, observations) => ( + observations.map((observation) => ({ height: observation.fallbackElevationM })) + ), + mediaLoader: async () => { + const error = new Error('fixture media intentionally unavailable'); + error.name = 'AbortError'; + throw error; + }, + overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} }, + renderHost: { request() {}, hold() {}, release() {} }, + }); + layer.attachMapStackController({ + getActiveId: () => 'esri-imagery', + getSwitchGeneration: () => 1, + async setStack() {}, + }); + + try { + await layer.enable(viewer, { origin: 'local-restore' }); + const panel = body.children.find((element) => element.tagName === 'aside'); + panel.querySelector('[data-action="cinematic"]').dispatch('click'); + assert.equal(layer.getCinematicState().active, true); + assert.equal(layer.getPlaybackState().playing, true); + assert.equal(viewer._test.cameraLooks.length, 1); + assert.equal( + body.children.some((element) => element.id === 'bhote-koshi-story-strap'), + false, + ); + + panel.querySelector('[data-action="play"]').dispatch('click'); + assert.equal(layer.getPlaybackState().playing, false); + const progress = panel.querySelector('[data-role="progress"]'); + progress.value = '500'; + progress.dispatch('input'); + const firstPose = viewer._test.cameraLooks.at(-1); + progress.dispatch('input'); + assert.deepEqual(viewer._test.cameraLooks.at(-1), firstPose); + assert.deepEqual(layer.getCinematicState(), { + active: true, + progress: 0.5, + evidenceId: 'mailung-upper-trishuli', + mediaReady: false, + mediaEvidenceId: null, + }); + + viewer._test.canvas.dispatch('pointerdown'); + assert.equal(layer.getCinematicState().active, false); + assert.equal(viewer._test.cameraTransforms.length, 1); + await layer.disable(); + assert.equal(viewer._test.canvas._listeners.size, 0); + } finally { + globalThis.document = originalDocument; + globalThis.window = originalWindow; + globalThis.performance = originalPerformance; + globalThis.requestAnimationFrame = originalRequestAnimationFrame; + globalThis.cancelAnimationFrame = originalCancelAnimationFrame; + } +}); + +test('Bhote Koshi replay restores cinematic ownership unless the operator released it', async () => { + const event = JSON.parse(await readFile(eventUrl, 'utf8')); + const originalDocument = globalThis.document; + const originalWindow = globalThis.window; + const originalPerformance = globalThis.performance; + const originalRequestAnimationFrame = globalThis.requestAnimationFrame; + const originalCancelAnimationFrame = globalThis.cancelAnimationFrame; + const { body } = installEventDom(); + const animationCallbacks = []; + const cancelledFrames = []; + const releases = []; + globalThis.window = { setTimeout: () => 71, clearTimeout() {} }; + globalThis.performance = { now: () => 1000 }; + globalThis.requestAnimationFrame = (callback) => { + animationCallbacks.push(callback); + return animationCallbacks.length; + }; + globalThis.cancelAnimationFrame = (id) => cancelledFrames.push(id); + const viewer = eventViewer(); + const layer = createBhoteKoshiEventLayer({ + eventLoader: async () => event, + imageryProviderFactory: async (url) => ({ url }), + terrainSampler: async (_viewer, observations) => ( + observations.map((observation) => ({ height: observation.fallbackElevationM })) + ), + mediaLoader: async () => { + const error = new Error('fixture media intentionally unavailable'); + error.name = 'AbortError'; + throw error; + }, + overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} }, + renderHost: { + request() {}, + hold() {}, + release(id) { releases.push(id); }, + }, + }); + layer.attachMapStackController({ + getActiveId: () => 'esri-imagery', + getSwitchGeneration: () => 1, + async setStack() {}, + }); + + try { + await layer.enable(viewer, { origin: 'local-restore' }); + const panel = body.children.find((element) => element.tagName === 'aside'); + panel.querySelector('[data-action="cinematic"]').dispatch('click'); + animationCallbacks[0](91001); + assert.equal(layer.getPlaybackState().progress, 1); + assert.equal(layer.getPlaybackState().playing, false); + assert.equal(layer.getCinematicState().active, false); + assert.equal(viewer._test.cameraTransforms.length, 1); + assert.deepEqual(releases, ['bhote-koshi-playback']); + const play = panel.querySelector('[data-action="play"]'); + assert.equal(play.textContent, '↺ REPLAY CINEMATIC'); + + play.dispatch('click'); + assert.equal(layer.getCinematicState().active, true); + assert.equal(layer.getPlaybackState().progress, 0); + assert.equal(layer.getPlaybackState().playing, true); + assert.equal( + body.children.some((element) => element.id === 'bhote-koshi-story-strap'), + false, + ); + + panel.querySelector('[data-action="cinematic"]').dispatch('click'); + assert.equal(layer.getCinematicState().active, false); + assert.equal(viewer._test.cameraTransforms.length, 2); + animationCallbacks[1](91001); + assert.equal(play.textContent, '↺ REPLAY'); + const cameraLookCount = viewer._test.cameraLooks.length; + play.dispatch('click'); + assert.equal(layer.getPlaybackState().playing, true); + assert.equal(layer.getCinematicState().active, false); + assert.equal(viewer._test.cameraLooks.length, cameraLookCount); + + await layer.disable(); + assert.equal(layer.getCinematicState().active, false); + assert.equal(viewer._test.cameraTransforms.length, 2); + assert.deepEqual(cancelledFrames, [1, 2, 3]); + assert.equal(viewer._test.canvas._listeners.size, 0); + } finally { + globalThis.document = originalDocument; + globalThis.window = originalWindow; + globalThis.performance = originalPerformance; + globalThis.requestAnimationFrame = originalRequestAnimationFrame; + globalThis.cancelAnimationFrame = originalCancelAnimationFrame; + } +}); + +test('Bhote Koshi closes a poster that resolves after disable', async () => { + const event = JSON.parse(await readFile(eventUrl, 'utf8')); + event.evidenceSpine = event.evidenceSpine.slice(0, 1); + event.evidenceSpine[0].media.posterPath = 'evidence-09.jpg'; + const originalDocument = globalThis.document; + const originalWindow = globalThis.window; + installEventDom(); + globalThis.window = { setTimeout: () => 71, clearTimeout() {} }; + const viewer = eventViewer(); + let resolvePoster; + const poster = { closed: false, close() { this.closed = true; } }; + const layer = createBhoteKoshiEventLayer({ + eventLoader: async () => event, + imageryProviderFactory: async (url) => ({ url }), + terrainSampler: async () => [{ height: 2800 }], + mediaLoader: async (url) => { + assert.equal(url, '/events/bhote-koshi-2026/evidence-09.jpg'); + return new Promise((resolve) => { resolvePoster = resolve; }); + }, + evidenceFrameFactory: (observation) => ({ fixture: observation.id }), + overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} }, + renderHost: { request() {}, hold() {}, release() {} }, + }); + layer.attachMapStackController({ + getActiveId: () => 'esri-imagery', + getSwitchGeneration: () => 1, + async setStack() {}, + }); + + try { + await layer.enable(viewer, { origin: 'local-restore' }); + await layer.disable(); + resolvePoster(poster); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(poster.closed, true); + } finally { + globalThis.document = originalDocument; + globalThis.window = originalWindow; + } +}); + +test('every sourced shot grows its tail with its head, keeps its own card, and clears on exit', async () => { + const event = JSON.parse(await readFile(eventUrl, 'utf8')); + const originals = Object.fromEntries(['document', 'window', 'performance', 'requestAnimationFrame', 'cancelAnimationFrame'] + .map((key) => [key, globalThis[key]])); + installEventDom(); + let now = 1000; + let nextFrame = 0; + const frames = new Map(); + globalThis.window = { setTimeout: () => 71, clearTimeout() {} }; + globalThis.performance = { now: () => now }; + globalThis.requestAnimationFrame = (callback) => { frames.set(++nextFrame, callback); return nextFrame; }; + globalThis.cancelAnimationFrame = (id) => frames.delete(id); + const viewer = eventViewer(); + let heightSamples = 0; + viewer.scene.sampleHeight = () => { heightSamples++; return 1500; }; + let entries = []; + const layer = createBhoteKoshiEventLayer({ + eventLoader: async () => event, + imageryProviderFactory: async (url) => ({ url }), + terrainSampler: async () => null, + mediaLoader: async () => { throw Object.assign(new Error('fixture'), { name: 'AbortError' }); }, + overlayHost: { setEntries(_id, value) { entries = value; }, setVisible() {}, clearSource() {} }, + renderHost: { request() {}, hold() {}, release() {} }, + }); + layer.attachMapStackController({ getActiveId: () => 'photoreal', getSwitchGeneration: () => 1 }); + const params = (beatId, cameraSettled, evidencePath = 'source', evidencePathElevation = null) => ({ + presentation: 'scene-beat', sceneSurface: 'evidence-beat', beatId, + sceneControls: { cameraSettled, evidencePath, evidencePathElevation, evidencePathDurationSec: 4, + deferEvidenceUntilCameraSettled: true, evidenceHoldCard: true }, + }); + const advance = (elapsed) => { + const callback = frames.get(nextFrame); + frames.delete(nextFrame); + callback(now + elapsed); + }; + try { + layer.setParams(params(SOURCE_PATH_BEAT_IDS[0], false)); + await layer.enable(viewer, { origin: 'scene' }); + const source = viewer._test.dataSources.at(-1); + const flood = source.entities.getById('bhote-koshi-flood-core'); + const surge = source.entities.getById('bhote-koshi-surge-front'); + for (const id of SOURCE_PATH_BEAT_IDS) { + layer.setParams(params(id, false)); + assert.equal(flood.show, false, `${id}: hide the previous route during flight`); + assert.equal(surge.show, false); + layer.setParams(params(id, true)); + const sampleCount = heightSamples; + let previousLength = 0; + for (const elapsed of [400, 1800, 4000]) { + advance(elapsed); + const points = flood.polyline.positions.getValue(); + assert.ok(points.length >= previousLength); + assert.equal(flood.show, true); + assert.equal(surge.show, true); + assert.ok(Cesium.Cartesian3.equals(points.at(-1), surge.position.getValue()), `${id}: tail must reach head in every frame`); + previousLength = points.length; + } + assert.equal(heightSamples, sampleCount, 'do not resample geometry during motion'); + const visibleCards = entries.filter((entry) => entry.id.startsWith('evidence-card-') && entry.position()); + assert.deepEqual(visibleCards.map(({ id: cardId }) => cardId), [`evidence-card-${id}`]); + assert.ok(visibleCards[0].sourceAlpha() > 0, 'keep the source readable after the path settles'); + now += 5000; + } + // A settled-to-settled entry must not paint the last shot's completed + // progress on the new route for a frame, then jump back to its start. + layer.setParams(params('dhunche', true)); + assert.equal(flood.show, false, 'new shot clears its tail before the first animation frame'); + assert.equal(surge.show, false); + advance(1800); + const headBeforeSync = Cesium.Cartesian3.clone(surge.position.getValue()); + const frameBeforeSync = nextFrame; + layer.setParams(params('dhunche', true)); + assert.equal(nextFrame, frameBeforeSync, 'repeated settled notification does not restart the reveal'); + assert.ok(Cesium.Cartesian3.equals(headBeforeSync, surge.position.getValue())); + advance(2200); + assert.ok(!Cesium.Cartesian3.equals(headBeforeSync, surge.position.getValue())); + // The comparison exit can expose coarse photogrammetry samples a kilometre + // above the river. Dhunche's schematic profile must not dive through them. + viewer.scene.sampleHeight = () => { heightSamples++; return 2703; }; + viewer.scene.globe = { getHeight: () => 0 }; + layer.setParams(params('dhunche', false, 'source', 'source')); + const samplesBeforeProfile = heightSamples; + layer.setParams(params('dhunche', true, 'source', 'source')); + for (const elapsed of [40, 400, 1800, 4000]) { + advance(elapsed); + const points = flood.polyline.positions.getValue(); + for (const position of points) { + const height = Cesium.Cartographic.fromCartesian(position).height; + assert.ok(height >= 1307 && height <= 1332, `source profile stays near river elevation, got ${height}`); + } + assert.ok(Cesium.Cartesian3.equals(points.at(-1), surge.position.getValue())); + } + assert.equal(heightSamples, samplesBeforeProfile, 'source elevation does not sample the changing mesh'); + layer.setParams(params('charaudi', false, 'none')); + layer.setParams(params('charaudi', true, 'none')); + advance(1000); + assert.equal(flood.show, false); + assert.equal(surge.show, false); + await layer.disable(); + assert.equal(frames.size, 0); + } finally { + for (const [key, value] of Object.entries(originals)) globalThis[key] = value; + } +}); + +test('media-led upper-valley shots retain only their upstream prefix during camera travel', async () => { + const event = JSON.parse(await readFile(eventUrl, 'utf8')); + const originals = Object.fromEntries(['document', 'window', 'performance', 'requestAnimationFrame', 'cancelAnimationFrame'] + .map((key) => [key, globalThis[key]])); + installEventDom(); + let now = 1000; + let nextFrame = 0; + const frames = new Map(); + globalThis.window = { setTimeout: () => 71, clearTimeout() {} }; + globalThis.performance = { now: () => now }; + globalThis.requestAnimationFrame = callback => { frames.set(++nextFrame, callback); return nextFrame; }; + globalThis.cancelAnimationFrame = id => frames.delete(id); + const viewer = eventViewer(); + const layer = createBhoteKoshiEventLayer({ + eventLoader: async () => event, + imageryProviderFactory: async url => ({ url }), + terrainSampler: async () => null, + mediaLoader: async () => { throw Object.assign(new Error('fixture'), { name: 'AbortError' }); }, + overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} }, + renderHost: { request() {}, hold() {}, release() {} }, + }); + layer.attachMapStackController({ getActiveId: () => 'esri-imagery', getSwitchGeneration: () => 1 }); + const params = (beatId, cameraSettled, extras = {}) => ({ + presentation: 'scene-beat', sceneSurface: 'evidence-beat', beatId, + sceneControls: { + evidencePath: 'source', evidencePathHistory: true, evidencePathPersistent: true, + evidencePathDuringMedia: true, evidencePathDurationSec: 4.8, + deferEvidenceUntilCameraSettled: true, evidenceHoldCard: true, + cameraSettled, ...extras, + }, + }); + const finishReveal = () => { + now += 7000; + const callback = frames.get(nextFrame); + frames.delete(nextFrame); + callback(now); + }; + try { + layer.setParams(params('debris-dammed-lake', false)); + await layer.enable(viewer, { origin: 'scene' }); + const source = viewer._test.dataSources.at(-1); + const flood = source.entities.getById('bhote-koshi-flood-core'); + const surge = source.entities.getById('bhote-koshi-surge-front'); + layer.setParams(params('debris-dammed-lake', true)); + finishReveal(); + const lakeTail = flood.polyline.positions.getValue().map(point => Cesium.Cartesian3.clone(point)); + assert.ok(lakeTail.length > 1); + const origin = Cesium.Cartographic.fromCartesian(lakeTail[0]); + assert.ok(Math.abs(Cesium.Math.toDegrees(origin.longitude) - 85.48476) < 1e-6); + assert.ok(Math.abs(Cesium.Math.toDegrees(origin.latitude) - 28.33255) < 1e-6, + 'the rendered trail starts at the lake reach, not the earlier trigger reach'); + layer.setParams(params('second-landslide', false)); + assert.equal(flood.show, true, 'lake trail survives departure before the next camera settles'); + assert.deepEqual(flood.polyline.positions.getValue(), lakeTail, 'retain the prefix without revealing the next segment'); + assert.ok(Cesium.Cartesian3.equals(surge.position.getValue(), lakeTail.at(-1))); + layer.setParams(params('second-landslide', true)); + assert.deepEqual(flood.polyline.positions.getValue(), lakeTail, 'arrival does not clear the prefix'); + finishReveal(); + assert.ok(flood.polyline.positions.getValue().length > lakeTail.length); + layer.setParams(params('debris-dammed-lake', false)); + assert.equal(flood.show, false, 'backward replay trims future segments instead of retaining stale history'); + layer.setParams(params('second-landslide', false)); + assert.deepEqual(flood.polyline.positions.getValue(), lakeTail, 'direct load reconstructs the same prefix'); + layer.setParams(params('second-landslide', false, { evidencePathPersistent: false })); + assert.equal(flood.show, false, 'nonpersistent shots retain their arrival gate'); + for (const beatId of ['devighat-taadi-khola-bridge', 'charaudi', 'final-view']) { + const historyControls = { + evidencePath: 'history', evidencePathHistoryBeatId: SOURCE_PATH_BEAT_IDS.at(-1), + evidencePathDuringMedia: false, evidenceMediaAutoplay: beatId !== 'final-view', + }; + layer.setParams(params(beatId, false, historyControls)); + const completedTail = [...flood.polyline.positions.getValue()]; + assert.ok(completedTail.length > 100, `${beatId}: direct load reconstructs full sourced history`); + layer.setParams(params(beatId, true, historyControls)); + assert.deepEqual(flood.polyline.positions.getValue(), completedTail, + `${beatId}: arrival must not reset the completed history`); + finishReveal(); + assert.deepEqual(flood.polyline.positions.getValue(), completedTail, + `${beatId}: media/card reveal must not erase or regrow completed history`); + } + await layer.disable(); + assert.equal(frames.size, 0); + } finally { + for (const [key, value] of Object.entries(originals)) globalThis[key] = value; + } +}); + +test('camera-led Nepal flood motion preserves its prefix and stays ahead of shot travel', async () => { + const event = JSON.parse(await readFile(eventUrl, 'utf8')); + const originals = Object.fromEntries(['document', 'window', 'performance', 'requestAnimationFrame', 'cancelAnimationFrame'] + .map((key) => [key, globalThis[key]])); + installEventDom(); + let now = 1000; + let nextFrame = 0; + let nextTimer = 0; + const frames = new Map(); + const timers = new Map(); + const holds = []; + const releases = []; + globalThis.window = { + setTimeout(callback, delay) { + const id = ++nextTimer; + timers.set(id, { callback, delay }); + return id; + }, + clearTimeout(id) { timers.delete(id); }, + }; + globalThis.performance = { now: () => now }; + globalThis.requestAnimationFrame = (callback) => { frames.set(++nextFrame, callback); return nextFrame; }; + globalThis.cancelAnimationFrame = (id) => frames.delete(id); + const viewer = eventViewer(); + const layer = createBhoteKoshiEventLayer({ + eventLoader: async () => event, + imageryProviderFactory: async (url) => ({ url }), + terrainSampler: async () => null, + mediaLoader: async () => { throw Object.assign(new Error('fixture'), { name: 'AbortError' }); }, + overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} }, + renderHost: { + request() {}, + hold(id) { holds.push(id); }, + release(id) { releases.push(id); }, + }, + }); + layer.attachMapStackController({ getActiveId: () => 'photoreal', getSwitchGeneration: () => 1 }); + const travel = (id, durationSec = 4.2, overrides = {}) => ({ + id, durationSec, active: true, completed: false, cancelled: false, ...overrides, + }); + const params = (beatId, evidencePath, cameraTravel, extras = {}) => ({ + presentation: 'scene-beat', + sceneSurface: 'evidence-beat', + beatId, + sceneControls: { + evidencePath, + evidencePathDuringCamera: true, + evidencePathPersistent: true, + deferEvidenceUntilCameraSettled: true, + cameraSettled: false, + cameraTravel, + ...extras, + }, + }); + const advance = (elapsed) => { + now += elapsed; + const id = Math.max(...frames.keys()); + const callback = frames.get(id); + frames.delete(id); + assert.equal(typeof callback, 'function'); + callback(now); + }; + + try { + layer.setParams(params('timure-cluster', 'comparison', travel(1), { + evidenceSequence: true, + })); + await layer.enable(viewer, { origin: 'scene' }); + let source = viewer._test.dataSources.at(-1); + let flood = source.entities.getById('bhote-koshi-flood-core'); + let surge = source.entities.getById('bhote-koshi-surge-front'); + const timureStart = flood.polyline.positions.getValue().length; + assert.ok(timureStart > 1, 'the completed Border Gate prefix is already visible during travel'); + assert.equal(surge.show, true); + advance(420); + const earlyTimureLength = flood.polyline.positions.getValue().length; + assert.ok(earlyTimureLength > timureStart, + 'the trail leads during the opening tenth of camera travel instead of waiting for arrival'); + advance(2880); + const timureEnd = flood.polyline.positions.getValue().length; + assert.ok(timureEnd > timureStart); + assert.equal(flood.show, true, 'the completed tail remains visible at the next point'); + assert.ok(Cesium.Cartesian3.equals(flood.polyline.positions.getValue().at(-1), surge.position.getValue())); + + layer.setParams(params('syabru-besi', 'comparison', travel(2), { + evidenceSequence: true, + })); + assert.ok(flood.polyline.positions.getValue().length >= timureEnd, + 'the Timure segment remains painted when Syabru travel starts'); + advance(3300); + assert.equal(surge.show, true, 'the amber head remains visible at comparison completion'); + assert.ok(Cesium.Cartesian3.equals(flood.polyline.positions.getValue().at(-1), surge.position.getValue())); + + layer.setParams(params('dhunche', 'source', travel(3), { + evidencePathHistory: true, + evidencePathElevation: 'source', + evidenceHoldCard: true, + })); + source = viewer._test.dataSources.at(-1); + flood = source.entities.getById('bhote-koshi-flood-core'); + surge = source.entities.getById('bhote-koshi-surge-front'); + const dhuncheStart = flood.polyline.positions.getValue().length; + assert.ok(dhuncheStart >= event.reconstruction.corridor.length, + 'the comparison corridor survives the transition onto Google photogrammetry'); + advance(3300); + const dhuncheEnd = flood.polyline.positions.getValue().length; + assert.ok(dhuncheEnd > dhuncheStart); + assert.equal(surge.show, true); + + layer.setParams(params('mailung-upper-trishuli', 'source', travel(4), { + evidencePathHistory: true, + evidencePathElevation: 'source', + evidenceHoldCard: true, + })); + assert.ok(flood.polyline.positions.getValue().length >= dhuncheEnd, + 'the completed Dhunche segment never disappears'); + advance(900); + const frozenHead = Cesium.Cartesian3.clone(surge.position.getValue()); + const frozenLength = flood.polyline.positions.getValue().length; + layer.setParams(params('mailung-upper-trishuli', 'source', travel(4, 4.2, { + active: false, cancelled: true, + }), { + evidencePathHistory: true, + evidencePathElevation: 'source', + evidenceHoldCard: true, + })); + assert.equal(frames.size, 0, 'cancelling camera travel revokes the path animation frame'); + assert.equal(flood.polyline.positions.getValue().length, frozenLength); + assert.ok(Cesium.Cartesian3.equals(surge.position.getValue(), frozenHead)); + assert.ok(holds.includes('bhote-koshi-scene-path-travel')); + assert.ok(releases.includes('bhote-koshi-scene-path-travel')); + + let previousCompleteLength = dhuncheEnd; + let travelId = 5; + for (const beatId of SOURCE_PATH_BEAT_IDS.slice(3)) { + layer.setParams(params(beatId, 'source', travel(travelId++), { + evidencePathHistory: true, + evidencePathElevation: 'source', + evidenceHoldCard: true, + })); + const startLength = flood.polyline.positions.getValue().length; + assert.ok(startLength >= previousCompleteLength, + `${beatId} retains every completed upstream segment while travel begins`); + advance(3300); + const completeLength = flood.polyline.positions.getValue().length; + assert.ok(completeLength > startLength, `${beatId} grows its own segment during travel`); + assert.equal(flood.show, true); + assert.equal(surge.show, true); + assert.ok(Cesium.Cartesian3.equals( + flood.polyline.positions.getValue().at(-1), surge.position.getValue(), + )); + previousCompleteLength = completeLength; + } + const bidurCompleteLength = previousCompleteLength; + + // Direct entry must reconstruct its supported prefix without relying on + // earlier playback, while backward navigation must trim later segments. + layer.setParams({ + presentation: 'scene-beat', sceneSurface: 'evidence-beat', beatId: 'devighat-taadi-khola-bridge', + sceneControls: { evidencePath: 'none', deferEvidenceUntilCameraSettled: true, cameraSettled: false }, + }); + assert.equal(flood.show, false, 'Devighat does not leak the supported route'); + assert.equal(surge.show, false); + layer.setParams(params('bidur-trishuli-bridge', 'source', travel(travelId++), { + evidencePathHistory: true, + evidencePathElevation: 'source', + evidenceHoldCard: true, + })); + assert.ok(flood.polyline.positions.getValue().length > dhuncheEnd, + 'a direct Bidur load reconstructs its complete upstream prefix'); + advance(3300); + assert.equal(flood.polyline.positions.getValue().length, bidurCompleteLength); + + layer.setParams(params('dhunche', 'source', travel(travelId++), { + evidencePathHistory: true, + evidencePathElevation: 'source', + evidenceHoldCard: true, + })); + assert.ok(flood.polyline.positions.getValue().length < bidurCompleteLength, + 'backward navigation trims unsupported downstream history immediately'); + advance(3300); + assert.equal(flood.polyline.positions.getValue().length, dhuncheEnd); + + // The deadline is independent of RAF, so a throttled render loop still + // completes the active segment before the camera's longer flight ends. + layer.setParams(params('mailung-upper-trishuli', 'source', travel(travelId++), { + evidencePathHistory: true, + evidencePathElevation: 'source', + evidenceHoldCard: true, + })); + const deadlineEntry = [...timers.entries()].find(([, entry]) => entry.delay >= 2400); + assert.ok(deadlineEntry, 'camera-led travel registers a pre-arrival completion deadline'); + now += deadlineEntry[1].delay; + deadlineEntry[1].callback(); + assert.equal(frames.size, 0, 'the deadline cancels a stalled animation frame'); + assert.ok(flood.polyline.positions.getValue().length > dhuncheEnd); + + layer.setParams({ + presentation: 'scene-beat', sceneSurface: 'evidence-beat', beatId: 'charaudi', + sceneControls: { evidencePath: 'none', deferEvidenceUntilCameraSettled: true, cameraSettled: false }, + }); + assert.equal(flood.show, false, 'unsupported downstream shots do not invent or leak a route'); + assert.equal(surge.show, false); + await layer.disable(); + } finally { + for (const [key, value] of Object.entries(originals)) globalThis[key] = value; + } +}); + +test('Bhote Koshi corridor remains ordered downstream and inside the imagery rectangle', async () => { + const event = JSON.parse(await readFile(eventUrl, 'utf8')); + const { west, south, east, north } = event.imagery.rectangle; + let priorChainage = Number.NEGATIVE_INFINITY; + for (const point of event.reconstruction.corridor) { + assert.ok(point.chainageM > priorChainage); + assert.ok(point.lon >= west && point.lon <= east); + assert.ok(point.lat >= south && point.lat <= north); + priorChainage = point.chainageM; + } +}); diff --git a/src/data/bhoteKoshiFloodPath.js b/src/data/bhoteKoshiFloodPath.js new file mode 100644 index 000000000..241b78676 --- /dev/null +++ b/src/data/bhoteKoshiFloodPath.js @@ -0,0 +1,499 @@ +/** + * Simplified GeoPera river centerline from the point nearest the owner-provided + * Debris-Dammed Lake observation through Trishuli Bazaar. Every fourth published + * vertex is retained, plus the exact endpoint nearest the verified bazaar pin. + * + * Source: https://github.com/geo-pera/bhotekoshi-2026-reconstruction/blob/main/vectors/river_centerline.geojson + * License: CC BY-NC 4.0 (derived data; Planet Labs PBC / Vantor open data) + */ +export const BHOTE_KOSHI_FLOOD_PATH = Object.freeze([ + Object.freeze([85.48476, 28.33255]), + Object.freeze([85.48353, 28.33308]), + Object.freeze([85.48231, 28.33279]), + Object.freeze([85.48108, 28.33278]), + Object.freeze([85.47985, 28.33304]), + Object.freeze([85.47863, 28.33275]), + Object.freeze([85.47741, 28.33301]), + Object.freeze([85.47618, 28.33299]), + Object.freeze([85.47496, 28.33325]), + Object.freeze([85.47373, 28.33351]), + Object.freeze([85.4725, 28.33377]), + Object.freeze([85.47128, 28.33375]), + Object.freeze([85.47005, 28.33401]), + Object.freeze([85.46881, 28.33481]), + Object.freeze([85.46759, 28.33506]), + Object.freeze([85.46636, 28.33532]), + Object.freeze([85.46513, 28.33585]), + Object.freeze([85.4639, 28.33584]), + Object.freeze([85.46267, 28.33609]), + Object.freeze([85.46145, 28.33635]), + Object.freeze([85.46022, 28.33661]), + Object.freeze([85.45899, 28.33659]), + Object.freeze([85.45777, 28.33685]), + Object.freeze([85.45655, 28.33629]), + Object.freeze([85.45533, 28.33628]), + Object.freeze([85.45411, 28.336]), + Object.freeze([85.45289, 28.33544]), + Object.freeze([85.45167, 28.33543]), + Object.freeze([85.45045, 28.33514]), + Object.freeze([85.44922, 28.33513]), + Object.freeze([85.448, 28.33511]), + Object.freeze([85.44677, 28.33537]), + Object.freeze([85.44554, 28.33563]), + Object.freeze([85.44432, 28.33534]), + Object.freeze([85.4431, 28.33533]), + Object.freeze([85.44188, 28.33504]), + Object.freeze([85.44065, 28.3353]), + Object.freeze([85.43943, 28.33529]), + Object.freeze([85.43821, 28.335]), + Object.freeze([85.43699, 28.33472]), + Object.freeze([85.43577, 28.33416]), + Object.freeze([85.43456, 28.33334]), + Object.freeze([85.43335, 28.33251]), + Object.freeze([85.43214, 28.33141]), + Object.freeze([85.43093, 28.33059]), + Object.freeze([85.42971, 28.33057]), + Object.freeze([85.42848, 28.33056]), + Object.freeze([85.42726, 28.33027]), + Object.freeze([85.42604, 28.33026]), + Object.freeze([85.42482, 28.33025]), + Object.freeze([85.4236, 28.32996]), + Object.freeze([85.42238, 28.32968]), + Object.freeze([85.42116, 28.32939]), + Object.freeze([85.41995, 28.32829]), + Object.freeze([85.41873, 28.32774]), + Object.freeze([85.41752, 28.32718]), + Object.freeze([85.41629, 28.32744]), + Object.freeze([85.41508, 28.32634]), + Object.freeze([85.41387, 28.32524]), + Object.freeze([85.41297, 28.32415]), + Object.freeze([85.41268, 28.32306]), + Object.freeze([85.41239, 28.32198]), + Object.freeze([85.41241, 28.3209]), + Object.freeze([85.41273, 28.31982]), + Object.freeze([85.41275, 28.31873]), + Object.freeze([85.41276, 28.31765]), + Object.freeze([85.41308, 28.31657]), + Object.freeze([85.41249, 28.31548]), + Object.freeze([85.4122, 28.3144]), + Object.freeze([85.41252, 28.31332]), + Object.freeze([85.41254, 28.31223]), + Object.freeze([85.41194, 28.31114]), + Object.freeze([85.41196, 28.31006]), + Object.freeze([85.41167, 28.30897]), + Object.freeze([85.41077, 28.30788]), + Object.freeze([85.40955, 28.30705]), + Object.freeze([85.40865, 28.30596]), + Object.freeze([85.40744, 28.30513]), + Object.freeze([85.40623, 28.30404]), + Object.freeze([85.40503, 28.30294]), + Object.freeze([85.40381, 28.30238]), + Object.freeze([85.4026, 28.30156]), + Object.freeze([85.40139, 28.30046]), + Object.freeze([85.40019, 28.29936]), + Object.freeze([85.39898, 28.29827]), + Object.freeze([85.39777, 28.29717]), + Object.freeze([85.39748, 28.29608]), + Object.freeze([85.39658, 28.29499]), + Object.freeze([85.39629, 28.2939]), + Object.freeze([85.3957, 28.29281]), + Object.freeze([85.39449, 28.29172]), + Object.freeze([85.39328, 28.29062]), + Object.freeze([85.39238, 28.28953]), + Object.freeze([85.39117, 28.28897]), + Object.freeze([85.38996, 28.28787]), + Object.freeze([85.38875, 28.28678]), + Object.freeze([85.38754, 28.28622]), + Object.freeze([85.38632, 28.28593]), + Object.freeze([85.38511, 28.28511]), + Object.freeze([85.3839, 28.28401]), + Object.freeze([85.383, 28.28292]), + Object.freeze([85.38179, 28.28182]), + Object.freeze([85.38089, 28.28073]), + Object.freeze([85.37968, 28.28017]), + Object.freeze([85.37846, 28.27934]), + Object.freeze([85.37725, 28.27852]), + Object.freeze([85.37666, 28.27743]), + Object.freeze([85.37668, 28.27634]), + Object.freeze([85.37669, 28.27526]), + Object.freeze([85.37701, 28.27418]), + Object.freeze([85.37642, 28.27309]), + Object.freeze([85.37644, 28.27201]), + Object.freeze([85.37615, 28.27092]), + Object.freeze([85.37616, 28.26984]), + Object.freeze([85.37648, 28.26876]), + Object.freeze([85.37558, 28.26767]), + Object.freeze([85.37499, 28.26658]), + Object.freeze([85.37378, 28.26548]), + Object.freeze([85.37288, 28.26439]), + Object.freeze([85.37198, 28.26329]), + Object.freeze([85.37169, 28.26221]), + Object.freeze([85.37048, 28.26111]), + Object.freeze([85.36928, 28.26001]), + Object.freeze([85.36806, 28.25973]), + Object.freeze([85.36685, 28.25863]), + Object.freeze([85.36656, 28.25754]), + Object.freeze([85.36566, 28.25645]), + Object.freeze([85.36507, 28.25536]), + Object.freeze([85.36478, 28.25427]), + Object.freeze([85.36449, 28.25318]), + Object.freeze([85.36512, 28.25211]), + Object.freeze([85.36452, 28.25102]), + Object.freeze([85.36423, 28.24993]), + Object.freeze([85.36425, 28.24885]), + Object.freeze([85.36335, 28.24776]), + Object.freeze([85.36245, 28.24666]), + Object.freeze([85.36124, 28.24556]), + Object.freeze([85.36004, 28.24447]), + Object.freeze([85.35882, 28.24391]), + Object.freeze([85.35823, 28.24282]), + Object.freeze([85.35733, 28.24173]), + Object.freeze([85.35734, 28.24064]), + Object.freeze([85.35797, 28.23957]), + Object.freeze([85.35799, 28.23849]), + Object.freeze([85.3577, 28.2374]), + Object.freeze([85.35802, 28.23632]), + Object.freeze([85.35834, 28.23524]), + Object.freeze([85.35928, 28.23417]), + Object.freeze([85.3596, 28.23309]), + Object.freeze([85.36023, 28.23202]), + Object.freeze([85.36024, 28.23093]), + Object.freeze([85.36057, 28.22985]), + Object.freeze([85.36058, 28.22877]), + Object.freeze([85.3609, 28.22769]), + Object.freeze([85.36092, 28.22661]), + Object.freeze([85.36094, 28.22553]), + Object.freeze([85.36126, 28.22445]), + Object.freeze([85.36067, 28.22336]), + Object.freeze([85.36007, 28.22227]), + Object.freeze([85.35886, 28.22117]), + Object.freeze([85.35796, 28.22008]), + Object.freeze([85.35706, 28.21898]), + Object.freeze([85.35616, 28.21789]), + Object.freeze([85.35526, 28.21679]), + Object.freeze([85.35467, 28.2157]), + Object.freeze([85.35469, 28.21462]), + Object.freeze([85.3547, 28.21354]), + Object.freeze([85.35472, 28.21246]), + Object.freeze([85.35443, 28.21137]), + Object.freeze([85.35414, 28.21028]), + Object.freeze([85.35416, 28.2092]), + Object.freeze([85.35448, 28.20812]), + Object.freeze([85.3548, 28.20704]), + Object.freeze([85.35482, 28.20596]), + Object.freeze([85.35392, 28.20486]), + Object.freeze([85.35332, 28.20377]), + Object.freeze([85.35303, 28.20269]), + Object.freeze([85.35275, 28.2016]), + Object.freeze([85.35246, 28.20052]), + Object.freeze([85.35247, 28.19943]), + Object.freeze([85.35188, 28.19834]), + Object.freeze([85.35128, 28.19725]), + Object.freeze([85.35038, 28.19616]), + Object.freeze([85.3501, 28.19507]), + Object.freeze([85.3495, 28.19398]), + Object.freeze([85.34891, 28.19289]), + Object.freeze([85.34862, 28.1918]), + Object.freeze([85.34802, 28.19071]), + Object.freeze([85.34743, 28.18962]), + Object.freeze([85.34622, 28.18853]), + Object.freeze([85.34593, 28.18744]), + Object.freeze([85.34565, 28.18635]), + Object.freeze([85.34475, 28.18526]), + Object.freeze([85.34385, 28.18417]), + Object.freeze([85.34325, 28.18308]), + Object.freeze([85.34266, 28.18199]), + Object.freeze([85.34237, 28.1809]), + Object.freeze([85.34239, 28.17982]), + Object.freeze([85.34271, 28.17874]), + Object.freeze([85.34272, 28.17765]), + Object.freeze([85.34274, 28.17657]), + Object.freeze([85.34215, 28.17548]), + Object.freeze([85.34216, 28.1744]), + Object.freeze([85.34249, 28.17332]), + Object.freeze([85.34189, 28.17223]), + Object.freeze([85.34191, 28.17115]), + Object.freeze([85.34192, 28.17006]), + Object.freeze([85.34255, 28.16899]), + Object.freeze([85.34226, 28.1679]), + Object.freeze([85.34289, 28.16683]), + Object.freeze([85.3423, 28.16574]), + Object.freeze([85.3417, 28.16465]), + Object.freeze([85.34048, 28.16436]), + Object.freeze([85.33928, 28.16353]), + Object.freeze([85.33807, 28.16271]), + Object.freeze([85.33747, 28.16162]), + Object.freeze([85.33718, 28.16053]), + Object.freeze([85.33628, 28.15971]), + Object.freeze([85.33507, 28.15888]), + Object.freeze([85.33385, 28.15859]), + Object.freeze([85.33295, 28.15777]), + Object.freeze([85.33174, 28.15694]), + Object.freeze([85.33053, 28.15639]), + Object.freeze([85.32932, 28.15529]), + Object.freeze([85.32842, 28.1542]), + Object.freeze([85.32721, 28.15337]), + Object.freeze([85.32631, 28.15228]), + Object.freeze([85.3251, 28.15145]), + Object.freeze([85.32389, 28.15089]), + Object.freeze([85.32267, 28.15061]), + Object.freeze([85.32147, 28.14978]), + Object.freeze([85.32026, 28.14895]), + Object.freeze([85.31904, 28.1484]), + Object.freeze([85.31783, 28.14784]), + Object.freeze([85.31663, 28.14674]), + Object.freeze([85.31541, 28.14619]), + Object.freeze([85.3142, 28.14536]), + Object.freeze([85.313, 28.14426]), + Object.freeze([85.3124, 28.14317]), + Object.freeze([85.31181, 28.14208]), + Object.freeze([85.31058, 28.14261]), + Object.freeze([85.30936, 28.14232]), + Object.freeze([85.30816, 28.14122]), + Object.freeze([85.30695, 28.14012]), + Object.freeze([85.30575, 28.13903]), + Object.freeze([85.30485, 28.13793]), + Object.freeze([85.30456, 28.13685]), + Object.freeze([85.30366, 28.13575]), + Object.freeze([85.30307, 28.13466]), + Object.freeze([85.30248, 28.13357]), + Object.freeze([85.30127, 28.13274]), + Object.freeze([85.30067, 28.13165]), + Object.freeze([85.30039, 28.13057]), + Object.freeze([85.3001, 28.12948]), + Object.freeze([85.29889, 28.12838]), + Object.freeze([85.29799, 28.12729]), + Object.freeze([85.29709, 28.12647]), + Object.freeze([85.29588, 28.12591]), + Object.freeze([85.29467, 28.12508]), + Object.freeze([85.29347, 28.12398]), + Object.freeze([85.29226, 28.12316]), + Object.freeze([85.29105, 28.12206]), + Object.freeze([85.28985, 28.12096]), + Object.freeze([85.28864, 28.11986]), + Object.freeze([85.28775, 28.11877]), + Object.freeze([85.28653, 28.11821]), + Object.freeze([85.28532, 28.11765]), + Object.freeze([85.28411, 28.11683]), + Object.freeze([85.2829, 28.116]), + Object.freeze([85.28231, 28.11491]), + Object.freeze([85.28172, 28.11382]), + Object.freeze([85.28082, 28.11272]), + Object.freeze([85.27961, 28.11217]), + Object.freeze([85.2787, 28.11134]), + Object.freeze([85.27749, 28.11079]), + Object.freeze([85.27629, 28.10969]), + Object.freeze([85.27508, 28.10859]), + Object.freeze([85.27388, 28.10749]), + Object.freeze([85.27267, 28.10667]), + Object.freeze([85.27177, 28.10584]), + Object.freeze([85.27056, 28.10474]), + Object.freeze([85.26936, 28.10392]), + Object.freeze([85.26815, 28.10309]), + Object.freeze([85.26695, 28.10199]), + Object.freeze([85.26574, 28.10089]), + Object.freeze([85.26454, 28.09979]), + Object.freeze([85.26364, 28.0987]), + Object.freeze([85.26274, 28.09761]), + Object.freeze([85.26153, 28.09678]), + Object.freeze([85.26033, 28.09568]), + Object.freeze([85.25911, 28.09539]), + Object.freeze([85.2579, 28.09511]), + Object.freeze([85.25668, 28.09509]), + Object.freeze([85.25546, 28.09481]), + Object.freeze([85.25425, 28.09398]), + Object.freeze([85.25304, 28.09342]), + Object.freeze([85.25183, 28.09286]), + Object.freeze([85.25061, 28.09285]), + Object.freeze([85.24939, 28.09256]), + Object.freeze([85.24818, 28.09201]), + Object.freeze([85.24696, 28.09199]), + Object.freeze([85.24573, 28.09252]), + Object.freeze([85.24451, 28.0925]), + Object.freeze([85.24328, 28.09248]), + Object.freeze([85.24207, 28.0922]), + Object.freeze([85.24085, 28.09191]), + Object.freeze([85.23963, 28.0919]), + Object.freeze([85.23841, 28.09161]), + Object.freeze([85.2372, 28.09132]), + Object.freeze([85.23598, 28.09104]), + Object.freeze([85.23476, 28.09102]), + Object.freeze([85.23355, 28.09019]), + Object.freeze([85.23234, 28.08964]), + Object.freeze([85.23113, 28.08881]), + Object.freeze([85.22993, 28.08771]), + Object.freeze([85.22872, 28.08688]), + Object.freeze([85.22782, 28.08579]), + Object.freeze([85.22692, 28.08496]), + Object.freeze([85.22571, 28.08414]), + Object.freeze([85.2245, 28.08358]), + Object.freeze([85.22329, 28.08302]), + Object.freeze([85.22208, 28.08219]), + Object.freeze([85.22087, 28.08164]), + Object.freeze([85.21966, 28.08108]), + Object.freeze([85.21876, 28.08026]), + Object.freeze([85.21755, 28.07943]), + Object.freeze([85.21635, 28.07833]), + Object.freeze([85.21514, 28.07723]), + Object.freeze([85.21394, 28.0764]), + Object.freeze([85.21272, 28.07585]), + Object.freeze([85.21151, 28.07529]), + Object.freeze([85.2103, 28.075]), + Object.freeze([85.2094, 28.07391]), + Object.freeze([85.20881, 28.07282]), + Object.freeze([85.20791, 28.07172]), + Object.freeze([85.20762, 28.07063]), + Object.freeze([85.20672, 28.06954]), + Object.freeze([85.20552, 28.06844]), + Object.freeze([85.20523, 28.06735]), + Object.freeze([85.20495, 28.06627]), + Object.freeze([85.20588, 28.0652]), + Object.freeze([85.2062, 28.06412]), + Object.freeze([85.20683, 28.06304]), + Object.freeze([85.20716, 28.06196]), + Object.freeze([85.20717, 28.06088]), + Object.freeze([85.2078, 28.05981]), + Object.freeze([85.20751, 28.05872]), + Object.freeze([85.20814, 28.05765]), + Object.freeze([85.20847, 28.05657]), + Object.freeze([85.20787, 28.05548]), + Object.freeze([85.20759, 28.05439]), + Object.freeze([85.20699, 28.0533]), + Object.freeze([85.2061, 28.0522]), + Object.freeze([85.20489, 28.05138]), + Object.freeze([85.20369, 28.05028]), + Object.freeze([85.2031, 28.04919]), + Object.freeze([85.2022, 28.04809]), + Object.freeze([85.20191, 28.04701]), + Object.freeze([85.20101, 28.04591]), + Object.freeze([85.19981, 28.04508]), + Object.freeze([85.1986, 28.04398]), + Object.freeze([85.19739, 28.04343]), + Object.freeze([85.1965, 28.04233]), + Object.freeze([85.19529, 28.0415]), + Object.freeze([85.19439, 28.04041]), + Object.freeze([85.1938, 28.03932]), + Object.freeze([85.19382, 28.03824]), + Object.freeze([85.19384, 28.03715]), + Object.freeze([85.19355, 28.03607]), + Object.freeze([85.19357, 28.03498]), + Object.freeze([85.19328, 28.0339]), + Object.freeze([85.1936, 28.03282]), + Object.freeze([85.19362, 28.03174]), + Object.freeze([85.19364, 28.03065]), + Object.freeze([85.19305, 28.02956]), + Object.freeze([85.19215, 28.02847]), + Object.freeze([85.19156, 28.02738]), + Object.freeze([85.19097, 28.02629]), + Object.freeze([85.18976, 28.02573]), + Object.freeze([85.18854, 28.02571]), + Object.freeze([85.18732, 28.02515]), + Object.freeze([85.18611, 28.0246]), + Object.freeze([85.18522, 28.0235]), + Object.freeze([85.18493, 28.02242]), + Object.freeze([85.18556, 28.02134]), + Object.freeze([85.1868, 28.02027]), + Object.freeze([85.18742, 28.0192]), + Object.freeze([85.18714, 28.01811]), + Object.freeze([85.18746, 28.01703]), + Object.freeze([85.18656, 28.01594]), + Object.freeze([85.18536, 28.01484]), + Object.freeze([85.18446, 28.01375]), + Object.freeze([85.18418, 28.01266]), + Object.freeze([85.18389, 28.01157]), + Object.freeze([85.1836, 28.01049]), + Object.freeze([85.18362, 28.0094]), + Object.freeze([85.18364, 28.00832]), + Object.freeze([85.18366, 28.00724]), + Object.freeze([85.18368, 28.00615]), + Object.freeze([85.1843, 28.00508]), + Object.freeze([85.18432, 28.004]), + Object.freeze([85.18404, 28.00291]), + Object.freeze([85.18375, 28.00182]), + Object.freeze([85.18438, 28.00075]), + Object.freeze([85.18501, 27.99967]), + Object.freeze([85.18533, 27.9986]), + Object.freeze([85.18596, 27.99752]), + Object.freeze([85.18567, 27.99643]), + Object.freeze([85.18477, 27.99534]), + Object.freeze([85.18357, 27.99424]), + Object.freeze([85.18237, 27.99314]), + Object.freeze([85.18239, 27.99206]), + Object.freeze([85.18302, 27.99098]), + Object.freeze([85.18273, 27.9899]), + Object.freeze([85.18152, 27.98907]), + Object.freeze([85.18062, 27.98824]), + Object.freeze([85.18034, 27.98716]), + Object.freeze([85.18066, 27.98608]), + Object.freeze([85.18037, 27.98499]), + Object.freeze([85.18039, 27.98391]), + Object.freeze([85.18041, 27.98283]), + Object.freeze([85.18043, 27.98174]), + Object.freeze([85.18075, 27.98067]), + Object.freeze([85.18138, 27.97959]), + Object.freeze([85.1814, 27.97851]), + Object.freeze([85.18172, 27.97743]), + Object.freeze([85.18204, 27.97635]), + Object.freeze([85.18236, 27.97527]), + Object.freeze([85.18299, 27.9742]), + Object.freeze([85.18362, 27.97312]), + Object.freeze([85.18425, 27.97205]), + Object.freeze([85.18396, 27.97096]), + Object.freeze([85.18275, 27.9704]), + Object.freeze([85.18154, 27.97012]), + Object.freeze([85.18033, 27.96956]), + Object.freeze([85.17911, 27.96927]), + Object.freeze([85.17821, 27.96818]), + Object.freeze([85.17762, 27.96709]), + Object.freeze([85.17703, 27.966]), + Object.freeze([85.17613, 27.9649]), + Object.freeze([85.17492, 27.96434]), + Object.freeze([85.17371, 27.96406]), + Object.freeze([85.1725, 27.9635]), + Object.freeze([85.17128, 27.96321]), + Object.freeze([85.17008, 27.96238]), + Object.freeze([85.16888, 27.96129]), + Object.freeze([85.16859, 27.9602]), + Object.freeze([85.168, 27.95911]), + Object.freeze([85.16771, 27.95802]), + Object.freeze([85.16682, 27.95693]), + Object.freeze([85.16561, 27.95637]), + Object.freeze([85.1644, 27.95581]), + Object.freeze([85.16318, 27.95552]), + Object.freeze([85.16198, 27.95442]), + Object.freeze([85.1623, 27.95335]), + Object.freeze([85.16232, 27.95226]), + Object.freeze([85.16234, 27.95118]), + Object.freeze([85.16236, 27.9501]), + Object.freeze([85.16238, 27.94902]), + Object.freeze([85.1627, 27.94794]), + Object.freeze([85.16241, 27.94685]), + Object.freeze([85.16152, 27.94576]), + Object.freeze([85.16093, 27.94466]), + Object.freeze([85.16034, 27.94357]), + Object.freeze([85.15974, 27.94248]), + Object.freeze([85.15915, 27.94139]), + Object.freeze([85.15887, 27.9403]), + Object.freeze([85.15797, 27.93921]), + Object.freeze([85.15738, 27.93812]), + Object.freeze([85.15679, 27.93703]), + Object.freeze([85.1562, 27.93594]), + Object.freeze([85.15561, 27.93485]), + Object.freeze([85.15532, 27.93376]), + Object.freeze([85.15473, 27.93267]), + Object.freeze([85.15352, 27.93184]), + Object.freeze([85.15229, 27.93291]), + Object.freeze([85.15105, 27.9337]), + Object.freeze([85.15015, 27.93288]), + Object.freeze([85.14987, 27.93179]), + Object.freeze([85.14988, 27.93071]), + Object.freeze([85.14929, 27.92962]), + Object.freeze([85.14901, 27.92853]), + Object.freeze([85.14994, 27.92746]), + Object.freeze([85.15057, 27.92639]), + Object.freeze([85.15089, 27.92531]), + Object.freeze([85.15121, 27.92423]), + Object.freeze([85.15123, 27.92315]), + Object.freeze([85.15034, 27.92205]), + Object.freeze([85.14943, 27.92177]), +]); diff --git a/src/data/bhoteKoshiIncidentPlaces.js b/src/data/bhoteKoshiIncidentPlaces.js new file mode 100644 index 000000000..6f9dc1baf --- /dev/null +++ b/src/data/bhoteKoshiIncidentPlaces.js @@ -0,0 +1,117 @@ +// Overview pins follow the event pack through Devighat. +// Charaudi remains a separate evidence shot, and none of these observation +// locations extends the mapped flood path. +export const INCIDENT_OVERVIEW_PLACES = Object.freeze( + [ + [ + 'immediate-collapse-viewpoint', + 'COLLAPSE VIEWPOINT', + 28.3632333, + 85.4423639, + 2850, + 'left', + ], + [ + 'debris-dammed-lake', + 'DAMMED LAKE', + 28.3346111, + 85.4861389, + 2750, + 'right', + ], + [ + 'second-landslide', + 'SECOND LANDSLIDE', + 28.3379706, + 85.4688823, + 2750, + 'left', + ], + [ + 'gyirong-border-gate', + 'BORDER GATE', + 28.2808338, + 85.3780248, + 1818, + 'right', + ], + ['timure-cluster', 'TIMURE', 28.2519361, 85.3665833, 1689, 'left'], + [ + 'syabru-besi', + 'SYABRU BESI', + 28.1602583333333, + 85.3325666666667, + 1417, + 'right', + ], + ['dhunche', 'DHUNCHE', 28.1131861111111, 85.2953305555556, 1300, 'left'], + [ + 'mailung-upper-trishuli', + 'UPPER TRISHULI', + 28.090925, + 85.2322916666667, + 950, + 'right', + ], + [ + 'mailung-bazzar', + 'MAILUNG', + 28.0761666666667, + 85.2125277777778, + 800, + 'left', + ], + [ + 'dandagaun', + 'DANDAGAUN', + 28.0751944444444, + 85.2076944444444, + 780, + 'right-lower', + ], + [ + 'dandagaun-viewpoint', + 'DANDAGAUN VIEWPOINT', + 28.0354, + 85.1894361111111, + 700, + 'right', + ], + [ + 'betrawati-bazaar', + 'BETRAWATI', + 27.9758992572246, + 85.1835331689033, + 600, + 'left', + ], + [ + 'bhainse', + 'BHAINSE', + 27.9623111111111, + 85.1739777777778, + 500, + 'right-lower', + ], + [ + 'bidur-trishuli-bridge', + 'BIDUR BRIDGE', + 27.920858, + 85.148029, + 510, + 'left', + ], + [ + 'devighat-taadi-khola-bridge', + 'DEVIGHAT BRIDGE', + 27.8585278, + 85.1090318, + 350, + 'right', + ], + ].map(([id, title, lat, lon, heightM, placement]) => + Object.freeze({ id, title, lat, lon, heightM, placement }), + ), +); + +export const INCIDENT_OVERVIEW_HOLD_SEC = 11; diff --git a/src/data/bhoteKoshiLocator.js b/src/data/bhoteKoshiLocator.js new file mode 100644 index 000000000..166fe95b4 --- /dev/null +++ b/src/data/bhoteKoshiLocator.js @@ -0,0 +1,1214 @@ +import * as Cesium from 'cesium'; +import { + clearOverlaySource, + setOverlayEntries, +} from '../overlays/worldOverlay.js'; +import { governorRequestRender } from '../renderGovernor.js'; +import { + BHOTE_KOSHI_LOCATOR_CREDIT, + registerDynamicCredit, +} from './dataCredits.js'; +import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js'; +import { INCIDENT_OVERVIEW_PLACES } from './bhoteKoshiIncidentPlaces.js'; + +export const BHOTE_KOSHI_LOCATOR_LAYER_ID = 'bhote-koshi-locator'; +export const BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID = + 'bhote-koshi-locator-callout'; +export const 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_DOT_SIZE_PX = 7; +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'; + +const INCIDENT = Object.freeze({ + lat: 28.3632333, + lon: 85.4423639, + heightM: 5200, + title: 'BHOTE KOSHI FLOOD INCIDENT', + subtitle: 'NEPAL · 26 AUG 2026', +}); +const TRIGGER_RECORD = Object.freeze({ + lat: 28.271, + lon: 85.515, + heightM: 4200, + title: 'USGS INITIAL REPORT', + details: Object.freeze([ + '26 AUG 2026 · 08:37 NPT', + '28.2710° N · 85.5150° E', + 'INITIAL M4.4 · RECLASSIFIED M5.2 LANDSLIDE', + '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, + title: 'NEPAL', +}); +const NEARBY_CITIES = Object.freeze([ + Object.freeze({ + id: 'kathmandu', + title: 'KATHMANDU', + lat: 27.7017, + lon: 85.3206, + heightM: 3600, + placement: 'left', + }), + Object.freeze({ + id: 'bhaktapur', + title: 'BHAKTAPUR', + lat: 27.673, + lon: 85.43, + heightM: 3400, + placement: 'right', + }), + Object.freeze({ + id: 'dhulikhel', + title: 'DHULIKHEL', + lat: 27.6221, + lon: 85.5428, + heightM: 3600, + placement: 'right-lower', + }), +]); +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: subject.title, + details: [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: TRIGGER_RECORD.title, + details: [...TRIGGER_RECORD.details], + 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: + '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')} · ${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); + 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() { + _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', + label: 'NEPAL', + active: _presentation === NEPAL_CONTEXT_PRESENTATION, + title: + 'Show the Nepal context callout without a border or country fill', + params: { presentation: NEPAL_CONTEXT_PRESENTATION }, + }, + { + id: 'bhote-koshi-regional', + label: 'BHOTE', + active: _presentation === BHOTE_KOSHI_REGIONAL_PRESENTATION, + title: + 'Introduce Nepal, draw its border, then reveal the Bhote Koshi incident', + params: { presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION }, + }, + { + id: 'bhote-koshi-city-context', + label: 'CITIES', + active: _presentation === BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION, + title: + 'Keep the regional incident context and introduce nearby city labels', + params: { presentation: BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION }, + }, + { + id: 'bhote-koshi-incident-places', + label: 'PLACES', + active: _presentation === BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION, + title: + 'Introduce the reconstruction scene key places from trigger to downstream', + params: { presentation: BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION }, + }, + { + id: 'bhote-koshi-flood-path', + label: 'PATH', + active: _presentation === BHOTE_KOSHI_FLOOD_PATH_PRESENTATION, + title: + 'Animate the sourced cyan river-centerline route to Trishuli Bazaar', + params: { presentation: BHOTE_KOSHI_FLOOD_PATH_PRESENTATION }, + }, + { + id: 'bhote-koshi-path-overview', + label: 'OVERVIEW', + active: _presentation === BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION, + title: 'Keep the completed flood path and location labels visible', + params: { presentation: BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION }, + }, + { + id: 'bhote-koshi-trigger-record', + label: 'RECORD', + active: _presentation === BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION, + title: + 'Reveal the trigger record while making a slow camera approach', + 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', + }; + }, + }; +} + +const bhoteKoshiLocatorLayer = createBhoteKoshiLocatorLayer(); +export default bhoteKoshiLocatorLayer; diff --git a/src/data/bhoteKoshiLocator.test.mjs b/src/data/bhoteKoshiLocator.test.mjs new file mode 100644 index 000000000..450668e48 --- /dev/null +++ b/src/data/bhoteKoshiLocator.test.mjs @@ -0,0 +1,671 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFile } from 'node:fs/promises'; +import { INCIDENT_OVERVIEW_HOLD_SEC, INCIDENT_OVERVIEW_PLACES } from './bhoteKoshiIncidentPlaces.js'; +import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js'; +import * as Cesium from 'cesium'; +import { SceneDirector } from '../scenes/director.js'; +import { + anchoredCalloutPresentation, + BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION, + BHOTE_KOSHI_FLOOD_PATH_PRESENTATION, + BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION, + BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID, + BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION, + BHOTE_KOSHI_REGIONAL_PRESENTATION, + BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION, + closeRegionRing, + createBhoteKoshiLocatorLayer, + largestGeoJsonRing, + NEPAL_CONTEXT_PRESENTATION, + revealPolylinePositions, +} from './bhoteKoshiLocator.js'; + +function viewerFixture() { + const added = []; + const removed = []; + return { + creditDisplay: { addStaticCredit() {} }, + dataSources: { + add(source) { added.push(source); }, + remove(source) { removed.push(source); }, + }, + _test: { added, removed }, + }; +} + +function nonRunningAnimation() { + return { + scheduleFrame() { return 1; }, + cancelFrame() {}, + }; +} + +function overlayHostFixture() { + const publications = []; + const clears = []; + return { + setEntries(sourceId, entries, options) { + publications.push({ sourceId, entries, options }); + }, + clearSource(sourceId) { + clears.push(sourceId); + }, + _test: { publications, clears }, + }; +} + +test('Bhote Koshi locator closes valid region rings once', () => { + assert.deepEqual(closeRegionRing([[80, 26], [88, 26], [88, 30]]), [ + [80, 26], [88, 26], [88, 30], [80, 26], + ]); + assert.deepEqual(closeRegionRing([[80, 26], [88, 26], [80, 26]]), [ + [80, 26], [88, 26], [80, 26], + ]); + assert.deepEqual(closeRegionRing([[80, 26], ['bad', 27]]), []); +}); + +test('Bhote Koshi locator selects the largest GeoJSON country exterior', () => { + const main = [[80, 26], [88, 26], [88, 30], [80, 30], [80, 26]]; + const island = [[82, 25], [83, 25], [82, 25]]; + assert.equal(largestGeoJsonRing({ type: 'Polygon', coordinates: [main] }), main); + assert.equal(largestGeoJsonRing({ + type: 'MultiPolygon', + coordinates: [[island], [main]], + }), main); + assert.deepEqual(largestGeoJsonRing({ type: 'Point', coordinates: [84, 28] }), []); +}); + +test('Bhote Koshi locator reveals a border progressively', () => { + const positions = [ + new Cesium.Cartesian3(0, 0, 0), + new Cesium.Cartesian3(10, 0, 0), + new Cesium.Cartesian3(20, 0, 0), + ]; + assert.deepEqual(revealPolylinePositions(positions, 0), [positions[0], positions[0]]); + assert.deepEqual(revealPolylinePositions(positions, 0.25), [ + positions[0], + new Cesium.Cartesian3(5, 0, 0), + ]); + assert.equal(revealPolylinePositions(positions, 1), positions); +}); + +test('Bhote Koshi locator stages its anchored callout from dot to leader to text', () => { + assert.deepEqual(anchoredCalloutPresentation(0), { + alpha: 0, + leaderProgress: 0, + contentAlpha: 0, + scale: 0.96, + }); + assert.deepEqual(anchoredCalloutPresentation(1200), { + alpha: 1, + leaderProgress: 1, + contentAlpha: 1, + scale: 1, + }); +}); + +test('Bhote Koshi regional locator sequences Nepal, border, then the incident', async () => { + const viewer = viewerFixture(); + const overlayHost = overlayHostFixture(); + const renders = []; + let nowMs = 0; + const layer = createBhoteKoshiLocatorLayer({ + boundaryResolver: async () => ({ + name: 'Nepal', + ring: [[80, 26], [88, 26], [88, 30], [80, 30]], + }), + requestRender: (reason) => renders.push(reason), + overlayHost, + now: () => nowMs, + ...nonRunningAnimation(), + }); + + assert.equal(await layer.init(viewer), true); + assert.equal(await layer.enable(viewer, { origin: 'scene' }), true); + assert.equal(viewer._test.added.length, 1); + const source = viewer._test.added[0]; + const ids = source.entities.values.map((entity) => entity.id); + for (const id of [ + 'nepal-context-halo', + 'nepal-border-fill', + 'nepal-border-highlight', + 'bhote-koshi-incident-halo', + ]) { + assert.ok(ids.includes(id), `${id} belongs to the regional sequence`); + } + const incidentHalo = source.entities.getById('bhote-koshi-incident-halo'); + const border = source.entities.getById('nepal-border-highlight'); + const publication = overlayHost._test.publications.at(-1); + assert.equal(publication.sourceId, BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID); + assert.equal(publication.entries.length, 2); + const nepal = publication.entries.find(({ id }) => id === 'nepal-context-callout'); + const incident = publication.entries.find(({ id }) => id === 'bhote-koshi-incident-callout'); + assert.equal(nepal.variant, 'label'); + assert.equal(nepal.title, 'NEPAL'); + assert.equal(nepal.anchorDot, true); + assert.equal(incident.variant, 'card'); + assert.equal(incident.paintLane, 'thumbnail'); + assert.equal(incident.title, 'BHOTE KOSHI FLOOD INCIDENT'); + assert.deepEqual(incident.details, ['NEPAL · 26 AUG 2026']); + assert.equal(incident.anchorDot, true); + assert.equal(incident.leaderStyle, 'elbow'); + assert.equal('image' in incident, false); + assert.equal('requireImage' in incident, false); + assert.equal(incident.sourceAlpha(), 0); + assert.equal(incident.leaderProgress(), 0); + assert.equal(incident.contentAlpha(), 0); + assert.equal(incident.presentationScale(), 0.96); + assert.equal(nepal.sourceAlpha(), 0); + assert.ok(border.polyline.positions.getValue().length < 5); + assert.equal(incidentHalo.show.getValue(), false); + assert.equal(incidentHalo.point.pixelSize.getValue(), 16); + nowMs = 3100; + assert.equal(nepal.sourceAlpha(), 1); + assert.equal(border.polyline.positions.getValue().length, 5); + assert.equal(incidentHalo.show.getValue(), false); + nowMs = 3300; + assert.equal(incidentHalo.show.getValue(), true); + assert.equal(incident.sourceAlpha(), 0); + nowMs = 4500; + assert.equal(incident.sourceAlpha(), 1); + assert.equal(incident.leaderProgress(), 1); + assert.equal(incident.contentAlpha(), 1); + assert.equal(incident.presentationScale(), 1); + assert.equal(layer.getStats().status, 'nominal'); + assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION }); + assert.ok(renders.includes('bhote-koshi-locator-boundary')); + + assert.equal(await layer.disable(), true); + assert.equal(viewer._test.removed.length, 1); + assert.equal(overlayHost._test.clears.at(-1), BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID); + assert.equal(layer.getStats().status, 'idle'); +}); + +test('Bhote Koshi locator switches between Nepal context and the regional incident treatment', async () => { + const viewer = viewerFixture(); + const overlayHost = overlayHostFixture(); + const layer = createBhoteKoshiLocatorLayer({ + boundaryResolver: async () => ({ + name: 'Nepal', + ring: [[80, 26], [88, 26], [88, 30], [80, 30]], + }), + requestRender() {}, + overlayHost, + ...nonRunningAnimation(), + }); + + layer.setParams({ presentation: NEPAL_CONTEXT_PRESENTATION }); + await layer.enable(viewer); + assert.deepEqual(viewer._test.added[0].entities.values.map((entity) => entity.id), [ + 'nepal-context-halo', + ]); + const nepalLabel = overlayHost._test.publications.at(-1).entries[0]; + assert.equal(nepalLabel.variant, 'label'); + assert.equal(nepalLabel.placement, 'right'); + assert.equal(nepalLabel.anchorDot, true); + assert.equal(layer.getStats().coverage, 'Nepal context'); + assert.deepEqual(layer.getRowControls().chips.map(({ id, active }) => ({ id, active })), [ + { id: 'nepal-context', active: true }, + { id: 'bhote-koshi-regional', active: false }, + { id: 'bhote-koshi-city-context', active: false }, + { id: 'bhote-koshi-incident-places', active: false }, + { id: 'bhote-koshi-flood-path', active: false }, + { id: 'bhote-koshi-path-overview', active: false }, + { id: 'bhote-koshi-trigger-record', active: false }, + ]); + + layer.setParams({ presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION }); + assert.ok(viewer._test.added[0].entities.getById('nepal-border-highlight')); + assert.equal(viewer._test.added[0].entities.getById('bhote-koshi-city-kathmandu'), undefined); + assert.equal( + overlayHost._test.publications.at(-1).entries + .find(({ id }) => id === 'bhote-koshi-incident-callout').variant, + 'card', + ); + + layer.setParams({ presentation: NEPAL_CONTEXT_PRESENTATION }); + assert.deepEqual(viewer._test.added[0].entities.values.map((entity) => entity.id), [ + 'nepal-context-halo', + ]); + await layer.destroy(); +}); + +test('Incident Corridor reveals all fifteen overview pins and keeps layout active through the sequence', async () => { + const viewer = viewerFixture(); + const overlayHost = overlayHostFixture(); + let nowMs = 0; + let pulseFrame = null; + const layer = createBhoteKoshiLocatorLayer({ + boundaryResolver: async () => null, requestRender() {}, + overlayHost, + now: () => nowMs, + scheduleFrame(callback) { pulseFrame = callback; return 1; }, + cancelFrame() {}, + }); + layer.setParams({ presentation: BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION }); + await layer.enable(viewer); + const source = viewer._test.added[0]; + const event = JSON.parse(await readFile(new URL('../../public/events/bhote-koshi-2026/event.json', import.meta.url), 'utf8')); + assert.equal(event.evidenceSpine.length, 16); + assert.equal(event.evidenceSpine.at(-1).id, 'charaudi'); + assert.equal(source.entities.values.length, 15); + const labels = overlayHost._test.publications.at(-1).entries; + assert.equal(labels.length, 15); + assert.equal(overlayHost._test.publications.at(-1).options.moving, true); + for (const [index, point] of INCIDENT_OVERVIEW_PLACES.entries()) { + const id = `bhote-koshi-place-${point.id}`; + const pin = labels.find((entry) => entry.id === id); + assert.ok(pin, point.id); + assert.equal(pin.title, `${String(index + 1).padStart(2, '0')} · ${point.title}`); + assert.equal(pin.variant, 'label'); + const location = Cesium.Cartographic.fromCartesian(pin.position); + assert.ok(Math.abs(Cesium.Math.toDegrees(location.latitude) - point.lat) < 1e-6); + assert.ok(Math.abs(Cesium.Math.toDegrees(location.longitude) - point.lon) < 1e-6); + nowMs = 349 + index * 650; + assert.equal(pin.sourceAlpha(), 0); + nowMs += 1; + assert.equal(pin.sourceAlpha(), 0); + assert.equal(source.entities.values.filter(entity => entity.id.endsWith('-halo') && entity.show.getValue()).length, 1); + nowMs += 520; + assert.equal(pin.sourceAlpha(), 1); + } + assert.ok(nowMs < INCIDENT_OVERVIEW_HOLD_SEC * 1000); + // A repeat halo cycle must not erase already revealed labels. + nowMs = 350 + 15 * 650; + assert.equal(source.entities.getById('bhote-koshi-place-immediate-collapse-viewpoint-halo').show.getValue(), true); + assert.equal(labels.find(({ id }) => id === 'bhote-koshi-place-devighat-taadi-khola-bridge').sourceAlpha(), 1); + assert.equal(labels.some(({ id }) => id === 'bhote-koshi-place-charaudi'), false); + assert.equal(typeof pulseFrame, 'function'); + pulseFrame(); + assert.equal(overlayHost._test.publications.at(-1).options.moving, false); + layer.setParams({ presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION }); + assert.equal(source.entities.values.some(entity => entity.id.startsWith('bhote-koshi-place-')), false); + await layer.destroy(); +}); + +test('Bhote Koshi flood path draws a solid cyan sourced route after the place shot', async () => { + const viewer = viewerFixture(); + const overlayHost = overlayHostFixture(); + let nowMs = 0; + const layer = createBhoteKoshiLocatorLayer({ + boundaryResolver: async () => null, + requestRender() {}, + overlayHost, + now: () => nowMs, + ...nonRunningAnimation(), + }); + + layer.setParams({ presentation: BHOTE_KOSHI_FLOOD_PATH_PRESENTATION }); + await layer.enable(viewer); + const source = viewer._test.added[0]; + const path = source.entities.getById('bhote-koshi-flood-path'); + const labels = overlayHost._test.publications.at(-1).entries; + const trigger = labels.find(({ id }) => id === 'bhote-koshi-place-immediate-collapse-viewpoint'); + const bidur = labels.find(({ id }) => id === 'bhote-koshi-place-bidur-trishuli-bridge'); + + assert.equal(source.entities.values.length, 1); + assert.equal(labels.length, 15); + assert.equal(bidur.title, '14 · BIDUR BRIDGE'); + for (const place of INCIDENT_OVERVIEW_PLACES) { + const pin = labels.find(({ id }) => id === `bhote-koshi-place-${place.id}`); + assert.equal(pin.sourceAlpha(), 1, place.id); + } + assert.equal(path.polyline.clampToGround.getValue(), true); + assert.ok(path.polyline.material instanceof Cesium.ColorMaterialProperty); + assert.ok(path.polyline.material.color.getValue().equalsEpsilon( + Cesium.Color.fromCssColorString('#20e7f2').withAlpha(0.96), + Cesium.Math.EPSILON7, + )); + assert.equal(path.show.getValue(), false); + assert.equal(trigger.sourceAlpha(), 1); + assert.equal(bidur.sourceAlpha(), 1); + + nowMs = 250; + assert.equal(path.show.getValue(), true); + assert.equal(path.polyline.positions.getValue().length, 2); + nowMs = 2250; + const halfwayCount = path.polyline.positions.getValue().length; + assert.ok(Math.abs(halfwayCount - BHOTE_KOSHI_FLOOD_PATH.length / 2) <= 2); + nowMs = 4250; + assert.equal(path.polyline.positions.getValue().length, BHOTE_KOSHI_FLOOD_PATH.length); + assert.deepEqual(BHOTE_KOSHI_FLOOD_PATH[0], [85.48476, 28.33255]); + // More observation pins must not extend or straighten the sourced route. + assert.deepEqual(path.polyline.positions.getValue(), BHOTE_KOSHI_FLOOD_PATH.map(([lon, lat]) => ( + Cesium.Cartesian3.fromDegrees(lon, lat) + ))); + assert.deepEqual(BHOTE_KOSHI_FLOOD_PATH.at(-1), [85.14943, 27.92177]); + assert.equal(layer.getStats().coverage, 'Bhote Koshi · Flood path'); + assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_FLOOD_PATH_PRESENTATION }); + await layer.destroy(); +}); + +test('Bhote Koshi path-overview presentation keeps the route settled without Nepal border', async () => { + const viewer = viewerFixture(); + const overlayHost = overlayHostFixture(); + let nowMs = 0; + const layer = createBhoteKoshiLocatorLayer({ + boundaryResolver: async () => ({ + name: 'Nepal', + ring: [[80, 26], [88, 26], [88, 30], [80, 30]], + }), + requestRender() {}, + overlayHost, + now: () => nowMs, + ...nonRunningAnimation(), + }); + + layer.setParams({ presentation: BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION }); + await layer.enable(viewer); + const source = viewer._test.added[0]; + const path = source.entities.getById('bhote-koshi-flood-path'); + const labels = overlayHost._test.publications.at(-1).entries; + + assert.equal(source.entities.values.length, 1); + assert.equal(labels.length, 15); + for (const [index, place] of INCIDENT_OVERVIEW_PLACES.entries()) { + const pin = labels.find(({ id }) => id === `bhote-koshi-place-${place.id}`); + assert.equal(pin.title, `${String(index + 1).padStart(2, '0')} · ${place.title}`); + assert.equal(pin.sourceAlpha(), 1); + } + assert.equal(path.show.getValue(), true); + assert.equal(path.polyline.positions.getValue().length, BHOTE_KOSHI_FLOOD_PATH.length); + assert.ok(Cesium.Cartesian3.equals(path.polyline.positions.getValue()[0], + Cesium.Cartesian3.fromDegrees(85.48476, 28.33255))); + assert.equal(source.entities.getById('nepal-border-fill'), undefined); + assert.equal(source.entities.getById('nepal-border-highlight'), undefined); + assert.equal( + source.entities.getById('bhote-koshi-place-immediate-collapse-viewpoint-halo'), + undefined, + ); + assert.equal(layer.getStats().coverage, 'Bhote Koshi · Path overview'); + assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION }); + await layer.destroy(); +}); + +test('Bhote Koshi trigger-record presentation reveals sourced incident details', async () => { + const viewer = viewerFixture(); + const overlayHost = overlayHostFixture(); + let nowMs = 0; + const layer = createBhoteKoshiLocatorLayer({ + boundaryResolver: async () => null, + requestRender() {}, + overlayHost, + now: () => nowMs, + ...nonRunningAnimation(), + }); + + layer.setParams({ + presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION, + preview: true, + }); + await layer.enable(viewer); + const source = viewer._test.added[0]; + const publication = overlayHost._test.publications.at(-1); + const record = publication.entries[0]; + + assert.deepEqual(source.entities.values.map(({ id }) => id), [ + 'bhote-koshi-flood-path', + 'bhote-koshi-trigger-record-halo', + ]); + assert.equal(record.title, 'USGS INITIAL REPORT'); + assert.deepEqual(record.details, [ + '26 AUG 2026 · 08:37 NPT', + '28.2710° N · 85.5150° E', + 'INITIAL M4.4 · RECLASSIFIED M5.2 LANDSLIDE', + 'USGS EVENT us7000tbwb · OPEN SOURCE', + ]); + assert.equal(record.interactive, true); + assert.equal(typeof record.activate, 'function'); + assert.equal(record.sourceAlpha(), 0); + nowMs = 1200; + assert.equal(record.sourceAlpha(), 1); + assert.equal(source.entities.getById('nepal-border-highlight'), undefined); + assert.equal(layer.getStats().coverage, 'Bhote Koshi · Trigger record'); + assert.deepEqual(layer.getParams(), { + presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION, + }); + await layer.destroy(); +}); + +test('Bhote Koshi trigger-record waits for the scene camera, zooms, then orbits', async () => { + const viewer = viewerFixture(); + const overlayHost = overlayHostFixture(); + const moveStart = new Cesium.Event(); + const moveEnd = new Cesium.Event(); + const flights = []; + let armedCallback = null; + viewer.camera = { + moveStart, + moveEnd, + flyTo(options) { flights.push(options); }, + cancelFlight() {}, + }; + const layer = createBhoteKoshiLocatorLayer({ + boundaryResolver: async () => null, + requestRender() {}, + overlayHost, + scheduleDelay(callback) { + armedCallback = callback; + return 1; + }, + cancelDelay() {}, + ...nonRunningAnimation(), + }); + + layer.setParams({ presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION }); + await layer.enable(viewer); + assert.equal(flights.length, 0); + assert.equal(typeof armedCallback, 'function'); + moveStart.raiseEvent(); + assert.equal(flights.length, 0); + moveEnd.raiseEvent(); + assert.equal(flights.length, 1); + assert.equal(flights[0].duration, 4.6); + const destination = Cesium.Cartographic.fromCartesian(flights[0].destination); + assert.ok(Math.abs(Cesium.Math.toDegrees(destination.latitude) - 28.368) < 1e-6); + assert.ok(Math.abs(Cesium.Math.toDegrees(destination.longitude) - 85.473) < 1e-6); + assert.ok(Math.abs(destination.height - 10526) < 1e-4); + flights[0].complete(); + assert.equal(flights.length, 2); + assert.equal(flights[1].duration, 2.8); + const orbit = Cesium.Cartographic.fromCartesian(flights[1].destination); + assert.ok(Math.abs(Cesium.Math.toDegrees(orbit.latitude) - 28.3769) < 1e-6); + assert.ok(Math.abs(Cesium.Math.toDegrees(orbit.longitude) - 85.4923) < 1e-6); + assert.ok(Math.abs(orbit.height - 10526) < 1e-4); + await layer.destroy(); +}); + +for (const phase of ['timer', 'move-end', 'approach', 'orbit']) { + test(`Scene Stop revokes locator ${phase} and stale callbacks cannot own a successor`, async () => { + const viewer = viewerFixture(); + const timers = new Map(); + const callbacks = []; + const flights = []; + let nextTimer = 0; + const moveStart = new Cesium.Event(); + const moveEnd = new Cesium.Event(); + viewer.camera = { + moveStart, moveEnd, + flyTo(options) { flights.push(options); }, + cancelFlight() {}, + }; + const layer = createBhoteKoshiLocatorLayer({ + boundaryResolver: async () => null, + overlayHost: overlayHostFixture(), + requestRender() {}, + scheduleDelay(callback) { + callbacks.push(callback); + timers.set(++nextTimer, callback); + return nextTimer; + }, + cancelDelay(id) { timers.delete(id); }, + ...nonRunningAnimation(), + }); + const director = { + dataManager: { layers: new Map([[layer.id, { module: layer }]]) }, + viewer, + _sceneSeekGeneration: 0, + _loadGeneration: 0, + _cancelActiveSceneTravel: SceneDirector.prototype._cancelActiveSceneTravel, + }; + try { + layer.setParams({ presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION }); + await layer.enable(viewer); + if (phase === 'move-end') moveStart.raiseEvent(); + if (phase === 'approach' || phase === 'orbit') callbacks[0](); + if (phase === 'orbit') flights[0].complete(); + const oldFlights = [...flights]; + SceneDirector.prototype.stopScene.call(director); + assert.equal(timers.size, 0, 'Stop cancels the actual delayed work'); + assert.equal(moveStart.numberOfListeners, 0); + assert.equal(moveEnd.numberOfListeners, 0); + callbacks[0](); + moveEnd.raiseEvent(); + for (const flight of oldFlights) flight.complete(); + assert.equal(flights.length, oldFlights.length, 'no post-Stop approach or orbit'); + assert.equal(layer.getStats().count, 1, 'Stop preserves the visible locator'); + + layer.setParams({ presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION }); + callbacks.at(-1)(); + const successor = flights.at(-1); + const count = flights.length; + callbacks[0](); + for (const flight of oldFlights) { flight.cancel(); flight.complete(); } + assert.equal(flights.length, count, 'old callbacks cannot add a flight'); + successor.complete(); + assert.equal(flights.length, count + 1, 'successor retains its own orbit'); + } finally { await layer.destroy(); } + }); +} + +test('Bhote Koshi locator keeps the regional context settled while nearby cities stagger in', async () => { + const viewer = viewerFixture(); + const overlayHost = overlayHostFixture(); + let nowMs = 0; + const layer = createBhoteKoshiLocatorLayer({ + boundaryResolver: async () => ({ + name: 'Nepal', + ring: [[80, 26], [88, 26], [88, 30], [80, 30]], + }), + requestRender() {}, + overlayHost, + now: () => nowMs, + ...nonRunningAnimation(), + }); + + layer.setParams({ presentation: BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION }); + await layer.enable(viewer); + const source = viewer._test.added[0]; + const borderFill = source.entities.getById('nepal-border-fill'); + const entries = overlayHost._test.publications.at(-1).entries; + const incident = entries.find(({ id }) => id === 'bhote-koshi-incident-callout'); + const kathmandu = entries.find(({ id }) => id === 'bhote-koshi-city-kathmandu'); + const bhaktapur = entries.find(({ id }) => id === 'bhote-koshi-city-bhaktapur'); + const dhulikhel = entries.find(({ id }) => id === 'bhote-koshi-city-dhulikhel'); + + assert.equal(borderFill.show.getValue(), true); + assert.equal(incident.contentAlpha(), 1); + assert.equal(kathmandu.title, 'KATHMANDU'); + assert.equal(bhaktapur.title, 'BHAKTAPUR'); + assert.equal(dhulikhel.title, 'DHULIKHEL'); + assert.ok([kathmandu, bhaktapur, dhulikhel].every(({ variant, anchorDot }) => ( + variant === 'label' && anchorDot === true + ))); + assert.equal(kathmandu.sourceAlpha(), 0); + assert.equal(bhaktapur.sourceAlpha(), 0); + assert.equal(dhulikhel.sourceAlpha(), 0); + + nowMs = 350; + assert.equal(kathmandu.sourceAlpha(), 0); + assert.equal(bhaktapur.sourceAlpha(), 0); + nowMs = 850; + assert.equal(kathmandu.sourceAlpha(), 1); + assert.ok(bhaktapur.sourceAlpha() > 0); + assert.ok(dhulikhel.sourceAlpha() > 0); + nowMs = 1300; + assert.equal(dhulikhel.sourceAlpha(), 1); + assert.equal(layer.getStats().coverage, 'Bhote Koshi · Nearby cities'); + assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION }); + await layer.destroy(); +}); + +test('Bhote Koshi locator degrades to labels when the regional boundary is unavailable', async () => { + const viewer = viewerFixture(); + const overlayHost = overlayHostFixture(); + const originalWarn = console.warn; + console.warn = () => {}; + const layer = createBhoteKoshiLocatorLayer({ + boundaryResolver: async () => null, + requestRender() {}, + overlayHost, + ...nonRunningAnimation(), + }); + try { + await layer.enable(viewer); + const ids = viewer._test.added[0].entities.values.map((entity) => entity.id); + assert.ok(ids.includes('nepal-context-halo')); + assert.ok(ids.includes('bhote-koshi-incident-halo')); + assert.equal(ids.includes('bhote-koshi-city-kathmandu'), false); + assert.equal(ids.includes('nepal-border-highlight'), false); + assert.deepEqual(overlayHost._test.publications.at(-1).entries.map(({ id }) => id), [ + 'nepal-context-callout', + 'bhote-koshi-incident-callout', + ]); + assert.equal(layer.getStats().status, 'degraded'); + } finally { + console.warn = originalWarn; + await layer.destroy(); + } +}); + +test('Bhote Koshi regional locator waits for its boundary before starting the full sequence', async () => { + const viewer = viewerFixture(); + const overlayHost = overlayHostFixture(); + let releaseBoundary; + const boundary = new Promise((resolve) => { releaseBoundary = resolve; }); + const layer = createBhoteKoshiLocatorLayer({ + boundaryResolver: async () => boundary, + requestRender() {}, + overlayHost, + ...nonRunningAnimation(), + }); + + const enabling = layer.enable(viewer); + assert.ok(viewer._test.added[0].entities.getById('nepal-context-halo')); + assert.equal(viewer._test.added[0].entities.getById('nepal-border-highlight'), undefined); + assert.equal(viewer._test.added[0].entities.getById('bhote-koshi-incident-halo'), undefined); + assert.equal(overlayHost._test.publications.length, 1); + assert.deepEqual(overlayHost._test.publications[0].entries.map(({ id }) => id), [ + 'nepal-context-callout', + ]); + + releaseBoundary({ ring: [[80, 26], [88, 26], [88, 30], [80, 30]] }); + await enabling; + assert.ok(viewer._test.added[0].entities.getById('bhote-koshi-incident-halo')); + assert.ok(viewer._test.added[0].entities.getById('nepal-border-highlight')); + assert.equal(viewer._test.added[0].entities.getById('bhote-koshi-city-kathmandu'), undefined); + assert.equal(overlayHost._test.publications.length, 2); + assert.deepEqual(overlayHost._test.publications.at(-1).entries.map(({ id }) => id), [ + 'nepal-context-callout', + 'bhote-koshi-incident-callout', + ]); + await layer.destroy(); +}); + +test('Bhote Koshi locator cancels a stale boundary resolution and removes its surface', async () => { + const viewer = viewerFixture(); + const controller = new AbortController(); + let releaseBoundary; + const boundary = new Promise((resolve) => { releaseBoundary = resolve; }); + const layer = createBhoteKoshiLocatorLayer({ + boundaryResolver: async () => boundary, + requestRender() {}, + ...nonRunningAnimation(), + }); + const enabling = layer.enable(viewer, { signal: controller.signal }); + controller.abort(); + releaseBoundary({ ring: [[80, 26], [88, 26], [88, 30]] }); + await assert.rejects(enabling, (error) => error?.name === 'AbortError'); + assert.equal(viewer._test.removed.length, 1); +}); diff --git a/src/data/bhoteKoshiShotPaths.js b/src/data/bhoteKoshiShotPaths.js new file mode 100644 index 000000000..adda5ba18 --- /dev/null +++ b/src/data/bhoteKoshiShotPaths.js @@ -0,0 +1,97 @@ +import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js'; + +export const SOURCE_PATH_BEAT_IDS = Object.freeze([ + 'debris-dammed-lake', + 'second-landslide', + 'dhunche', + 'mailung-upper-trishuli', + 'mailung-bazzar', + 'dandagaun', + 'dandagaun-viewpoint', + 'betrawati-bazaar', + 'bhainse', + 'bidur-trishuli-bridge', +]); + +function nearestVertex(point, route) { + if (!point || !Number.isFinite(point.lon) || !Number.isFinite(point.lat)) + return -1; + const longitudeScale = Math.cos((point.lat * Math.PI) / 180); + let nearest = -1; + let distance = 1000; // Witness viewpoints can sit beside the river, not on it. + route.forEach(([lon, lat], index) => { + const metres = + Math.hypot((lon - point.lon) * longitudeScale, lat - point.lat) * 111195; + if (metres < distance) { + distance = metres; + nearest = index; + } + }); + return nearest; +} + +/** Split the already-bundled GeoPera centreline; never connect witness pins. */ +export function buildSourceShotPaths(event, route = BHOTE_KOSHI_FLOOD_PATH) { + const observations = new Map( + (event.evidenceSpine || []).map((point) => [point.id, point]), + ); + const result = new Map(); + const add = (id, from, to, { joinComparison = false } = {}) => { + let start = nearestVertex(from, route); + const end = nearestVertex(to, route); + if (start < 0 || end <= start) return; + const startHeight = from.fallbackElevationM ?? from.elevationM; + const endHeight = to.fallbackElevationM ?? to.elevationM; + if (!Number.isFinite(startHeight) || !Number.isFinite(endHeight)) return; + if (joinComparison) { + // The raster corridor ends between simplified centreline vertices. + // Snapping to its nearest vertex can move the head upstream on entry. + // Keep that already-sourced endpoint and continue to the next vertex + // downstream of it, rather than replaying the upstream half-segment. + const [lon, lat] = route[start]; + const [nextLon, nextLat] = route[start + 1]; + const scale = Math.cos((from.lat * Math.PI) / 180); + const along = + (from.lon - lon) * (nextLon - lon) * scale * scale + + (from.lat - lat) * (nextLat - lat); + if (along > 0) start++; + } + const coordinates = route.slice(start, end + 1); + if ( + joinComparison && + (coordinates[0][0] !== from.lon || coordinates[0][1] !== from.lat) + ) { + coordinates.unshift([from.lon, from.lat]); + } + result.set( + id, + coordinates.map(([lon, lat], index) => ({ + lon, + lat, + // Stable source profile for explicit source-elevation shots; otherwise a + // fallback when neither rendered mesh nor elevation terrain can be sampled. + fallbackElevationM: + startHeight + + ((endHeight - startHeight) * index) / (coordinates.length - 1), + })), + ); + }; + add( + 'debris-dammed-lake', + observations.get('debris-dammed-lake'), + observations.get('second-landslide'), + ); + add( + 'second-landslide', + observations.get('second-landslide'), + observations.get('gyirong-border-gate'), + ); + let previous = event.reconstruction?.corridor?.at(-1); + for (const id of SOURCE_PATH_BEAT_IDS.slice(2)) { + const point = observations.get(id); + add(id, previous, point, { joinComparison: id === 'dhunche' }); + // Missing coverage breaks continuity rather than inventing a long connector. + previous = point; + } + return result; +} diff --git a/src/data/bhoteKoshiShotPaths.test.mjs b/src/data/bhoteKoshiShotPaths.test.mjs new file mode 100644 index 000000000..014d0b030 --- /dev/null +++ b/src/data/bhoteKoshiShotPaths.test.mjs @@ -0,0 +1,58 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { buildSourceShotPaths, SOURCE_PATH_BEAT_IDS } from './bhoteKoshiShotPaths.js'; +import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js'; + +const event = JSON.parse(await readFile(new URL('../../public/events/bhote-koshi-2026/event.json', import.meta.url))); + +test('overview and evidence paths start at the lake reach, never the earlier trigger reach', () => { + const lakeStart = [85.48476, 28.33255]; + assert.deepEqual(BHOTE_KOSHI_FLOOD_PATH[0], lakeStart); + assert.equal(BHOTE_KOSHI_FLOOD_PATH.some(([lon, lat]) => lon === 85.5306 && lat === 28.2916), false); + const paths = buildSourceShotPaths(event); + const first = paths.get('debris-dammed-lake')[0]; + assert.deepEqual([first.lon, first.lat], lakeStart); + assert.deepEqual(BHOTE_KOSHI_FLOOD_PATH.slice(0, paths.get('debris-dammed-lake').length), + paths.get('debris-dammed-lake').map(({lon, lat}) => [lon, lat])); +}); + +test('ten shot paths use only the existing centreline and join at shared endpoints', () => { + const paths = buildSourceShotPaths(event); + assert.deepEqual([...paths.keys()], SOURCE_PATH_BEAT_IDS); + const vertices = new Set(BHOTE_KOSHI_FLOOD_PATH.map(([lon, lat]) => `${lon},${lat}`)); + const boundary = event.reconstruction.corridor.at(-1); + vertices.add(`${boundary.lon},${boundary.lat}`); // Exact sourced comparison handoff. + for (const points of paths.values()) { + assert.ok(points.length >= 2); + assert.ok(points.every(({ lon, lat, fallbackElevationM }) => ( + vertices.has(`${lon},${lat}`) && Number.isFinite(fallbackElevationM) + ))); + } + const coordinate = (point) => [point.lon, point.lat]; + for (const ids of [SOURCE_PATH_BEAT_IDS.slice(0, 2), SOURCE_PATH_BEAT_IDS.slice(2)]) { + for (let index = 1; index < ids.length; index++) { + assert.deepEqual(coordinate(paths.get(ids[index - 1]).at(-1)), coordinate(paths.get(ids[index])[0])); + } + } + assert.deepEqual(coordinate(paths.get('bidur-trishuli-bridge').at(-1)), BHOTE_KOSHI_FLOOD_PATH.at(-1)); + for (const id of ['immediate-collapse-viewpoint', 'devighat-taadi-khola-bridge', 'charaudi']) { + assert.equal(paths.has(id), false, 'out-of-coverage media must not gain a fabricated river route'); + } +}); + +test('missing and distant observations fail closed without connecting witness coordinates', () => { + const broken = structuredClone(event); + broken.evidenceSpine.find(({ id }) => id === 'dhunche').lon = 0; + const paths = buildSourceShotPaths(broken); + assert.equal(paths.has('dhunche'), false); + assert.equal(paths.has('mailung-upper-trishuli'), false); + assert.equal(buildSourceShotPaths({}).size, 0); +}); + +test('Dhunche starts at the exact comparison endpoint without stepping upstream first', () => { + const boundary = event.reconstruction.corridor.at(-1); + const path = buildSourceShotPaths(event).get('dhunche'); + assert.deepEqual([path[0].lon, path[0].lat], [boundary.lon, boundary.lat]); + assert.ok(path[1].lat < boundary.lat, 'the first new vertex is downstream of the comparison endpoint'); +}); diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index c876b9a55..76029ace8 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -312,6 +312,28 @@ export function transitFeedCredit(feed) { }; } +/** Registered when the Bhote Koshi event reconstruction activates. */ +export const BHOTE_KOSHI_CREDIT = { + key: 'bhote-koshi-2026', + html: + 'Bhote Koshi 2026 event imagery and derived reconstruction: ' + + 'Vantor Open Data ' + + 'and GeoPera ' + + '(CC BY-NC 4.0); terrain © Re:Earth / Mapterhorn (CC BY 4.0)', +}; + +/** Registered when the scene-friendly Nepal incident locator activates. */ +export const BHOTE_KOSHI_LOCATOR_CREDIT = { + key: 'bhote-koshi-locator', + html: + 'Nepal administrative boundary and nearby-city locations: ' + + '© OpenStreetMap contributors ' + + '(ODbL 1.0); incident-place anchors use an owner-curated, source-verified geolocation union; ' + + 'flood corridor derived from the ' + + 'GeoPera river centerline ' + + '(CC BY-NC 4.0)', +}; + /** @type {Set