diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f86a298c..640740864 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +- 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. @@ -14,6 +25,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 9fc311be7..aa561b15c 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,31 @@ # God's Eye View Current State +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. @@ -733,7 +759,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 8a0b6f7b9..4c596c942 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", @@ -1122,6 +1128,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", @@ -1691,6 +1704,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 = + ''; + const player = documentRef.createElement('div'); + player.className = 'bhote-embedded-callout-player'; + const footer = documentRef.createElement('footer'); + const title = documentRef.createElement('strong'); + title.textContent = String( + observation?.shortTitle || observation?.title || 'Witness source', + ).toUpperCase(); + const status = documentRef.createElement('small'); + status.setAttribute('role', 'status'); + status.textContent = `${providerLabel(source.provider)} · EMBEDDED SOURCE`; + const link = documentRef.createElement('a'); + link.href = source.url; + link.target = '_blank'; + link.rel = 'noopener noreferrer'; + link.textContent = 'OPEN ORIGINAL ↗'; + footer.append(title, status, link); + const body = documentRef.createElement('div'); + body.className = 'bhote-embedded-callout-body'; + body.append(player, footer); + card.append(leader, body); + root.append(card); + record = { + key, + source, + anchor, + card, + leader, + player, + status, + entered: false, + }; + + if (source.provider === 'x') { + void mountX(record, token); + } else if (source.provider === 'facebook') { + bindFacebook(record, token, warmKey, autoplay); + } else { + const iframe = documentRef.createElement('iframe'); + iframe.src = embeddedMediaFrameUrl(source, { + autoplay, + startAtSec, + endAtSec, + }); + const controlled = + autoplay && + Number.isFinite(Number(endAtSec)) && + Number(endAtSec) > startAtSec; + if (controlled) { + const url = new URL(iframe.src); + url.searchParams.set('enablejsapi', '1'); + if (globalRef.location?.origin) + url.searchParams.set('origin', globalRef.location.origin); + iframe.src = url.href; + } + iframe.title = `${providerLabel(source.provider)} media: ${observation?.title || 'witness source'}`; + iframe.allow = + 'autoplay; encrypted-media; picture-in-picture; fullscreen'; + iframe.allowFullscreen = true; + iframe.loading = 'eager'; + iframe.referrerPolicy = 'strict-origin-when-cross-origin'; + iframe.addEventListener?.( + 'load', + () => { + if (token !== generation || record?.key !== key) return; + clearWarm(warmKey); + if ( + !record.youtubeSession || + ['starting', 'playing'].includes( + record.youtubeSession.getState().phase, + ) + ) { + status.textContent = `${providerLabel(source.provider)} · PROVIDER CONTROLS`; + } + position(); + }, + { once: true }, + ); + player.append(iframe); + if (controlled) { + const current = record; + current.youtubeSession = createTrimmedYouTubePlayback({ + iframe, + endAtSec: Number(endAtSec), + startAtSec, + globalRef, + loadApi: () => youtubeLoader(documentRef, globalRef), + isCurrent: () => token === generation && record === current, + onStatus: (phase) => { + if (phase !== 'completed') + status.textContent = `YOUTUBE ${phase.toUpperCase()} · OPEN ORIGINAL`; + }, + }); + } + } + position(); + return true; + } + + function destroy() { + hide({ immediate: true }); + for (const current of [...leavingRecords]) removeRecord(current); + clearWarm(); + removePostRender?.(); + root.remove(); + } + + return { + warm, + show, + getPlaybackState: () => record?.youtubeSession?.getState() || null, + play: () => setPlayback(true), + pause: () => setPlayback(false), + hide, + destroy, + }; +} diff --git a/src/data/bhoteKoshiEmbeddedMedia.test.mjs b/src/data/bhoteKoshiEmbeddedMedia.test.mjs new file mode 100644 index 000000000..5223fff6c --- /dev/null +++ b/src/data/bhoteKoshiEmbeddedMedia.test.mjs @@ -0,0 +1,345 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; + +import { + createBhoteKoshiEmbeddedMedia, + createTrimmedYouTubePlayback, + embeddedMediaFrameUrl, + publicEmbeddedMediaUrl, + resolveEmbeddedMediaSource, +} from './bhoteKoshiEmbeddedMedia.js'; + +function trimmedPlaybackFixture() { + let now = 0; + let nextId = 0; + const timers = new Map(); + const globalRef = { + setTimeout(fn, delay) { const id = ++nextId; timers.set(id, { at: now + delay, fn }); return id; }, + clearTimeout(id) { timers.delete(id); }, + }; + const advance = (ms) => { + const until = now + ms; + while (true) { + const entry = [...timers].filter(([, item]) => item.at <= until).sort((a, b) => a[1].at - b[1].at)[0]; + if (!entry) break; + const [id, item] = entry; timers.delete(id); now = item.at; item.fn(); + } + now = until; + }; + let events; + const calls = []; + let time = 0; + let state = -1; + const player = { + mute: () => calls.push('mute'), playVideo: () => calls.push('play'), + pauseVideo: () => calls.push('pause'), destroy: () => calls.push('destroy'), + getCurrentTime: () => time, + getPlayerState: () => state, + }; + const api = { Player: class { constructor(_iframe, options) { events = options.events; return player; } } }; + const statuses = []; + const session = createTrimmedYouTubePlayback({ iframe: {}, endAtSec: 7, globalRef, + loadApi: () => api, onStatus: (phase) => statuses.push(phase) }); + return { session, advance, calls, timers, statuses, player, get events() { return events; }, setTime(value) { time = value; }, setState(value) { state = value; } }; +} + +test('trimmed YouTube reconciles already-playing READY and missed PLAYING callbacks', async () => { + for (const alreadyPlaying of [true, false]) { + const f = trimmedPlaybackFixture(); + await Promise.resolve(); await Promise.resolve(); + if (alreadyPlaying) { f.setState(1); f.setTime(1); } + f.events.onReady({ target: f.player }); + if (!alreadyPlaying) { f.setState(1); f.setTime(1); f.advance(100); } + f.setTime(6); f.advance(5000); + assert.deepEqual(f.session.getState(), { phase: 'playing', currentTime: 6 }); + f.setTime(7); f.advance(100); + assert.deepEqual(f.session.getState(), { phase: 'completed', currentTime: 7 }); + assert.deepEqual(f.statuses, ['completed']); + assert.equal(f.timers.size, 0); + f.session.destroy(); + } +}); + +test('trimmed YouTube accepts ENDED before PLAYING and never replays a completed READY', async () => { + for (const endedCallback of [true, false]) { + const f = trimmedPlaybackFixture(); + await Promise.resolve(); await Promise.resolve(); + f.setState(0); f.setTime(7); + if (endedCallback) f.events.onStateChange({ data: 0, target: f.player }); + f.events.onReady({ target: f.player }); + assert.deepEqual(f.session.getState(), { phase: 'completed', currentTime: 7 }); + assert.deepEqual(f.calls, ['pause']); + assert.deepEqual(f.statuses, ['completed']); + assert.equal(f.timers.size, 0); + f.session.destroy(); + } +}); + +test('trimmed YouTube waits through delayed startup, stops at source 0:07, and releases its timers', async () => { + const f = trimmedPlaybackFixture(); + await Promise.resolve(); await Promise.resolve(); + f.advance(3000); + assert.equal(f.session.getState().phase, 'starting'); + f.events.onReady({ target: f.player }); + f.events.onStateChange({ data: 1 }); + f.setTime(4); f.advance(4000); + assert.equal(f.session.getState().phase, 'playing', 'seven wall-clock seconds do not truncate a delayed clip'); + f.setTime(7); f.advance(100); + assert.deepEqual(f.session.getState(), { phase: 'completed', currentTime: 7 }); + assert.deepEqual(f.statuses, ['completed']); + assert.equal(f.timers.size, 0); + assert.equal(f.calls.at(-1), 'pause'); + f.session.destroy(); +}); + +test('trimmed YouTube bounds blocked, failed, stalled and never-started playback', async () => { + for (const outcome of ['blocked', 'unavailable', 'timeout', 'stalled']) { + const f = trimmedPlaybackFixture(); + await Promise.resolve(); await Promise.resolve(); + if (outcome === 'blocked') f.events.onAutoplayBlocked(); + else if (outcome === 'unavailable') f.events.onError(); + else if (outcome === 'stalled') { f.events.onStateChange({ data: 1 }); f.advance(12000); } + else f.advance(5000); + assert.equal(f.session.getState().phase, outcome === 'stalled' ? 'timeout' : outcome); + assert.equal(f.timers.size, 0); + const calls = [...f.calls]; + f.events.onReady({ target: f.player }); + f.events.onStateChange({ data: 1 }); + assert.deepEqual(f.calls, calls, 'late provider callbacks cannot restart a terminal attempt'); + f.session.destroy(); + } +}); + +test('trimmed YouTube teardown revokes pending SDK and player callbacks', async () => { + const f = trimmedPlaybackFixture(); + await Promise.resolve(); await Promise.resolve(); + f.session.destroy(); + f.events.onReady({ target: f.player }); + f.events.onStateChange({ data: 1 }); + f.events.onError(); f.advance(20000); + assert.deepEqual(f.calls, ['destroy']); + assert.deepEqual(f.statuses, []); + assert.equal(f.timers.size, 0); + let constructCount = 0; + let resolveApi; + const session = createTrimmedYouTubePlayback({ iframe: {}, endAtSec: 7, + loadApi: () => new Promise((resolve) => { resolveApi = resolve; }) }); + await Promise.resolve(); + session.destroy(); + resolveApi({ Player: class { constructor() { constructCount++; } } }); + await Promise.resolve(); await Promise.resolve(); + assert.equal(constructCount, 0); +}); + +test('pausing YouTube removes its frame and rejects a late load from the stopped generation', () => { + const nodes = []; + class Element extends EventTarget { + constructor(tag) { super(); this.tag = tag; this.children = []; this.dataset = {}; this.classList = { add() {}, remove() {} }; nodes.push(this); } + setAttribute() {} + append(...children) { for (const child of children) { child.parent = this; this.children.push(child); } } + remove() { if (this.parent) this.parent.children = this.parent.children.filter((child) => child !== this); this.parent = null; } + } + const documentRef = { + body: new Element('body'), + createElement: (tag) => new Element(tag), + createElementNS: (_, tag) => new Element(tag), + }; + const media = createBhoteKoshiEmbeddedMedia({ viewer: {}, documentRef, globalRef: {} }); + const options = { observation: { title: 'Clip', media: { sourceUrl: 'https://youtu.be/DbqRexFxv3k' } }, anchor: {}, autoplay: true }; + assert.equal(media.show(options), true); + const frame = nodes.find((node) => node.tag === 'iframe'); + const card = nodes.find((node) => node.tag === 'section'); + assert.ok(card.parent); + assert.equal(media.pause(), true); + assert.equal(card.parent, null); + frame.dispatchEvent(new Event('load')); + assert.equal(card.parent, null); + assert.equal(media.show(options), true, 'an explicit replay creates a fresh player'); + media.destroy(); + assert.equal(documentRef.body.children.length, 0); +}); + +test('resolves supported embedded callout providers from canonical public URLs', () => { + assert.deepEqual( + resolveEmbeddedMediaSource('https://www.youtube.com/watch?v=DbqRexFxv3k'), + { + provider: 'youtube', + id: 'DbqRexFxv3k', + url: 'https://www.youtube.com/watch?v=DbqRexFxv3k', + }, + ); + assert.equal( + resolveEmbeddedMediaSource('https://www.facebook.com/reel/1571491657757829').provider, + 'facebook', + ); + assert.deepEqual( + resolveEmbeddedMediaSource('https://x.com/Indowatchosint/status/2092640554568007958'), + { + provider: 'x', + id: '2092640554568007958', + url: 'https://x.com/Indowatchosint/status/2092640554568007958', + }, + ); + assert.equal( + resolveEmbeddedMediaSource('https://twitter.com/Indowatchosint/status/2092640554568007958').provider, + 'x', + ); + assert.equal( + resolveEmbeddedMediaSource('https://www.youtube.com/shorts/DbqRexFxv3k').id, + 'DbqRexFxv3k', + ); +}); + +test('rejects deceptive hosts, non-post X links, private targets, and iframe markup', () => { + for (const value of [ + 'https://x.com.evil.example/user/status/2092640554568007958', + 'https://x.com/Indowatchosint', + 'https://youtube.com.evil.example/watch?v=DbqRexFxv3k', + 'http://www.youtube.com/watch?v=DbqRexFxv3k', + 'https://localhost/video.mp4', + '', + ]) { + assert.equal(resolveEmbeddedMediaSource(value), null, value); + } + assert.equal(publicEmbeddedMediaUrl('https://user:secret@x.com/name/status/123'), null); +}); + +test('builds privacy-aware provider frames and keeps X on its widget runtime', () => { + const youtube = resolveEmbeddedMediaSource('https://youtu.be/DbqRexFxv3k'); + const youtubeFrame = new URL(embeddedMediaFrameUrl(youtube, { autoplay: true })); + assert.equal(youtubeFrame.hostname, 'www.youtube-nocookie.com'); + assert.equal(youtubeFrame.pathname, '/embed/DbqRexFxv3k'); + assert.equal(youtubeFrame.searchParams.get('autoplay'), '1'); + assert.equal(youtubeFrame.searchParams.get('mute'), '1'); + + const timestampedFrame = new URL(embeddedMediaFrameUrl(youtube, { + autoplay: true, + startAtSec: 26, + })); + assert.equal(timestampedFrame.searchParams.get('start'), '26'); + + const facebook = resolveEmbeddedMediaSource('https://www.facebook.com/watch/?v=456'); + const facebookFrame = new URL(embeddedMediaFrameUrl(facebook, { autoplay: false })); + assert.equal(facebookFrame.hostname, 'www.facebook.com'); + assert.equal(facebookFrame.pathname, '/plugins/video.php'); + assert.equal(facebookFrame.searchParams.get('href'), facebook.url); + assert.equal(facebookFrame.searchParams.get('autoplay'), 'false'); + + const x = resolveEmbeddedMediaSource('https://x.com/Indowatchosint/status/2092640554568007958'); + assert.equal(embeddedMediaFrameUrl(x), null); +}); + +test('Mailung playback stops at source timestamp 0:07 without changing other clips', async () => { + const event = JSON.parse(await readFile(new URL('../../public/events/bhote-koshi-2026/event.json', import.meta.url), 'utf8')); + const bounded = event.evidenceSpine.filter(point => point.media?.embedEndAtSec != null); + assert.deepEqual(bounded.map(point => point.id), ['mailung-bazzar']); + const media = bounded[0].media; + assert.equal(media.embedEndAtSec, 7); + const source = resolveEmbeddedMediaSource(media.sourceUrl); + for (const autoplay of [false, true]) { + const url = new URL(embeddedMediaFrameUrl(source, { + autoplay, startAtSec: media.clipInSec, endAtSec: media.embedEndAtSec, + })); + assert.equal(url.searchParams.get('end'), '7'); + } + for (const endAtSec of [undefined, null, NaN, Infinity, -1, 0, 3]) { + const url = new URL(embeddedMediaFrameUrl(source, {startAtSec: 3, endAtSec})); + assert.equal(url.searchParams.has('end'), false); + } +}); + +function preloadFixture(options = {}) { + class Element extends EventTarget { + constructor(tag) { + super(); this.tag = tag; this.children = []; this.dataset = {}; + this.classList = { add() {}, remove() {}, contains() { return false; } }; + } + setAttribute() {} + removeAttribute() {} + append(...children) { + for (const child of children) { child.remove(); child.parent = this; this.children.push(child); } + } + remove() { + if (this.parent) this.parent.children = this.parent.children.filter(child => child !== this); + this.parent = null; + } + } + const documentRef = { + body: new Element('body'), + createElement: tag => new Element(tag), + createElementNS: (_, tag) => new Element(tag), + }; + const media = createBhoteKoshiEmbeddedMedia({ viewer: {}, documentRef, globalRef: {}, ...options }); + return { media, root: documentRef.body.children[0] }; +} + +for (const operation of ['pause', 'hide', 'destroy']) { + test(`${operation} removes a hidden YouTube preload`, () => { + const { media, root } = preloadFixture(); + media.warm({ sourceUrl: 'https://youtu.be/DbqRexFxv3k' }); + assert.equal(root.children.length, 1); + media[operation](); + assert.equal(root.children.length, 0); + media.destroy(); + }); +} + +test('camera updates retain a preload until the visible player loads', () => { + const { media, root } = preloadFixture(); + const observation = { media: { sourceUrl: 'https://youtu.be/DbqRexFxv3k' } }; + media.warm({ observation }); + const frame = root.children[0]; + media.hide({ preserveWarm: true }); + media.warm({ observation }); + assert.equal(root.children[0], frame); + assert.equal(media.show({ observation, anchor: {} }), true); + assert.equal(frame.parent, root, 'keep the preload while the visible player starts'); + const card = root.children.find(node => node.tag === 'section'); + const body = card.children.find(node => node.className === 'bhote-embedded-callout-body'); + const player = body.children.find(node => node.className === 'bhote-embedded-callout-player'); + player.children.find(node => node.tag === 'iframe').dispatchEvent(new Event('load')); + assert.equal(frame.parent, null, 'visible player readiness releases the preload'); + media.destroy(); + assert.equal(root.children.length, 0); +}); + +test('hiding revokes a pending Facebook SDK before it can mount or subscribe', async () => { + let resolveApi; + const calls = []; + const { media, root } = preloadFixture({ + facebookLoader: () => new Promise(resolve => { resolveApi = resolve; }), + }); + media.warm({ sourceUrl: 'https://www.facebook.com/reel/1571491657757829' }); + assert.equal(root.children.length, 1); + media.hide(); + assert.equal(root.children.length, 0); + resolveApi({ Event: { subscribe() { calls.push('subscribe'); } }, XFBML: { parse() { calls.push('parse'); } } }); + await Promise.resolve(); await Promise.resolve(); + assert.deepEqual(calls, []); + media.destroy(); +}); + +test('pausing cancels a mounted Facebook preload and rejects its late ready callback', async () => { + let ready; + let unsubscribed = 0; + const timers = new Set(); + const playerCalls = []; + const { media, root } = preloadFixture({ + globalRef: { setTimeout(fn) { timers.add(fn); return fn; }, clearTimeout(fn) { timers.delete(fn); } }, + facebookLoader: async () => ({ + Event: { subscribe(_name, callback) { ready = callback; }, unsubscribe() { unsubscribed++; } }, + XFBML: { parse() {} }, + }), + }); + media.warm({ sourceUrl: 'https://www.facebook.com/reel/1571491657757829' }); + await Promise.resolve(); await Promise.resolve(); + assert.equal(timers.size, 1); + media.pause(); + assert.equal(root.children.length, 0); + assert.equal(timers.size, 0); + assert.equal(unsubscribed, 1); + ready({ type: 'video', instance: { play() { playerCalls.push('play'); }, pause() { playerCalls.push('pause'); } } }); + assert.deepEqual(playerCalls, []); + media.destroy(); +}); diff --git a/src/data/bhoteKoshiEvent.js b/src/data/bhoteKoshiEvent.js new file mode 100644 index 000000000..d53999f21 --- /dev/null +++ b/src/data/bhoteKoshiEvent.js @@ -0,0 +1,3804 @@ +import * as Cesium from 'cesium'; +import { isPointerFree } from './inputOwnership.js'; +import { + clearOverlaySource, + hitTestWorldOverlay, + setOverlayEntries, + setOverlaySourceVisible, +} from '../overlays/worldOverlay.js'; +import { WORLD_OVERLAY_STYLE } from '../overlays/worldOverlayTokens.js'; +import { + governorRequestRender, + holdContinuousRender, + releaseContinuousRender, +} from '../renderGovernor.js'; +import { announceNavigationAuthority } from '../navigationPolicy.js'; +import { BHOTE_KOSHI_CREDIT, registerDynamicCredit } from './dataCredits.js'; +import { + buildSourceShotPaths, + SOURCE_PATH_BEAT_IDS, +} from './bhoteKoshiShotPaths.js'; +import { + createBhoteKoshiEmbeddedMedia, + resolveEmbeddedMediaSource, +} from './bhoteKoshiEmbeddedMedia.js'; + +export const BHOTE_KOSHI_LAYER_ID = 'bhote-koshi-2026'; +export const BHOTE_KOSHI_OVERLAY_SOURCE_ID = 'bhote-koshi-witnesses'; +export const BHOTE_KOSHI_PLAYBACK_SECONDS = 90; +export const BHOTE_KOSHI_PROLOGUE_END = 0.22; +export const BHOTE_KOSHI_CORRIDOR_END = 0.84; +export const BHOTE_KOSHI_CAMERA_HOLD_RATIO = 0.62; +export const BHOTE_KOSHI_SCENE_PRESENTATION = 'scene-beat'; +const BHOTE_KOSHI_SCENE_PANEL_ONLY = 'panel-only'; +const BHOTE_KOSHI_SCENE_EVIDENCE_BEAT = 'evidence-beat'; + +const EVENT_ASSET_ROOT = `${import.meta.env?.BASE_URL || '/'}events/bhote-koshi-2026/`; +const FLOOD_RENDER_HOLD = 'bhote-koshi-playback'; +const SCENE_REVEAL_RENDER_HOLD = 'bhote-koshi-scene-beat-reveal'; +const SCENE_PATH_RENDER_HOLD = 'bhote-koshi-scene-path-travel'; +const SCENE_MEDIA_MIN_PLAYBACK_SECONDS = 6; +const SCENE_MEDIA_EXIT_SECONDS = 0.65; +const BHOTE_EVENT_ACCENT = WORLD_OVERLAY_STYLE.accent; +const FLOOD_COLOR = Cesium.Color.fromCssColorString('#d8954e').withAlpha(0.68); +const SURGE_COLOR = Cesium.Color.fromCssColorString('#e8b47c'); +const CINEMATIC_INPUT_EVENTS = Object.freeze(['pointerdown', 'wheel']); +const CINEMATIC_VIEW_SPECS = Object.freeze([ + { + id: 'immediate-collapse-viewpoint', + headingDeg: 224, + pitchDeg: -38, + rangeM: 15000, + }, + { id: 'debris-dammed-lake', headingDeg: 232, pitchDeg: -37, rangeM: 13200 }, + { id: 'second-landslide', headingDeg: 224, pitchDeg: -38, rangeM: 12000 }, + { id: 'gyirong-border-gate', headingDeg: 208, pitchDeg: -42, rangeM: 6100 }, + { id: 'timure-cluster', headingDeg: 198, pitchDeg: -40, rangeM: 5200 }, + { id: 'syabru-besi', headingDeg: 205, pitchDeg: -38, rangeM: 7200 }, + { id: 'dhunche', headingDeg: 208, pitchDeg: -39, rangeM: 7800 }, + { + id: 'mailung-upper-trishuli', + headingDeg: 211, + pitchDeg: -40, + rangeM: 8400, + }, + { id: 'mailung-bazzar', headingDeg: 212, pitchDeg: -40, rangeM: 8600 }, + { id: 'dandagaun', headingDeg: 214, pitchDeg: -40, rangeM: 8600 }, + { id: 'dandagaun-viewpoint', headingDeg: 215, pitchDeg: -41, rangeM: 9200 }, + { id: 'betrawati-bazaar', headingDeg: 216, pitchDeg: -41, rangeM: 9800 }, + { id: 'bhainse', headingDeg: 219, pitchDeg: -41, rangeM: 11500 }, + { + id: 'bidur-trishuli-bridge', + headingDeg: 248, + pitchDeg: -46, + rangeM: 15500, + }, + { + id: 'devighat-taadi-khola-bridge', + headingDeg: 224, + pitchDeg: -42, + rangeM: 13000, + }, + { id: 'charaudi', headingDeg: 232, pitchDeg: -42, rangeM: 15000 }, + { + id: 'charaudi', + progress: 1, + headingDeg: 232, + pitchDeg: -42, + rangeM: 15000, + }, +]); +const PASSIVE_ENABLE_ORIGINS = new Set([ + 'scene', + 'share-restore', + 'local-restore', + 'context-restore', + 'dependency-restore', +]); + +const DEFAULT_OVERLAY_HOST = Object.freeze({ + setEntries: setOverlayEntries, + setVisible: setOverlaySourceVisible, + clearSource: clearOverlaySource, + hitTest: hitTestWorldOverlay, +}); +const DEFAULT_RENDER_HOST = Object.freeze({ + request: governorRequestRender, + hold: holdContinuousRender, + release: releaseContinuousRender, +}); +const DEFAULT_EVENT_LOADER = async () => { + const response = await fetch(eventAsset('event.json'), { cache: 'no-store' }); + if (!response.ok) + throw new Error(`Bhote Koshi event pack unavailable (${response.status})`); + return response.json(); +}; +const DEFAULT_IMAGERY_PROVIDER_FACTORY = (url, options) => + Cesium.SingleTileImageryProvider.fromUrl(url, options); +const DEFAULT_TERRAIN_SAMPLER = async (viewer, observations) => { + if ( + !viewer?.terrainProvider || + !viewer.terrainProvider.availability || + typeof Cesium.sampleTerrainMostDetailed !== 'function' + ) { + return null; + } + const cartographics = observations.map((observation) => + Cesium.Cartographic.fromDegrees(observation.lon, observation.lat), + ); + return Cesium.sampleTerrainMostDetailed( + viewer.terrainProvider, + cartographics, + ); +}; +const DEFAULT_MEDIA_LOADER = async (url, { signal } = {}) => { + if (typeof createImageBitmap !== 'function') { + throw new Error('Evidence poster decoding is unavailable'); + } + const response = await fetch(url, { + cache: 'force-cache', + credentials: 'omit', + mode: 'cors', + signal, + }); + if (!response.ok) + throw new Error(`Evidence poster unavailable (${response.status})`); + return createImageBitmap(await response.blob()); +}; +const DEFAULT_VIDEO_FACTORY = () => { + const video = globalThis.document?.createElement?.('video'); + if ( + !video || + typeof video.play !== 'function' || + typeof video.pause !== 'function' + ) + return null; + video.muted = true; + video.playsInline = true; + video.preload = 'auto'; + return video; +}; + +export function clampUnit(value) { + return Math.max(0, Math.min(1, Number(value) || 0)); +} + +export function elapsedLabel(progress, totalSeconds) { + const elapsed = Math.round( + clampUnit(progress) * Math.max(0, Number(totalSeconds) || 0), + ); + const minutes = Math.floor(elapsed / 60); + const seconds = elapsed % 60; + return `${minutes}:${String(seconds).padStart(2, '0')}`; +} + +export function visibleSegmentCount(progress, segmentCount) { + const count = Math.max(0, Math.floor(Number(segmentCount) || 0)); + const normalizedProgress = clampUnit(progress); + if (count === 0 || normalizedProgress <= 0) return 0; + return Math.min(count, Math.max(1, Math.ceil(normalizedProgress * count))); +} + +/** + * Build a corridor whose final vertex is the exact animated surge position. + * Returns true only when the Cesium polyline assignments need refreshing. + */ +export function updateCorridorPositionCache(progress, terrainPositions, cache) { + const positions = Array.isArray(terrainPositions) ? terrainPositions : []; + const normalizedProgress = clampUnit(progress); + const segmentCount = Math.max(0, positions.length - 1); + const visibleCount = visibleSegmentCount(normalizedProgress, segmentCount); + if (cache.progress === normalizedProgress && cache.source === positions) + return false; + + cache.progress = normalizedProgress; + cache.source = positions; + cache.visibleCount = visibleCount; + if (segmentCount === 0 || normalizedProgress <= 0) { + cache.positions = []; + cache.headPosition = null; + return true; + } + + const scaledProgress = normalizedProgress * segmentCount; + const startIndex = Math.min(segmentCount - 1, Math.floor(scaledProgress)); + const segmentProgress = + normalizedProgress >= 1 ? 1 : scaledProgress - startIndex; + cache.headPosition = Cesium.Cartesian3.lerp( + positions[startIndex], + positions[startIndex + 1], + segmentProgress, + cache.headPosition || new Cesium.Cartesian3(), + ); + cache.positions = positions.slice(0, startIndex + 1); + if (segmentProgress > 0) { + cache.positions.push(Cesium.Cartesian3.clone(cache.headPosition)); + } + return true; +} + +/** Map the editorial story clock onto the existing flood-front corridor clock. */ +export function floodProgressForStoryProgress(progress, window = null) { + const storyProgress = clampUnit(progress); + const start = window?.start ?? BHOTE_KOSHI_PROLOGUE_END; + const end = window?.end ?? BHOTE_KOSHI_CORRIDOR_END; + if (storyProgress <= start) return 0; + if (storyProgress >= end) return 1; + return (storyProgress - start) / (end - start); +} + +/** + * Resolve curated evidence triggers onto the one event clock. Manual-phase + * beats are explicit editorial ordering; corridor beats inherit chainage. + */ +export function buildEvidenceTimeline(observations, corridor) { + const beats = Array.isArray(observations) ? observations : []; + const points = Array.isArray(corridor) ? corridor : []; + const firstChainage = Number(points[0]?.chainageM); + const lastChainage = Number(points[points.length - 1]?.chainageM); + const span = Math.max(1, lastChainage - firstChainage); + const timeline = beats + .map((observation, sourceIndex) => { + const trigger = observation?.trigger || {}; + let activationProgress; + if ( + trigger.mode === 'corridor' && + Number.isFinite(Number(trigger.chainageM)) + ) { + const corridorProgress = clampUnit( + (Number(trigger.chainageM) - firstChainage) / span, + ); + activationProgress = + BHOTE_KOSHI_PROLOGUE_END + + corridorProgress * + (BHOTE_KOSHI_CORRIDOR_END - BHOTE_KOSHI_PROLOGUE_END); + } else { + activationProgress = clampUnit(trigger.storyProgress); + } + return { observation, sourceIndex, activationProgress }; + }) + .sort( + (a, b) => + a.activationProgress - b.activationProgress || + a.sourceIndex - b.sourceIndex, + ); + + for (let index = 0; index < timeline.length; index += 1) { + timeline[index].deactivationProgress = + timeline[index + 1]?.activationProgress ?? 1.000001; + } + return timeline; +} + +/** Return the one active evidence beat for deterministic replay and scrubbing. */ +export function activeEvidenceIndex(progress, timeline) { + const normalized = clampUnit(progress); + let activeIndex = -1; + for (let index = 0; index < timeline.length; index += 1) { + if (normalized < timeline[index].activationProgress) break; + if ( + normalized < timeline[index].deactivationProgress || + index === timeline.length - 1 + ) { + activeIndex = index; + } + } + return activeIndex; +} + +/** Resolve a stable evidence id to a visible point inside that beat's window. */ +export function evidenceBeatProgress(beatId, timeline, reveal = 0.36) { + const beat = (Array.isArray(timeline) ? timeline : []).find( + ({ observation }) => observation?.id === beatId, + ); + if (!beat) return null; + const start = clampUnit(beat.activationProgress); + const end = Math.max(start, Math.min(1, beat.deactivationProgress)); + return clampUnit(start + (end - start) * clampUnit(reveal)); +} + +/** Resolve the exact standalone story-clock span owned by one embedded shot. */ +export function evidenceSequenceWindow(beatId, timeline) { + const beats = Array.isArray(timeline) ? timeline : []; + const beatIndex = beats.findIndex( + ({ observation }) => observation?.id === beatId, + ); + if (beatIndex < 0) return null; + const beat = beats[beatIndex]; + const startProgress = clampUnit(beat.activationProgress); + const targetProgress = clampUnit(beat.deactivationProgress); + return { + startProgress: Math.min(startProgress, targetProgress), + targetProgress, + }; +} + +function smoothstep(from, to, value) { + if (to <= from) return value >= to ? 1 : 0; + const amount = clampUnit((value - from) / (to - from)); + return amount * amount * (3 - 2 * amount); +} + +/** + * Resolve a card's reveal and recession from the one normalized story clock. + * The stable hold matches the camera hold; no wall-clock TTL can drift from it. + */ +export function evidenceBeatPresentation(progress, beat) { + if (!beat) { + return { + alpha: 0, + scale: 0.96, + leaderProgress: 0, + contentAlpha: 0, + localProgress: 0, + }; + } + const start = clampUnit(beat.activationProgress); + const end = Math.max( + start + 0.000001, + Math.min(1.000001, beat.deactivationProgress), + ); + const localProgress = clampUnit( + (clampUnit(progress) - start) / (end - start), + ); + const beatDurationSeconds = Math.max( + 0.25, + (end - start) * BHOTE_KOSHI_PLAYBACK_SECONDS, + ); + const dotEnd = Math.min(0.12, 0.25 / beatDurationSeconds); + const leaderStart = dotEnd * 0.35; + const leaderEnd = Math.min(0.24, leaderStart + 0.35 / beatDurationSeconds); + const contentStart = Math.min( + leaderEnd, + leaderStart + 0.12 / beatDurationSeconds, + ); + const contentEnd = Math.min(0.34, contentStart + 0.6 / beatDurationSeconds); + const dotReveal = smoothstep(0, dotEnd, localProgress); + const leaderReveal = smoothstep(leaderStart, leaderEnd, localProgress); + const contentReveal = smoothstep(contentStart, contentEnd, localProgress); + const contentHold = + 1 - smoothstep(BHOTE_KOSHI_CAMERA_HOLD_RATIO, 0.78, localProgress); + const leaderHold = 1 - smoothstep(0.74, 0.9, localProgress); + const dotHold = 1 - smoothstep(0.86, 1, localProgress); + const alpha = Math.min(dotReveal, dotHold); + const leaderProgress = Math.min(leaderReveal, leaderHold); + const revealScale = 0.96 + contentReveal * 0.04; + const recedeScale = 1 - (1 - contentHold) * 0.03; + return { + alpha, + scale: revealScale * recedeScale, + leaderProgress, + contentAlpha: Math.min(contentReveal, contentHold), + localProgress, + }; +} + +/** Resolve a deterministic previous/next beat without creating another clock. */ +export function adjacentEvidenceIndex(progress, timeline, direction) { + const beats = Array.isArray(timeline) ? timeline : []; + if (beats.length === 0) return -1; + const current = Math.max(0, activeEvidenceIndex(progress, beats)); + const delta = Number(direction) < 0 ? -1 : Number(direction) > 0 ? 1 : 0; + return Math.max(0, Math.min(beats.length - 1, current + delta)); +} + +/** Compact, source-bounded disclosure for multi-source geolocation beats. */ +export function evidenceCorroborationLabel(observation) { + const count = Math.max( + 0, + Math.floor(Number(observation?.corroboration?.sourceCount) || 0), + ); + return count > 1 ? `${count} GEOLOCATED SOURCE-MAP PLACEMENTS` : ''; +} + +/** Interpolate headings across north using the shortest angular path. */ +export function interpolateHeadingDegrees(start, end, amount) { + const from = Number(start) || 0; + const to = Number(end) || 0; + const delta = ((to - from + 540) % 360) - 180; + return (((from + delta * clampUnit(amount)) % 360) + 360) % 360; +} + +/** Resolve the editorial camera beats against the terrain-sampled evidence anchors. */ +export function buildCinematicKeyframes(timeline, anchors) { + const beats = Array.isArray(timeline) ? timeline : []; + const positions = Array.isArray(anchors) ? anchors : []; + const resolved = new Map(); + for (let index = 0; index < beats.length; index += 1) { + const id = beats[index]?.observation?.id; + if (id && positions[index]) + resolved.set(id, { beat: beats[index], target: positions[index] }); + } + return CINEMATIC_VIEW_SPECS.flatMap((spec) => { + const entry = resolved.get(spec.id); + if (!entry) return []; + return [ + { + ...spec, + progress: Number.isFinite(spec.progress) + ? spec.progress + : entry.beat.activationProgress, + target: entry.target, + }, + ]; + }).sort((a, b) => a.progress - b.progress); +} + +/** Sample a camera pose directly from the event clock. */ +export function sampleCinematicPose(progress, keyframes, result = {}) { + const frames = Array.isArray(keyframes) ? keyframes : []; + if (frames.length === 0) return null; + const normalized = clampUnit(progress); + let from = frames[0]; + let to = frames[frames.length - 1]; + for (let index = 1; index < frames.length; index += 1) { + if (normalized <= frames[index].progress) { + to = frames[index]; + from = frames[index - 1]; + break; + } + } + if (normalized <= frames[0].progress) from = to = frames[0]; + if (normalized >= frames[frames.length - 1].progress) + from = to = frames[frames.length - 1]; + const span = Math.max(0.000001, to.progress - from.progress); + const linearAmount = + from === to ? 0 : clampUnit((normalized - from.progress) / span); + const travelAmount = + linearAmount <= BHOTE_KOSHI_CAMERA_HOLD_RATIO + ? 0 + : (linearAmount - BHOTE_KOSHI_CAMERA_HOLD_RATIO) / + (1 - BHOTE_KOSHI_CAMERA_HOLD_RATIO); + const amount = travelAmount * travelAmount * (3 - 2 * travelAmount); + result.target = Cesium.Cartesian3.lerp( + from.target, + to.target, + amount, + result.target || new Cesium.Cartesian3(), + ); + result.headingDeg = interpolateHeadingDegrees( + from.headingDeg, + to.headingDeg, + amount, + ); + result.pitchDeg = Cesium.Math.lerp(from.pitchDeg, to.pitchDeg, amount); + result.rangeM = Cesium.Math.lerp(from.rangeM, to.rangeM, amount); + result.fromId = from.id; + result.toId = to.id; + return result; +} + +/** Wrap a compact card summary without clipping a word at the canvas edge. */ +export function evidenceSummaryLines(value, maxChars = 52, maxLines = 2) { + const words = String(value || '') + .trim() + .split(/\s+/) + .filter(Boolean); + const limit = Math.max(8, Math.floor(Number(maxChars) || 52)); + const lineLimit = Math.max(1, Math.floor(Number(maxLines) || 2)); + const lines = []; + let current = ''; + let consumed = 0; + + for (let index = 0; index < words.length; index += 1) { + const word = words[index]; + const candidate = current ? `${current} ${word}` : word; + if (candidate.length <= limit) { + current = candidate; + consumed = index + 1; + continue; + } + if (current) { + lines.push(current); + if (lines.length >= lineLimit) break; + } + current = word; + consumed = index + 1; + } + if (current && lines.length < lineLimit) lines.push(current); + if (consumed < words.length && lines.length > 0) { + let tail = lines.at(-1); + while (tail.length > limit - 1) tail = tail.slice(0, -1); + lines[lines.length - 1] = `${tail}…`; + } + return lines; +} + +function closeFrame(frame) { + try { + frame?.close?.(); + } catch { + // A browser may already have released a decoded frame during teardown. + } +} + +const EVIDENCE_CARD_LAYOUTS = Object.freeze({ + landscape: Object.freeze({ + kind: 'landscape', + canvasWidth: 472, + canvasHeight: 352, + mediaHeight: 284, + thumbnailWidth: 236, + thumbnailHeight: 176, + titleChars: 28, + }), + portrait: Object.freeze({ + kind: 'portrait', + canvasWidth: 336, + canvasHeight: 520, + mediaHeight: 450, + thumbnailWidth: 168, + thumbnailHeight: 260, + titleChars: 20, + }), + link: Object.freeze({ + kind: 'link', + canvasWidth: 440, + canvasHeight: 184, + mediaHeight: 116, + thumbnailWidth: 220, + thumbnailHeight: 92, + titleChars: 28, + }), +}); + +/** Resolve one stable card footprint before the entry enters the shared host. */ +export function evidenceCardLayout(observation) { + const media = observation?.media || {}; + if (!media.videoPath && !media.posterPath && !media.posterUrl) + return EVIDENCE_CARD_LAYOUTS.link; + const width = Number(media.width); + const height = Number(media.height); + const orientation = String(media.orientation || '').toLowerCase(); + if (orientation === 'portrait' || (width > 0 && height > width * 1.12)) { + return EVIDENCE_CARD_LAYOUTS.portrait; + } + return EVIDENCE_CARD_LAYOUTS.landscape; +} + +/** Prefer official YouTube/Facebook players while retaining local clips as fallback media. */ +export function usesProviderEmbeddedEvidence(observation) { + const source = resolveEmbeddedMediaSource(observation?.media?.sourceUrl); + if (!source) return false; + return ( + source.provider === 'youtube' || + source.provider === 'facebook' || + !observation?.media?.videoPath + ); +} + +function evidenceFrameCanvas(observation) { + const canvas = globalThis.document?.createElement?.('canvas'); + if (!canvas || typeof canvas.getContext !== 'function') return null; + const layout = evidenceCardLayout(observation); + canvas.width = layout.canvasWidth; + canvas.height = layout.canvasHeight; + canvas._bhoteEvidenceLayout = layout; + return canvas; +} + +function drawCover(ctx, image, x, y, width, height) { + const sourceWidth = + Number(image?.videoWidth || image?.width || image?.naturalWidth) || width; + const sourceHeight = + Number(image?.videoHeight || image?.height || image?.naturalHeight) || + height; + const scale = Math.max(width / sourceWidth, height / sourceHeight); + const cropWidth = width / scale; + const cropHeight = height / scale; + const sourceX = Math.max(0, (sourceWidth - cropWidth) / 2); + const sourceY = Math.max(0, (sourceHeight - cropHeight) / 2); + ctx.drawImage( + image, + sourceX, + sourceY, + cropWidth, + cropHeight, + x, + y, + width, + height, + ); +} + +function drawContain(ctx, image, x, y, width, height) { + const sourceWidth = + Number(image?.videoWidth || image?.width || image?.naturalWidth) || width; + const sourceHeight = + Number(image?.videoHeight || image?.height || image?.naturalHeight) || + height; + const scale = Math.min(width / sourceWidth, height / sourceHeight); + const drawWidth = sourceWidth * scale; + const drawHeight = sourceHeight * scale; + ctx.drawImage( + image, + x + (width - drawWidth) / 2, + y + (height - drawHeight) / 2, + drawWidth, + drawHeight, + ); +} + +function scaledOverlayFont(font, scale = 2) { + return String(font).replace( + /(\d+(?:\.\d+)?)px/, + (_, size) => `${Number(size) * scale}px`, + ); +} + +function drawEvidenceFallback(ctx, observation, width, height) { + ctx.fillStyle = WORLD_OVERLAY_STYLE.background; + ctx.fillRect(0, 0, width, height); + ctx.strokeStyle = WORLD_OVERLAY_STYLE.leader; + ctx.lineWidth = 2; + for (let offset = -240; offset < width + 120; offset += 48) { + ctx.beginPath(); + ctx.moveTo(offset, height); + ctx.lineTo(offset + 240, 0); + ctx.stroke(); + } + ctx.fillStyle = WORLD_OVERLAY_STYLE.accent; + ctx.font = scaledOverlayFont(WORLD_OVERLAY_STYLE.fontLabel, 1.4); + ctx.fillText('NO PREVIEW', 20, 30); +} + +function ellipsizeEvidenceText(value, limit) { + const text = String(value || '').trim(); + if (text.length <= limit) return text; + return `${text.slice(0, Math.max(1, limit - 3)).trimEnd()}...`; +} + +/** Repaint one stable frame with a poster or the current source-video frame. */ +export function updateEvidenceFrame( + canvas, + observation, + media = null, + options = {}, +) { + if (!canvas || typeof canvas.getContext !== 'function') return false; + const ctx = canvas.getContext('2d'); + if (!ctx) return false; + const layout = canvas._bhoteEvidenceLayout || evidenceCardLayout(observation); + const accent = BHOTE_EVENT_ACCENT; + const mediaHeight = layout.mediaHeight; + const infoY = mediaHeight; + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.textAlign = 'left'; + ctx.textBaseline = 'alphabetic'; + ctx.fillStyle = WORLD_OVERLAY_STYLE.background; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + let mediaRendered = false; + if (media) { + try { + const fit = String( + observation.media?.fit || + (layout.kind === 'portrait' ? 'contain' : 'cover'), + ); + if (fit === 'contain') + drawContain(ctx, media, 0, 0, canvas.width, mediaHeight); + else drawCover(ctx, media, 0, 0, canvas.width, mediaHeight); + mediaRendered = true; + } catch { + // The deterministic local fallback below remains the visual source. + } + } + if (!mediaRendered) + drawEvidenceFallback(ctx, observation, canvas.width, mediaHeight); + + if (options.videoActive && mediaRendered) { + const mediaProgress = clampUnit(options.mediaProgress); + ctx.fillStyle = WORLD_OVERLAY_STYLE.selectedBackground; + ctx.fillRect(0, mediaHeight - 4, canvas.width, 4); + ctx.fillStyle = accent; + ctx.fillRect(0, mediaHeight - 4, canvas.width * mediaProgress, 4); + } + + ctx.fillStyle = WORLD_OVERLAY_STYLE.selectedBackground; + ctx.fillRect(0, infoY, canvas.width, canvas.height - infoY); + ctx.fillStyle = accent; + ctx.fillRect(0, 0, canvas.width, 4); + ctx.fillStyle = WORLD_OVERLAY_STYLE.title; + ctx.font = scaledOverlayFont(WORLD_OVERLAY_STYLE.fontTitle); + ctx.fillText( + ellipsizeEvidenceText( + observation.shortTitle || observation.title, + layout.titleChars, + ).toUpperCase(), + 16, + infoY + 27, + ); + ctx.fillStyle = WORLD_OVERLAY_STYLE.detail; + ctx.font = scaledOverlayFont(WORLD_OVERLAY_STYLE.fontDetail); + const publisher = String( + observation.media?.publisher || 'SOURCE LINK', + ).toUpperCase(); + ctx.fillText( + `${ellipsizeEvidenceText(publisher, 24)} / OPEN`, + 16, + infoY + 53, + ); + return true; +} + +/** Compose source imagery and caveats into one stable renderer-owned frame. */ +export function createEvidenceFrame(observation, poster = null, options = {}) { + const canvas = evidenceFrameCanvas(observation); + if (!canvas) return null; + updateEvidenceFrame(canvas, observation, poster, options); + return canvas; +} + +function eventAsset(path) { + return `${EVENT_ASSET_ROOT}${String(path || '').replace(/^\/+/, '')}`; +} + +function cancellationError() { + const error = new Error('Bhote Koshi event enable cancelled'); + error.name = 'AbortError'; + return error; +} + +function throwIfEnableCancelled(signal, enabled) { + if (signal?.aborted || !enabled) throw cancellationError(); +} + +function openExternal(url) { + const target = window.open(url, '_blank', 'noopener,noreferrer'); + if (target) target.opener = null; +} + +function focusView(viewer, viewpoint) { + announceNavigationAuthority('bhote-koshi-event-focus'); + const target = Cesium.Cartesian3.fromDegrees( + viewpoint.lon, + viewpoint.lat, + viewpoint.targetElevationM, + ); + const offset = new Cesium.HeadingPitchRange( + Cesium.Math.toRadians(viewpoint.headingDeg), + Cesium.Math.toRadians(viewpoint.pitchDeg), + viewpoint.rangeM, + ); + viewer.camera.cancelFlight?.(); + viewer.camera.flyToBoundingSphere(new Cesium.BoundingSphere(target, 80), { + offset, + duration: 2.2, + complete: () => { + viewer.camera.lookAt(target, offset); + viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); + }, + }); +} + +function setElementProperty(element, property, value) { + if (!element || element[property] === value) return false; + element[property] = value; + return true; +} + +function setElementAttribute(element, attribute, value) { + if (!element || element.getAttribute?.(attribute) === value) return false; + element.setAttribute(attribute, value); + return true; +} + +function collectPanelReferences(panel) { + return Object.freeze({ + imagerySection: panel.querySelector('[data-role="imagery-comparison"]'), + observedStatus: panel.querySelector('.bhote-event-status.observed'), + beforeDate: panel.querySelector('[data-role="before-date"]'), + afterDate: panel.querySelector('[data-role="after-date"]'), + time: panel.querySelector('[data-role="time"]'), + caveat: panel.querySelector('[data-role="caveat"]'), + imageryCloudNote: panel.querySelector('[data-role="imagery-cloud-note"]'), + reportList: panel.querySelector('.bhote-event-report-list'), + playButton: panel.querySelector('[data-action="play"]'), + playSceneButton: panel.querySelector('[data-action="play-scene"]'), + cinematicButton: panel.querySelector('[data-action="cinematic"]'), + storyReplayButton: panel.querySelector('[data-action="story-replay"]'), + corridorButton: panel.querySelector('[data-action="corridor"]'), + previousButton: panel.querySelector('[data-action="previous-beat"]'), + nextButton: panel.querySelector('[data-action="next-beat"]'), + openSourceButton: panel.querySelector('[data-action="open-source"]'), + progressInput: panel.querySelector('[data-role="progress"]'), + splitInput: panel.querySelector('[data-role="split"]'), + splitReadout: panel.querySelector('[data-role="split-readout"]'), + timelineLabel: panel.querySelector('[data-role="timeline-label"]'), + beatIndex: panel.querySelector('[data-role="beat-index"]'), + beatTitle: panel.querySelector('[data-role="beat-title"]'), + beatMeta: panel.querySelector('[data-role="beat-meta"]'), + }); +} + +function createPanel(event, handlers) { + const panel = document.createElement('aside'); + panel.id = 'bhote-koshi-event-panel'; + panel.setAttribute('aria-label', 'Bhote Koshi flood reconstruction controls'); + panel.innerHTML = ` +
+
+
EVENT RECONSTRUCTION · 26 AUG 2026
+
BHOTE KOSHI OUTBURST FLOOD
+
+ +
+
+ OBSERVED IMAGERY + SCHEMATIC CORRIDOR +
+
+
+ 2021 HISTORICAL REFERENCE / 2026 POST-EVENT + 50 / 50 +
+ +
+ + +
+
+
+
+ RECONSTRUCTION CLOCK + +
+ +
+ +
+ 01 / 06 + CAUSE + CAPTURE TIME UNVERIFIED +
+ +
+
+ + + + + + +
+
+
+ FIELD REPORTS · ${event.fieldReports.length} +
+
+
+ GEOLOCATIONS · GEO GEORGE SHADRACH + +
+

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} Keys of dynamic credits already registered this session. */ const _dynamicCreditKeys = new Set(); diff --git a/src/data/layerState.js b/src/data/layerState.js index 4960a5442..68eebd777 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -312,6 +312,16 @@ export const LAYER_STATE_REGISTRY = Object.freeze([ token: 'p', disposition: 'enabled-only', }), + Object.freeze({ + id: 'bhote-koshi-2026', + token: 'h', + disposition: 'enabled-only', + }), + Object.freeze({ + id: 'bhote-koshi-locator', + token: 'z', + disposition: 'enabled-only', + }), Object.freeze({ id: 'bikeshare', token: 'b', disposition: 'enabled-only' }), Object.freeze({ id: 'cctv', diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index cd6f3924e..577027069 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -158,8 +158,9 @@ function encode(state) { test('production registry is exact, canonical, and rejects incomplete contracts', async () => { assert.equal(validateLayerStateRegistry(), true); - assert.equal(REGISTERED_LAYER_IDS.length, 19); - assert.equal(new Set(REGISTERED_LAYER_IDS).size, 19); + assert.equal(REGISTERED_LAYER_IDS.length, 21); + assert.equal(new Set(REGISTERED_LAYER_IDS).size, 21); + assert.ok(REGISTERED_LAYER_IDS.includes('transit')); assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort()); assert.throws( () => validateLayerStateRegistry([...LAYER_STATE_REGISTRY, LAYER_STATE_REGISTRY[0]]), @@ -226,8 +227,14 @@ test('v2 codec distinguishes absent from empty and keeps canonical deterministic }); test('unknown enabled-layer tokens reject the payload instead of becoming an empty set', () => { - assert.equal(decodeLayerStateParams(new URLSearchParams('v=2&l=z')), null); - assert.equal(decodeLayerStateParams(new URLSearchParams('v=2&l=c.z')), null); + assert.equal(decodeLayerStateParams(new URLSearchParams('v=2&l=unknown')), null); + assert.equal(decodeLayerStateParams(new URLSearchParams('v=2&l=c.unknown')), null); +}); + +test('Nepal event and locator have distinct enabled-only share tokens', () => { + const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=h.z')); + assert.deepEqual(decoded.enabledLayerIds, ['bhote-koshi-2026', 'bhote-koshi-locator']); + assert.ok(encode(decoded).includes('l=h.z')); }); test('unknown and forbidden option fields are ignored while missing options use codec defaults', () => { diff --git a/src/maps/controller.js b/src/maps/controller.js index 9f7805df7..778514eba 100644 --- a/src/maps/controller.js +++ b/src/maps/controller.js @@ -180,12 +180,24 @@ export class MapSourceController { async _activateGlobeStack(stack, gen) { const resolution = await this._getImageryProvider(stack); if (gen !== this._switchGen) return; - this._removeImageryLayer(); - this._imageryLayer = this._createImageryLayer(resolution.provider); - this._activeImageryProvider = resolution.provider; - this.viewer.imageryLayers.add(this._imageryLayer, 0); + // Scene shots reapply their map stack at every handoff. Keep the live + // layer (and its loaded tiles) when the resolved provider is unchanged; + // rebuilding it exposes the bare globe while imagery loads again. + if ( + !this._imageryLayer || + this._activeImageryProvider !== resolution.provider + ) { + this._removeImageryLayer(); + this._imageryLayer = this._createImageryLayer(resolution.provider); + this._activeImageryProvider = resolution.provider; + this.viewer.imageryLayers.add(this._imageryLayer, 0); + } const source = this._sources.get(resolution.effectiveStackId); this._credits.show(source?.credit || null); + // A repeated request still owns a new switch generation. Rebind its + // failure listener so fallback remains live without accumulating listeners. + this._removeImageryErrorListener?.(); + this._removeImageryErrorListener = null; this._watchProvider(resolution, gen); this._showTileset(null); this.viewer.scene.globe.show = true; diff --git a/src/maps/controller.test.mjs b/src/maps/controller.test.mjs index 633be3852..ffb244cf4 100644 --- a/src/maps/controller.test.mjs +++ b/src/maps/controller.test.mjs @@ -124,14 +124,68 @@ test('an additional imagery source needs no controller branch and owns its cache assert.equal(reads, 1); assert.equal(terrainReads, 1); assert.equal(env.imagery.length, 1); - assert.equal(env.removed[0].destroy, true); + assert.equal( + env.removed.length, + 0, + 'same provider keeps its loaded imagery layer', + ); env.controller.destroy(); env.controller.destroy(); await settle(); assert.equal(env.imagery.length, 0); + assert.equal(env.removed[0].destroy, true); assert.equal(destroys, 1); }); +test('repeated Esri shot handoffs retain imagery and keep tile fallback live', async () => { + const env = publicFixture(); + await env.controller.setStack('esri-imagery'); + const layer = env.imagery[0]; + const generation = env.controller.getSwitchGeneration(); + const errors = env.providers.get('esri-imagery').errorEvent; + for (let i = 0; i < 3; i++) await env.controller.setStack('esri-imagery'); + assert.equal(env.imagery[0], layer); + assert.equal(env.removed.length, 0); + assert.equal(errors.size, 1); + assert.equal(env.controller.getSwitchGeneration(), generation + 3); + errors.raise(); + errors.raise(); + await settle(); + assert.equal(env.controller.getActiveId(), 'osm'); + assert.equal(env.removed.length, 1); + assert.equal(errors.size, 0); + env.controller.destroy(); +}); + +test('returning to the live provider supersedes a pending switch without rebuilding imagery', async () => { + const env = publicFixture(); + await env.controller.setStack('esri-imagery'); + const layer = env.imagery[0]; + let resolve; + env.registry.sources.find( + (source) => source.descriptor.id === 'osm', + ).imagery = () => + new Promise((done) => { + resolve = done; + }); + const pending = env.controller.setStack('osm'); + await settle(); + await env.controller.setStack('esri-imagery'); + resolve(env.providers.get('osm')); + await pending; + assert.equal(env.controller.getActiveId(), 'esri-imagery'); + assert.equal(env.imagery[0], layer); + assert.equal(env.removed.length, 0); + await env.controller.setStack('photoreal'); + assert.equal(env.imagery.length, 0); + assert.equal(env.tileset.show, true); + await env.controller.setStack('esri-imagery'); + assert.notEqual(env.imagery[0], layer, 'a removed layer must be recreated'); + assert.equal(env.viewer.scene.globe.show, true); + assert.equal(env.tileset.show, false); + env.controller.destroy(); +}); + test('a destroyed controller aborts creation and disposes a late provider without touching the scene', async () => { let resolve, signal, diff --git a/src/maps/sourceFactories.test.mjs b/src/maps/sourceFactories.test.mjs index 75cb44df1..317f2aa4a 100644 --- a/src/maps/sourceFactories.test.mjs +++ b/src/maps/sourceFactories.test.mjs @@ -2,7 +2,67 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; import { createIonImagery } from './imagery.js'; -import { createWorldTerrain } from './terrain.js'; +import { createWorldTerrain, createKeylessTerrain } from './terrain.js'; +import { createDefaultMapSources } from './defaultSources.js'; + +test('Esri uses Re:Earth without keys and preserves ion terrain when configured', async () => { + const originalTerrain = Cesium.CesiumTerrainProvider.fromUrl; + const originalResource = Cesium.IonResource.fromAssetId; + const calls = []; + try { + Cesium.CesiumTerrainProvider.fromUrl = async (resource) => { + calls.push(resource); + return { resource }; + }; + Cesium.IonResource.fromAssetId = async () => + assert.fail('keyless mode must not acquire ion terrain'); + const keyless = createDefaultMapSources(); + const esri = keyless.sources.find( + ({ descriptor }) => descriptor.id === 'esri-imagery', + ); + assert.equal(esri.available, true); + assert.equal(esri.terrain.id, 'keyless'); + assert.equal(esri.terrain.create, createKeylessTerrain); + await esri.terrain.create(); + assert.deepEqual(calls, [ + 'https://terrain.reearth.land/cesium-mesh/ellipsoid', + ]); + assert.equal( + keyless.sources.find(({ descriptor }) => descriptor.id === 'photoreal') + .available, + false, + ); + for (const credentials of [ + { googleApiKey: 'test-key' }, + { cesiumToken: 'test-token' }, + ]) { + const keyed = createDefaultMapSources({ + ...credentials, + googleTileset: { show: true }, + }); + assert.equal( + keyed.sources.find(({ descriptor }) => descriptor.id === 'photoreal') + .available, + true, + ); + assert.equal( + keyed.sources.find(({ descriptor }) => descriptor.id === 'esri-imagery') + .terrain.id, + credentials.cesiumToken ? 'world' : 'keyless', + ); + const failedTileset = createDefaultMapSources(credentials); + assert.equal( + failedTileset.sources.find( + ({ descriptor }) => descriptor.id === 'photoreal', + ).available, + false, + ); + } + } finally { + Cesium.CesiumTerrainProvider.fromUrl = originalTerrain; + Cesium.IonResource.fromAssetId = originalResource; + } +}); test('imagery and terrain pass their own ion token without relying on SDK defaults', async () => { const originalImagery = Cesium.IonImageryProvider.fromAssetId; diff --git a/src/overlays/worldOverlay.js b/src/overlays/worldOverlay.js index 38d08af06..4266871a1 100644 --- a/src/overlays/worldOverlay.js +++ b/src/overlays/worldOverlay.js @@ -112,6 +112,7 @@ export const WORLD_OVERLAY_OCCLUDER_SELECTORS = Object.freeze([ '#space-mission-panel', '#space-mission-panel-host', '#military-awareness-panel', + '#bhote-koshi-event-panel', // Cockpit: solid backdrop-filled windows only (both bounded to // `min(340px, 28vw)` wide and `min(42vh, 410px)` tall, both `hidden` until // toggled). Every other cockpit selector was removed — see the block comment. @@ -503,7 +504,24 @@ export function normalizeOverlayEntry(sourceId, entry) { : 'keyhole', horizonCull: entry.horizonCull !== false, terrainOcclusion: entry.terrainOcclusion === true, - sourceAlpha: clamp01(entry.sourceAlpha ?? entry.alpha, 1), + sourceAlpha: + typeof (entry.sourceAlpha ?? entry.alpha) === 'function' + ? (entry.sourceAlpha ?? entry.alpha) + : clamp01(entry.sourceAlpha ?? entry.alpha, 1), + presentationScale: + typeof entry.presentationScale === 'function' + ? entry.presentationScale + : Math.max(0, Number(entry.presentationScale) || 1), + leaderProgress: + typeof entry.leaderProgress === 'function' + ? entry.leaderProgress + : clamp01(entry.leaderProgress, 1), + contentAlpha: + typeof entry.contentAlpha === 'function' + ? entry.contentAlpha + : clamp01(entry.contentAlpha, 1), + anchorDot: entry.anchorDot === true, + leaderStyle: entry.leaderStyle === 'elbow' ? 'elbow' : 'straight', temporalAlpha: clamp01(entry.temporalAlpha, 1), gapPx: Number.isFinite(Number(entry.gapPx)) ? Math.max(0, Number(entry.gapPx)) @@ -1722,6 +1740,8 @@ function getProjectionRecord(entry) { candidate: null, distanceAlpha: 1, paintScale: 1, + leaderProgress: 1, + contentAlpha: 1, altitudeAlpha: 1, sourceAlpha: 1, protectedPlacement: null, @@ -1784,6 +1804,47 @@ function resetFrameDomains() { } } +// Evaluate optional scene presentation outside the shared projection hot path. +// Passing records (rather than intermediate doubles) also avoids boxing the +// ordinary moving-source workload at a non-inlined call boundary. +function applyPresentation(entry, source, record) { + try { + const scale = Number( + typeof entry.presentationScale === 'function' + ? entry.presentationScale() + : entry.presentationScale, + ); + record.paintScale *= Number.isFinite(scale) ? Math.max(0, scale) : 1; + const alpha = Number( + typeof entry.sourceAlpha === 'function' + ? entry.sourceAlpha() + : entry.sourceAlpha, + ); + record.sourceAlpha = + source.options.alpha * + (Number.isFinite(alpha) ? Math.max(0, Math.min(1, alpha)) : 1); + const leader = Number( + typeof entry.leaderProgress === 'function' + ? entry.leaderProgress() + : entry.leaderProgress, + ); + record.leaderProgress = Number.isFinite(leader) + ? Math.max(0, Math.min(1, leader)) + : 1; + const content = Number( + typeof entry.contentAlpha === 'function' + ? entry.contentAlpha() + : entry.contentAlpha, + ); + record.contentAlpha = Number.isFinite(content) + ? Math.max(0, Math.min(1, content)) + : 1; + return true; + } catch { + return false; + } +} + function snapshotAndProject(entry, source, viewProjection, keyhole) { const record = getProjectionRecord(entry); let position; @@ -1908,7 +1969,18 @@ function snapshotAndProject(entry, source, viewProjection, keyhole) { ? 0 : 1 : altitudeFade(cameraAltitude, record.altitudeOptions); - record.sourceAlpha = source.options.alpha * entry.sourceAlpha; + record.leaderProgress = 1; + record.contentAlpha = 1; + if ( + entry.presentationScale !== 1 || + entry.leaderProgress !== 1 || + entry.contentAlpha !== 1 || + typeof entry.sourceAlpha === 'function' + ) { + if (!applyPresentation(entry, source, record)) return null; + } else { + record.sourceAlpha = source.options.alpha * entry.sourceAlpha; + } if ( record.distanceAlpha <= 0 || record.paintScale <= 0 || @@ -2398,7 +2470,14 @@ function paintEntryItem(item, keyhole) { keyholeAlpha; if (finalAlpha <= 0.001) return; if (record.paintScale === 1) { - paintOverlayEntry(_ctx, entry, placement, finalAlpha); + paintOverlayEntry( + _ctx, + entry, + placement, + finalAlpha, + record.leaderProgress, + record.contentAlpha, + ); } else { const scaled = localizeScaledPlacement( placement, @@ -2408,7 +2487,14 @@ function paintEntryItem(item, keyhole) { _ctx.save(); _ctx.translate(placement.rect.x, placement.rect.y); _ctx.scale(record.paintScale, record.paintScale); - paintOverlayEntry(_ctx, entry, scaled, finalAlpha); + paintOverlayEntry( + _ctx, + entry, + scaled, + finalAlpha, + record.leaderProgress, + record.contentAlpha, + ); _ctx.restore(); } publishPaintRect(item); diff --git a/src/overlays/worldOverlay.test.mjs b/src/overlays/worldOverlay.test.mjs index 90ff52b0f..8046c424c 100644 --- a/src/overlays/worldOverlay.test.mjs +++ b/src/overlays/worldOverlay.test.mjs @@ -1821,6 +1821,85 @@ test('CCTV host binding preserves the smoothstep scale and 0.45/0.35 fade curve' } }); +test('scene presentation samples once per frame and recovers after a callback failure', () => { + const env = installMockEnvironment({ width: 500, height: 400, dpr: 1 }); + const alphas = []; + Object.defineProperty(env.ctx, 'globalAlpha', { + configurable: true, + get() { return alphas.at(-1) ?? 1; }, + set(value) { alphas.push(value); }, + }); + initWorldOverlay(env.viewer); + const entry = createCctvThumbnailOverlayEntry({ + id: 'scene-presentation', position: position(), title: 'SCENE', + frameSlot: { frame: { ready: true }, stamp: 1 }, active: true, + }); + Object.assign(entry, { + horizonCull: false, maxDistance: Number.POSITIVE_INFINITY, edgeFade: 'none', + altitudeScale: null, altitudeFadeEnd: Number.POSITIVE_INFINITY, + }); + let scale = 0.5; + let alpha = 0.8; + let leader = 0.5; + let content = 0.25; + let fail = false; + const sampled = { scale: 0, alpha: 0, leader: 0, content: 0 }; + entry.presentationScale = () => { sampled.scale++; return scale; }; + entry.sourceAlpha = () => { + sampled.alpha++; + if (fail) throw new Error('presentation unavailable'); + return alpha; + }; + entry.leaderProgress = () => { sampled.leader++; return leader; }; + entry.contentAlpha = () => { sampled.content++; return content; }; + const publish = () => setOverlayEntries('scene-presentation', [entry], { + cohortLimit: 1, collisionCapacity: 0, + }); + const frame = () => { + env.ctx.calls.length = 0; + alphas.length = 0; + env.postRender.raise(); + }; + try { + publish(); + frame(); + assert.deepEqual(sampled, { scale: 1, alpha: 1, leader: 1, content: 1 }); + assert.equal(env.ctx.calls.find(([name]) => name === 'scale')?.[1], 0.5); + assert.ok(Math.abs(alphas.at(-1) - 0.2) < 1e-12); + assert.ok(env.ctx.calls.some(([name]) => name === 'drawImage')); + + fail = true; + assert.doesNotThrow(frame); + assert.equal(env.ctx.calls.some(([name]) => name === 'drawImage'), false); + assert.deepEqual(sampled, { scale: 2, alpha: 2, leader: 1, content: 1 }); + + fail = false; + scale = Number.NaN; + alpha = 2; + leader = Number.NaN; + content = 2; + frame(); + assert.equal(env.ctx.calls.some(([name]) => name === 'scale'), false); + assert.equal(alphas.at(-1), 1); + assert.ok(env.ctx.calls.some(([name]) => name === 'drawImage')); + + scale = 0; + frame(); + assert.equal(env.ctx.calls.some(([name]) => name === 'drawImage'), false); + + // Reusing the record for an ordinary card must not retain animated values. + Object.assign(entry, { + presentationScale: 1, sourceAlpha: 1, leaderProgress: 1, contentAlpha: 1, + }); + publish(); + frame(); + assert.equal(alphas.at(-1), 1); + assert.ok(env.ctx.calls.some(([name]) => name === 'drawImage')); + } finally { + env.cleanup(); + } +}); + test('CCTV leader remains one CSS pixel at the 0.35 altitude scale point', () => { const env = installMockEnvironment({ width: 500, height: 400, dpr: 1 }); env.viewer.camera.positionCartographic.height = 9500; diff --git a/src/overlays/worldOverlayDraw.js b/src/overlays/worldOverlayDraw.js index e315f5807..f64a3170d 100644 --- a/src/overlays/worldOverlayDraw.js +++ b/src/overlays/worldOverlayDraw.js @@ -384,7 +384,10 @@ export function measureOverlayEntry(ctx, entry, out = {}) { out.padY = Math.max(0, Number(entry?.thumbnailPadTop) || 0); out.padBottom = Math.max(0, Number(entry?.thumbnailPadBottom) || 0); out.titleGap = Math.max(0, Number(entry?.thumbnailTitleGap) || 0); - out.titleH = Math.max(0, Number(entry?.thumbnailTitleHeight) || out.titleH); + const thumbnailTitleHeight = Number(entry?.thumbnailTitleHeight); + out.titleH = Number.isFinite(thumbnailTitleHeight) + ? Math.max(0, thumbnailTitleHeight) + : out.titleH; out.thumbW = Math.max(1, Number(entry?.thumbnailWidth) || 96); out.thumbH = Math.max(1, Number(entry?.thumbnailHeight) || 54); out.w = out.thumbW + out.padX * 2; @@ -540,34 +543,90 @@ export function placementVariants( return out; } -function drawLeader(ctx, placement, accent) { +function drawLeader( + ctx, + placement, + accent, + progress = 1, + width = 1, + style = 'straight', +) { + const reveal = Math.max(0, Math.min(1, Number(progress) || 0)); + if (reveal <= 0) return; ctx.strokeStyle = accent || WORLD_OVERLAY_STYLE.leader; // Leaders are screen-space strokes. Card content may be painted inside an // altitude/distance scale transform, so counter-scale the canvas width to // retain the shipped one-CSS-pixel leader at every card size. - ctx.lineWidth = 1 / (placement.paintScale || 1); - ctx.beginPath(); - if (placement.leaderOffset === 0) { - ctx.moveTo(placement.leadFromX, placement.leadFromY); - } else if (placement.corner === 'above' || placement.corner === 'below') { - ctx.moveTo( - placement.leadFromX, - placement.leadFromY + placement.leaderOffset, - ); - } else { - ctx.moveTo( - placement.leadFromX + placement.leaderOffset, - placement.leadFromY, - ); + ctx.lineWidth = width / (placement.paintScale || 1); + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + let startX = placement.leadFromX; + let startY = placement.leadFromY; + if (placement.leaderOffset !== 0) { + if (placement.corner === 'above' || placement.corner === 'below') { + startY += placement.leaderOffset; + } else { + startX += placement.leaderOffset; + } } - ctx.lineTo(placement.leadToX, placement.leadToY); + ctx.beginPath(); + ctx.moveTo(startX, startY); + if ( + style === 'elbow' && + (placement.corner === 'left' || placement.corner === 'right') + ) { + const elbowY = + placement.rect.y + Math.min(Math.max(12, placement.rect.h * 0.12), 26); + const verticalLength = Math.abs(elbowY - startY); + const horizontalLength = Math.abs(placement.leadToX - startX); + const totalLength = verticalLength + horizontalLength; + const visibleLength = totalLength * reveal; + if (visibleLength <= verticalLength && verticalLength > 0) { + const direction = Math.sign(elbowY - startY); + ctx.lineTo(startX, startY + visibleLength * direction); + } else { + ctx.lineTo(startX, elbowY); + const remaining = Math.max(0, visibleLength - verticalLength); + const direction = Math.sign(placement.leadToX - startX); + ctx.lineTo( + startX + Math.min(horizontalLength, remaining) * direction, + elbowY, + ); + } + ctx.stroke(); + return; + } + ctx.lineTo( + startX + (placement.leadToX - startX) * reveal, + startY + (placement.leadToY - startY) * reveal, + ); ctx.stroke(); } -function drawCardChrome(ctx, entry, placement, selected = false) { +function drawAnchorDot(ctx, entry, placement) { + if (!entry.anchorDot) return; + const paintScale = placement.paintScale || 1; + const radius = WORLD_OVERLAY_STYLE.anchorDotRadius / paintScale; + ctx.beginPath(); + ctx.arc(placement.anchorX, placement.anchorY, radius, 0, Math.PI * 2); + ctx.fillStyle = entry.accent || WORLD_OVERLAY_STYLE.accent; + ctx.fill(); + ctx.strokeStyle = WORLD_OVERLAY_STYLE.anchorDotStroke; + ctx.lineWidth = WORLD_OVERLAY_STYLE.anchorDotStrokeWidth / paintScale; + ctx.stroke(); +} + +function drawCardChrome( + ctx, + entry, + placement, + selected = false, + drawLeaderLine = true, +) { const { x, y, w, h } = placement.rect; const accent = entry.accent || WORLD_OVERLAY_STYLE.accent; - drawLeader(ctx, placement, accent); + if (drawLeaderLine) + drawLeader(ctx, placement, accent, 1, 1, entry.leaderStyle); ctx.beginPath(); roundedRectPath(ctx, x, y, w, h); ctx.fillStyle = selected @@ -821,18 +880,47 @@ export function paintTrack(ctx, entry, placement, alpha = 1) { return placement.rect; } -/** Paint a standard detail card. */ -export function paintCard(ctx, entry, placement, alpha = 1) { +/** Paint a standard detail card, with an opt-in staged anchor/leader reveal. */ +export function paintCard( + ctx, + entry, + placement, + alpha = 1, + leaderProgress = 1, + contentAlpha = 1, +) { ctx.save(); ctx.globalAlpha = alpha; - drawCardChrome(ctx, entry, placement, false); + if (entry.anchorDot) { + drawLeader( + ctx, + placement, + entry.accent || WORLD_OVERLAY_STYLE.accent, + leaderProgress, + WORLD_OVERLAY_STYLE.leaderWidth, + entry.leaderStyle, + ); + drawAnchorDot(ctx, entry, placement); + ctx.globalAlpha = + alpha * Math.max(0, Math.min(1, Number(contentAlpha) || 0)); + drawCardChrome(ctx, entry, placement, false, false); + } else { + drawCardChrome(ctx, entry, placement, false); + } drawCardText(ctx, entry, placement, false); ctx.restore(); return placement.rect; } /** Paint a thumbnail card. The image slot remains source-owned. */ -export function paintThumbnail(ctx, entry, placement, alpha = 1) { +export function paintThumbnail( + ctx, + entry, + placement, + alpha = 1, + leaderProgress = 1, + contentAlpha = 1, +) { const layout = entry._overlayLayout || {}; const { x, y, w, h } = placement.rect; const padX = Number.isFinite(layout.padX) ? layout.padX : 4; @@ -848,7 +936,12 @@ export function paintThumbnail(ctx, entry, placement, alpha = 1) { ctx, placement, entry.thumbnailLeaderColor || WORLD_OVERLAY_STYLE.leader, + leaderProgress, + entry.anchorDot ? WORLD_OVERLAY_STYLE.leaderWidth : 1, + entry.leaderStyle, ); + drawAnchorDot(ctx, entry, placement); + ctx.globalAlpha = alpha * Math.max(0, Math.min(1, Number(contentAlpha) || 0)); ctx.beginPath(); roundedRectPath(ctx, x, y, w, h, Number(entry.thumbnailRadius) || 4); ctx.fillStyle = entry.thumbnailBackground || WORLD_OVERLAY_STYLE.background; @@ -884,11 +977,13 @@ export function paintThumbnail(ctx, entry, placement, alpha = 1) { ctx.textBaseline = 'alphabetic'; ctx.fillStyle = entry.thumbnailTitleColor || WORLD_OVERLAY_STYLE.title; ctx.font = entry.thumbnailTitleFont || WORLD_OVERLAY_STYLE.fontTitle; - ctx.fillText( - titleChars > 0 ? title.slice(0, titleChars) : title, - imageX, - imageY + thumbH + titleH - 3, - ); + if (title && titleH > 0) { + ctx.fillText( + titleChars > 0 ? title.slice(0, titleChars) : title, + imageX, + imageY + thumbH + titleH - 3, + ); + } ctx.restore(); return placement.rect; } @@ -911,7 +1006,7 @@ export function paintTracked(ctx, entry, placement, alpha = 1) { const accent = entry.accent || WORLD_OVERLAY_STYLE.accent; ctx.save(); ctx.globalAlpha = alpha; - drawLeader(ctx, placement, accent); + drawLeader(ctx, placement, accent, 1, 1, entry.leaderStyle); ctx.beginPath(); roundedRectPath(ctx, x, y, w, h, 5); ctx.fillStyle = WORLD_OVERLAY_STYLE.background; @@ -1018,16 +1113,40 @@ export function paintDetectionCallout(ctx, callout, alpha = 1) { } /** Dispatch a normalized entry to its pure variant painter. */ -export function paintOverlayEntry(ctx, entry, placement, alpha = 1) { +export function paintOverlayEntry( + ctx, + entry, + placement, + alpha = 1, + leaderProgress = 1, + contentAlpha = 1, +) { if (entry.cardStyle === 'tactical') return paintTacticalCard(ctx, entry, placement, alpha); if (entry.variant === 'tracked') return paintTracked(ctx, entry, placement, alpha); if (entry.selected || entry.variant === 'selected') return paintSelected(ctx, entry, placement, alpha); - if (entry.variant === 'thumbnail') - return paintThumbnail(ctx, entry, placement, alpha); - if (entry.variant === 'card') return paintCard(ctx, entry, placement, alpha); + if (entry.variant === 'thumbnail') { + return paintThumbnail( + ctx, + entry, + placement, + alpha, + leaderProgress, + contentAlpha, + ); + } + if (entry.variant === 'card') { + return paintCard( + ctx, + entry, + placement, + alpha, + leaderProgress, + contentAlpha, + ); + } if (entry.variant === 'track') return paintTrack(ctx, entry, placement, alpha); return paintLabel(ctx, entry, placement, alpha); diff --git a/src/overlays/worldOverlayTokens.js b/src/overlays/worldOverlayTokens.js index a08c3985a..7fcf9dc64 100644 --- a/src/overlays/worldOverlayTokens.js +++ b/src/overlays/worldOverlayTokens.js @@ -24,6 +24,10 @@ export const WORLD_OVERLAY_STYLE = Object.freeze({ fontTrackedTitle: '600 13px "JetBrains Mono", monospace', fontTrackedDetail: '500 11px "JetBrains Mono", monospace', radius: 4, + anchorDotRadius: 3.2, + anchorDotStrokeWidth: 1, + anchorDotStroke: 'rgba(4, 12, 16, 0.96)', + leaderWidth: 1.35, }); /** CCTV's field-tested thumbnail-card overrides on top of shared card chrome. */ diff --git a/src/scenes/director.js b/src/scenes/director.js index 278e8a442..5958080c3 100644 --- a/src/scenes/director.js +++ b/src/scenes/director.js @@ -15,8 +15,14 @@ import { createStateChannel } from '../app/stateChannel.js'; import { SceneControls } from '../ui/scenes.js'; import * as Cesium from 'cesium'; -import { SCENE_RECIPES } from './recipes.js'; +import { + SCENE_APPEND_RECIPES, + SCENE_RECIPES, + getSceneAppendRecipeById, +} from './recipes.js'; import { sceneLayerPlan, sceneRequiresContextModeExit } from './scenePolicy.js'; +import { MAP_STACKS } from '../maps/catalog.js'; +import { INCIDENT_OVERVIEW_HOLD_SEC } from '../data/bhoteKoshiIncidentPlaces.js'; import { BLOOM_INTENSITY_DEFAULT, BLOOM_SCALE_VERSION, @@ -25,12 +31,18 @@ import { /** @constant {string} localStorage key for the serialized project */ const STORAGE_KEY = 'godsEyeView.sceneProject.v2'; +const STORAGE_CHECKPOINT_KEY = 'godsEyeView.sceneProject.checkpoint.v1'; /** @constant {number} Current schema version for project migration */ const PROJECT_VERSION = 3; /** @constant {number} Fallback camera flight duration per shot (seconds) */ const DEFAULT_SHOT_DURATION_SEC = 4; /** @constant {number} Default hold/pause after a shot completes (seconds) */ const DEFAULT_HOLD_SEC = 0.9; +const SCENE_MAP_STACK_IDS = new Set(MAP_STACKS.map(({ id }) => id)); + +function clamp01(value) { + return Math.max(0, Math.min(1, value)); +} /** * Generate a short random identifier with the given prefix. @@ -107,7 +119,7 @@ function normalizeBloomState( * with fully normalized shots. Each keyframe in the recipe's cameraPath becomes * one shot, inheriting the recipe's style, post, and layer configuration. * @param {Object} recipe - A SCENE_RECIPES entry - * @returns {{ id: string, title: string, shots: Object[] }} + * @returns {{ id: string, title: string, releaseLayerIds: string[], shots: Object[] }} */ function recipeToScene(recipe) { const post = recipe.post || {}; @@ -130,50 +142,73 @@ function recipeToScene(recipe) { // Convert each cameraPath keyframe into a shot with shared visual state const path = recipe.cameraPath || []; - const shots = path.map((keyframe, idx) => ({ - id: uid('shot'), - title: `Shot ${idx + 1}`, - durationSec: Math.max(0.2, keyframe.duration || DEFAULT_SHOT_DURATION_SEC), - holdSec: Math.max(0, keyframe.hold || 0), - camera: { - lat: keyframe.lat, - lon: keyframe.lon, - alt: keyframe.alt, - heading: keyframe.heading || 0, - pitch: keyframe.pitch || -40, - roll: keyframe.roll || 0, - }, - visual: { - style: recipe.style || 'normal', - bloom: { - enabled: typeof post.bloom === 'number' ? post.bloom > 0 : !!post.bloom, - intensity: - typeof post.bloom === 'number' - ? decodeBloomIntensity(post.bloom, 1) - : BLOOM_INTENSITY_DEFAULT, - version: BLOOM_SCALE_VERSION, + const shots = path.map((keyframe, idx) => { + const keyframeLayers = deepClone(layers); + for (const [layerId, target] of Object.entries(keyframe.layers || {})) { + keyframeLayers[layerId] = normalizeLayerEntry(target); + } + const mapStack = keyframe.mapStack || post.mapStack; + return { + id: uid('shot'), + title: keyframe.title || `Shot ${idx + 1}`, + durationSec: Math.max( + 0.2, + keyframe.duration || DEFAULT_SHOT_DURATION_SEC, + ), + holdSec: Math.max(0, keyframe.hold || 0), + camera: { + lat: keyframe.lat, + lon: keyframe.lon, + alt: keyframe.alt, + heading: keyframe.heading || 0, + pitch: keyframe.pitch || -40, + roll: keyframe.roll || 0, }, - sharpen: { - enabled: - typeof post.sharpen === 'boolean' ? post.sharpen : !!post.sharpen, - intensity: 65, + visual: { + style: recipe.style || 'normal', + bloom: { + enabled: + typeof post.bloom === 'number' ? post.bloom > 0 : !!post.bloom, + intensity: + typeof post.bloom === 'number' + ? decodeBloomIntensity(post.bloom, 1) + : BLOOM_INTENSITY_DEFAULT, + version: BLOOM_SCALE_VERSION, + }, + sharpen: { + enabled: + typeof post.sharpen === 'boolean' ? post.sharpen : !!post.sharpen, + intensity: 65, + }, + hud: { + visible: hudVisible, + variant: hudVariant, + }, + detection: { + mode: post.detectionMode || 'OFF', + density: 35, + }, + ...(SCENE_MAP_STACK_IDS.has(mapStack) ? { mapStack } : {}), + styleParams, }, - hud: { - visible: hudVisible, - variant: hudVariant, - }, - detection: { - mode: post.detectionMode || 'OFF', - density: 35, - }, - styleParams, - }, - layers: deepClone(layers), - })); + layers: keyframeLayers, + ...(recipe.id ? { sourcePackId: recipe.id } : {}), + ...(recipe.version ? { sourcePackVersion: recipe.version } : {}), + }; + }); return { id: recipe.id || uid('scene'), title: recipe.title || 'Untitled Scene', + releaseLayerIds: [ + ...new Set( + (Array.isArray(recipe.releaseLayerIds) + ? recipe.releaseLayerIds + : [] + ).filter((layerId) => typeof layerId === 'string' && layerId.trim()), + ), + ], + appliedShotPacks: [], shots, }; } @@ -187,6 +222,9 @@ function createDefaultProject() { version: PROJECT_VERSION, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + installedBuiltInSceneIds: SCENE_RECIPES.filter( + (recipe) => typeof recipe.installAlongsideSceneId === 'string', + ).map((recipe) => recipe.id), scenes: SCENE_RECIPES.map(recipeToScene), }; } @@ -263,6 +301,9 @@ function normalizeShot( ), ), }, + ...(SCENE_MAP_STACK_IDS.has(visual.mapStack) + ? { mapStack: visual.mapStack } + : {}), styleParams: visual.styleParams && typeof visual.styleParams === 'object' ? deepClone(visual.styleParams) @@ -274,6 +315,12 @@ function normalizeShot( normalizeLayerEntry(value), ]), ), + ...(typeof rawShot?.sourcePackId === 'string' + ? { sourcePackId: rawShot.sourcePackId } + : {}), + ...(Number.isFinite(Number(rawShot?.sourcePackVersion)) + ? { sourcePackVersion: Number(rawShot.sourcePackVersion) } + : {}), }; } @@ -300,6 +347,35 @@ function normalizeProject(rawProject) { return { id: scene?.id || uid('scene'), title: scene?.title || `Scene ${sceneIdx + 1}`, + releaseLayerIds: [ + ...new Set( + (Array.isArray(scene?.releaseLayerIds) + ? scene.releaseLayerIds + : [] + ).filter( + (layerId) => typeof layerId === 'string' && layerId.trim(), + ), + ), + ], + appliedShotPacks: (Array.isArray(scene?.appliedShotPacks) + ? scene.appliedShotPacks + : [] + ) + .filter((entry) => typeof entry?.id === 'string') + .map((entry) => ({ + id: entry.id, + version: Number(entry.version) || 1, + ...(entry.shotBindings && typeof entry.shotBindings === 'object' + ? { + shotBindings: Object.fromEntries( + Object.entries(entry.shotBindings).filter( + ([title, shotId]) => + typeof title === 'string' && typeof shotId === 'string', + ), + ), + } + : {}), + })), shots, }; }) @@ -314,6 +390,14 @@ function normalizeProject(rawProject) { version: PROJECT_VERSION, createdAt: rawProject.createdAt || new Date().toISOString(), updatedAt: new Date().toISOString(), + installedBuiltInSceneIds: [ + ...new Set( + (Array.isArray(rawProject.installedBuiltInSceneIds) + ? rawProject.installedBuiltInSceneIds + : [] + ).filter((sceneId) => typeof sceneId === 'string' && sceneId.trim()), + ), + ], scenes, }; } @@ -332,25 +416,42 @@ export class SceneDirector { * @param {Object} styleManager - Controls visual state (bloom, sharpen, HUD, detection, style presets) * @param {Object} dataManager - Manages data layer enable/disable and per-layer params */ - constructor(viewer, styleManager, dataManager) { + constructor( + viewer, + styleManager, + dataManager, + { isMapStackAvailable = () => false } = {}, + ) { this._destroyed = false; this._pendingWork = new Set(); this.viewer = viewer; this.styleManager = styleManager; this.dataManager = dataManager; + this._isMapStackAvailable = isMapStackAvailable; /** @type {boolean} True while a scene run is in progress */ this._running = false; + this._previewRun = false; /** @type {{ cancelled: boolean }|null} Cancellation token for the active run */ this._runToken = null; /** @type {number} Monotonic LOAD counter — only the newest LOAD may land */ this._loadGeneration = 0; + /** @type {number} Monotonic camera-travel id for layer-owned motion. */ + this._sceneTravelGeneration = 0; + /** @type {{id:number,scene:Object,shot:Object,durationSec:number}|null} */ + this._activeSceneTravel = null; /** @type {AbortController|null} Aborts the active run's layer transitions */ this._runAbort = null; /** @type {AbortController|null} Aborts the in-flight LOAD's layer transitions */ this._loadAbort = null; /** @type {number|null} setInterval ID for the progress bar ticker */ this._progressTimer = null; + this._shotProgressTimer = null; + this._sceneClockTimer = null; + this._sceneClockListeners = new Set(); + this._sceneClockSnapshot = null; + this._runIdleResolvers = new Set(); + this._sceneSeekGeneration = 0; /** @type {Object|null} Telemetry accumulator for the current run */ this._activeRun = null; /** @type {Object|null} Telemetry from the most recent completed run */ @@ -361,6 +462,8 @@ export class SceneDirector { this._project = this._loadProject(); this._selectedSceneId = this._project.scenes[0]?.id || null; this._selectedShotId = this._project.scenes[0]?.shots[0]?.id || null; + /** @type {string|null} Scene whose layer state most recently landed. */ + this._loadedSceneId = null; this._presentation = { status: 'Ready', @@ -374,7 +477,35 @@ export class SceneDirector { ...this._presentation, hasRun: !!this._lastRunJson, })); + this._bootstrapLegacyShotPacks(); + // Upgrade only already-installed packs; unrelated Scenes are untouched. + for (const scene of this._project.scenes) { + for (const marker of [...(scene.appliedShotPacks || [])]) { + const recipe = getSceneAppendRecipeById(marker.id); + if ( + recipe?.expansionFromVersion && + marker.version > 0 && + marker.version <= recipe.expansionFromVersion + ) { + this.appendShotPack(scene.id, marker.id, { + render: false, + announce: false, + }); + } + } + } this._initUI(); + this._visibilityUnsubscribe = + this.dataManager.subscribeVisibilityRequests?.((change) => { + if ( + change.enabled !== false || + !['user', 'voice', 'tool'].includes(change.origin) + ) + return; + const scene = this._getSelectedScene(); + if (!scene?.releaseLayerIds?.includes(change.layerId)) return; + this.stopScene('Scene layer turned off'); + }); } _trackWork(promise) { @@ -389,6 +520,8 @@ export class SceneDirector { destroy() { if (this._destroyPromise) return this._destroyPromise; this._destroyed = true; + this._sceneSeekGeneration++; + this._visibilityUnsubscribe?.(); this._controls?.destroy(); this._state.destroy(); this._destroyPromise = Promise.resolve().then(async () => { @@ -399,6 +532,10 @@ export class SceneDirector { clearTimeout(this._storageToastTimer); await Promise.allSettled(this._pendingWork || []); clearInterval(this._progressTimer); + clearInterval(this._shotProgressTimer); + clearInterval(this._sceneClockTimer); + this._cancelActiveSceneTravel(); + this._sceneClockListeners.clear(); }); return this._destroyPromise; } @@ -412,7 +549,48 @@ export class SceneDirector { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return createDefaultProject(); - return normalizeProject(JSON.parse(raw)); + const project = normalizeProject(JSON.parse(raw)); + const installed = new Set(project.installedBuiltInSceneIds || []); + let migrated = false; + for (const recipe of SCENE_RECIPES) { + if ( + typeof recipe.installAlongsideSceneId !== 'string' || + installed.has(recipe.id) + ) { + continue; + } + let anchorIndex = project.scenes.findIndex( + (scene) => scene.id === recipe.installAlongsideSceneId, + ); + if (anchorIndex < 0 && recipe.installAlongsideFallbackSceneId) { + anchorIndex = project.scenes.findIndex( + (scene) => scene.id === recipe.installAlongsideFallbackSceneId, + ); + } + if (anchorIndex < 0) continue; + const alreadyPresent = project.scenes.some( + (scene) => scene.id === recipe.id || scene.title === recipe.title, + ); + if (!alreadyPresent) { + project.scenes.splice(anchorIndex + 1, 0, recipeToScene(recipe)); + } + installed.add(recipe.id); + migrated = true; + } + if (migrated) { + project.installedBuiltInSceneIds = [...installed]; + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(project)); + } catch (e) { + // Keep the migrated in-memory project usable even if this origin + // refuses persistence; the normal UI save path will surface errors. + console.warn( + '[Scenes] Could not persist built-in scene migration:', + e, + ); + } + } + return project; } catch { return createDefaultProject(); } @@ -624,6 +802,385 @@ export class SceneDirector { return layers; } + /** + * Upgrade an exact legacy, browser-saved scene into the current shot + * inventory. Recipe matching leaves unrelated user-authored scenes untouched. + * The original project is checkpointed before the first mutation. + */ + _bootstrapLegacyShotPacks() { + for (const recipe of SCENE_APPEND_RECIPES) { + const bootstrap = recipe.legacySceneBootstrap; + if (!bootstrap || !Array.isArray(bootstrap.cameraPath)) continue; + const expectedTitles = Array.isArray(bootstrap.fromShotTitles) + ? bootstrap.fromShotTitles + : []; + const scene = this._project.scenes.find( + (candidate) => + candidate.title === bootstrap.targetSceneTitle && + candidate.shots.length === expectedTitles.length && + candidate.shots.every( + (shot, index) => shot.title === expectedTitles[index], + ), + ); + if (!scene) continue; + + const originalProject = deepClone(this._project); + try { + localStorage.setItem( + STORAGE_CHECKPOINT_KEY, + JSON.stringify(originalProject), + ); + } catch { + this._toastStorageError(); + continue; + } + + const canonicalBase = recipeToScene({ + ...recipe, + id: null, + cameraPath: bootstrap.cameraPath, + }).shots; + for ( + let index = 0; + index < Math.min(scene.shots.length, canonicalBase.length); + index++ + ) { + canonicalBase[index].id = scene.shots[index].id; + canonicalBase[index].camera = deepClone(scene.shots[index].camera); + } + scene.shots = canonicalBase; + + const result = this.appendShotPack(scene.id, recipe.id, { + writeCheckpoint: false, + render: false, + announce: false, + }); + if (!result.appended) { + this._project = originalProject; + continue; + } + this._selectedSceneId = scene.id; + this._selectedShotId = scene.shots[0]?.id || null; + } + } + + /** + * Append a versioned shot pack to one exact persisted + * Scene. Existing authored shots are never replaced, and the pack marker + * makes repeat invocations idempotent. + * + * @param {string} sceneId Exact target Scene id + * @param {string} packId Registered append-recipe id + * @param {Object} [options] + * @param {boolean} [options.writeCheckpoint=true] + * @param {boolean} [options.render=true] + * @param {boolean} [options.announce=true] + * @returns {{appended: boolean, reason?: string, shotCount?: number, firstShotId?: string}} + */ + appendShotPack( + sceneId, + packId, + { writeCheckpoint = true, render = true, announce = true } = {}, + ) { + const scene = this._project.scenes.find( + (candidate) => candidate.id === sceneId, + ); + if (!scene) return { appended: false, reason: 'scene-not-found' }; + const recipe = getSceneAppendRecipeById(packId); + if (!recipe) return { appended: false, reason: 'pack-not-found' }; + scene.appliedShotPacks ||= []; + const marker = scene.appliedShotPacks.find( + (entry) => entry.id === recipe.id, + ); + const targetVersion = Number(recipe.version) || 1; + if ((Number(marker?.version) || 0) >= targetVersion) { + return { appended: false, reason: 'already-appended' }; + } + + const pack = recipeToScene(recipe); + const existingPackShots = scene.shots.filter( + (shot) => shot.sourcePackId === recipe.id, + ); + const expanding = + recipe.expansionFromVersion && + marker?.version > 0 && + marker.version <= recipe.expansionFromVersion && + existingPackShots.length !== recipe.requiredSourcePackBeatIds.length; + const existingPackBeatIds = existingPackShots.map( + (shot) => shot.layers?.[recipe.requiredSourcePackLayerId]?.params?.beatId, + ); + const previousSourcePackVariants = Array.isArray( + recipe.previousRequiredSourcePackBeatIdVariants, + ) + ? recipe.previousRequiredSourcePackBeatIdVariants + : [recipe.previousRequiredSourcePackBeatIds]; + if ( + expanding && + !previousSourcePackVariants.some( + (beatIds) => + JSON.stringify(existingPackBeatIds) === JSON.stringify(beatIds), + ) + ) { + return { + appended: false, + updated: false, + reason: 'source-pack-mismatch', + }; + } + const adoptableTitles = new Set( + Array.isArray(recipe.adoptExistingShotTitles) + ? recipe.adoptExistingShotTitles + : [], + ); + const adoptedShotIds = new Map(); + if (expanding && adoptableTitles.size) { + for (const packShot of pack.shots) { + if (!adoptableTitles.has(packShot.title)) continue; + const packBeatId = + packShot.layers?.[recipe.requiredSourcePackLayerId]?.params?.beatId; + if ( + existingPackShots.some( + (shot) => + shot.layers?.[recipe.requiredSourcePackLayerId]?.params + ?.beatId === packBeatId, + ) + ) + continue; + const candidates = scene.shots.filter( + (shot) => !shot.sourcePackId && shot.title === packShot.title, + ); + if (candidates.length === 1) + adoptedShotIds.set(packShot.id, candidates[0].id); + } + } + const appendedShots = marker + ? expanding + ? pack.shots.filter( + (shot) => + !existingPackShots.some( + (existing) => + existing.layers?.[recipe.requiredSourcePackLayerId]?.params + ?.beatId === + shot.layers?.[recipe.requiredSourcePackLayerId]?.params + ?.beatId, + ) && !adoptedShotIds.has(shot.id), + ) + : [] + : pack.shots; + const nextShots = deepClone([...scene.shots, ...appendedShots]); + for (const [packShotId, existingShotId] of adoptedShotIds) { + const existingShot = nextShots.find((shot) => shot.id === existingShotId); + const packShot = pack.shots.find((shot) => shot.id === packShotId); + if (!existingShot || !packShot) continue; + existingShot.sourcePackId = recipe.id; + existingShot.sourcePackVersion = targetVersion; + existingShot.layers = { + ...deepClone(existingShot.layers || {}), + ...deepClone(packShot.layers), + }; + } + if (expanding) { + // Insert additions around existing beats, never replace their IDs/cameras. + for (let i = pack.shots.length - 1; i >= 0; i--) { + const added = appendedShots.find( + (shot) => shot.id === pack.shots[i].id, + ); + if (!added) continue; + const at = nextShots.findIndex((shot) => shot.id === added.id); + const [shot] = nextShots.splice(at, 1); + const nextBeatId = + pack.shots[i + 1]?.layers?.[recipe.requiredSourcePackLayerId]?.params + ?.beatId; + const before = nextShots.findIndex( + (item) => + item.sourcePackId === recipe.id && + item.layers?.[recipe.requiredSourcePackLayerId]?.params?.beatId === + nextBeatId, + ); + nextShots.splice(before < 0 ? nextShots.length : before, 0, shot); + } + } + const requiredShotTitles = Array.isArray(recipe.requiredShotTitles) + ? recipe.requiredShotTitles + : []; + const addedBindings = Object.fromEntries([ + ...appendedShots.map((shot) => [shot.title, shot.id]), + ...[...adoptedShotIds] + .map(([packShotId, existingShotId]) => [ + pack.shots.find((shot) => shot.id === packShotId)?.title, + existingShotId, + ]) + .filter(([title]) => typeof title === 'string'), + ]); + const markerBindings = marker?.shotBindings + ? { ...marker.shotBindings, ...addedBindings } + : null; + if (requiredShotTitles.length) { + const resolvedShots = markerBindings + ? requiredShotTitles.map( + (title) => + nextShots.find((shot) => shot.id === markerBindings[title]) || + null, + ) + : nextShots; + const inventoryMatches = + resolvedShots.length === requiredShotTitles.length && + (markerBindings + ? resolvedShots.every(Boolean) && + new Set(resolvedShots.map((shot) => shot.id)).size === + requiredShotTitles.length + : resolvedShots.every( + (shot, index) => shot.title === requiredShotTitles[index], + )); + if (!inventoryMatches) { + this._updateStatus( + `Cannot update ${recipe.title}: shot inventory changed`, + ); + return { + appended: false, + updated: false, + reason: 'shot-inventory-mismatch', + }; + } + } + const requiredSourcePackBeatIds = Array.isArray( + recipe.requiredSourcePackBeatIds, + ) + ? recipe.requiredSourcePackBeatIds + : []; + const requiredSourcePackLayerId = + typeof recipe.requiredSourcePackLayerId === 'string' + ? recipe.requiredSourcePackLayerId + : null; + if (requiredSourcePackBeatIds.length && requiredSourcePackLayerId) { + const sourcePackBeatIds = nextShots + .filter((shot) => shot.sourcePackId === recipe.id) + .map( + (shot) => shot.layers?.[requiredSourcePackLayerId]?.params?.beatId, + ); + const sourcePackMatches = + sourcePackBeatIds.length === requiredSourcePackBeatIds.length && + sourcePackBeatIds.every( + (beatId, index) => beatId === requiredSourcePackBeatIds[index], + ); + if (!sourcePackMatches) { + this._updateStatus( + `Cannot update ${recipe.title}: evidence beats changed`, + ); + return { + appended: false, + updated: false, + reason: 'source-pack-mismatch', + }; + } + } + + const resolvePatchedShot = (patch) => { + const boundShotId = markerBindings?.[patch.title]; + if (boundShotId) + return nextShots.find((shot) => shot.id === boundShotId) || null; + const matches = nextShots.filter((shot) => shot.title === patch.title); + return matches.length === 1 ? matches[0] : null; + }; + const resolvedPatches = (recipe.shotPatches || []).map((patch) => ({ + patch, + shot: resolvePatchedShot(patch), + })); + if (resolvedPatches.some(({ shot }) => !shot)) { + this._updateStatus( + `Cannot update ${recipe.title}: shot bindings are incomplete`, + ); + return { + appended: false, + updated: false, + reason: 'shot-bindings-incomplete', + }; + } + + let patchedShotCount = 0; + for (const { patch, shot } of resolvedPatches) { + if (patch.camera && !expanding) { + shot.camera = normalizeShot({ ...shot, camera: patch.camera }).camera; + } + if (Number.isFinite(Number(patch.holdSec))) { + shot.holdSec = Math.max(0, Number(patch.holdSec)); + } + if (patch.visual) { + shot.visual = normalizeShot({ + ...shot, + visual: { ...shot.visual, ...patch.visual }, + }).visual; + } + shot.layers ||= {}; + for (const [layerId, target] of Object.entries(patch.layers || {})) { + shot.layers[layerId] = normalizeLayerEntry(target); + } + patchedShotCount++; + } + const nextReleaseLayerIds = [ + ...new Set([ + ...(scene.releaseLayerIds || []), + ...(pack.releaseLayerIds || []), + ]), + ]; + const shotBindings = Object.fromEntries( + requiredShotTitles + .map((title) => [ + title, + nextShots.find((shot) => shot.id === markerBindings?.[title])?.id ?? + nextShots.find((shot) => shot.title === title)?.id, + ]) + .filter(([, shotId]) => typeof shotId === 'string'), + ); + + if (writeCheckpoint) { + try { + localStorage.setItem( + STORAGE_CHECKPOINT_KEY, + JSON.stringify(this._project), + ); + } catch { + this._toastStorageError(); + return { appended: false, reason: 'checkpoint-failed' }; + } + } + + scene.shots = nextShots; + scene.releaseLayerIds = nextReleaseLayerIds; + const nextMarker = { + id: recipe.id, + version: targetVersion, + ...(Object.keys(shotBindings).length ? { shotBindings } : {}), + }; + if (marker) { + const markerIndex = scene.appliedShotPacks.indexOf(marker); + scene.appliedShotPacks[markerIndex] = nextMarker; + } else { + scene.appliedShotPacks.push(nextMarker); + } + this._selectedSceneId = scene.id; + this._selectedShotId = appendedShots[0]?.id || this._selectedShotId; + this._saveProject(); + if (render) { + this._renderSceneSelect(); + this._renderShotList(); + } + if (announce) { + this._updateStatus( + marker + ? `Updated ${patchedShotCount} ${patchedShotCount === 1 ? 'shot' : 'shots'}: ${recipe.title}` + : `Appended ${appendedShots.length} shots: ${recipe.title}`, + ); + } + return { + appended: appendedShots.length > 0, + updated: Boolean(marker), + shotCount: appendedShots.length, + patchedShotCount, + firstShotId: appendedShots[0]?.id, + }; + } + /** * Capture the current camera position, visual state, and layer states as a * new shot appended to the selected scene. No-op if no scene is selected. @@ -714,45 +1271,723 @@ export class SceneDirector { * @param {string} shotId * @param {Object} [options] * @param {number} [options.flyDuration=2.2] - Camera flight duration in seconds + * @param {Object|null} [options.fromCamera=null] - Optional authored replay start pose + * @param {Object|null} [options.sceneSeek=null] - Deterministic authored-clock snapshot */ loadShot(sceneId, shotId, options) { if (this._destroyed) return Promise.resolve({ started: false, reason: 'destroyed' }); + this._sceneSeekGeneration++; return this._trackWork(this._loadShot(sceneId, shotId, options)); } - async _loadShot(sceneId, shotId, { flyDuration = 2.2 } = {}) { - if (this._running) return; + async _loadShot( + sceneId, + shotId, + { flyDuration = 2.2, fromCamera = null, sceneSeek = null } = {}, + ) { + if (this._running) return { started: false, reason: 'already-running' }; const { scene, shot } = this._getShot(sceneId, shotId); - if (!scene || !shot) return; + if (!scene || !shot) return { started: false, reason: 'shot-not-found' }; + const previousScene = + this._project.scenes.find((item) => item.id === this._loadedSceneId) || + null; - if (!this._claimCameraOwnership()) return; + if (!this._claimCameraOwnership()) + return { started: false, reason: 'camera-unavailable' }; + if (fromCamera) this._setCameraView(fromCamera); // Supersede the previous LOAD before reserving this one: aborting first // means an in-flight layer transition is cancelled (and rolled back by the // manager) rather than merely ignored once it has already committed. + this._cancelActiveSceneTravel(); this._loadAbort?.abort(); const controller = new AbortController(); this._loadAbort = controller; const token = this._loadToken(++this._loadGeneration, controller.signal); + clearInterval(this._shotProgressTimer); + this._shotProgressTimer = null; + this._setProgress(0); + + if (previousScene && previousScene.id !== scene.id) { + const released = await this._releaseSceneLayers(previousScene, token); + if (!released || token.cancelled) { + if (!token.cancelled) + this._updateStatus(`Could not leave scene: ${previousScene.title}`); + return; + } + this._loadedSceneId = null; + } this._selectedSceneId = scene.id; this._selectedShotId = shot.id; this._renderSceneSelect(); this._renderShotList(); - await this.styleManager.applyVisualState(shot.visual, { + await this.styleManager.applyVisualState(this._visualStateForShot(shot), { isCurrent: () => !token.cancelled, }); if (token.cancelled) return; - await this._applyLayerStates(shot.layers || {}, token); + const seekState = + sceneSeek && typeof sceneSeek === 'object' ? sceneSeek : null; + const layerResult = await this._applyLayerStates( + this._layerStatesForShot(scene, shot, { + cameraSettled: seekState ? seekState.cameraProgress >= 1 : false, + sceneSeek: seekState, + }), + token, + ); if (token.cancelled) return; - await this._flyCamera(shot.camera, flyDuration, token); + // Layer state is already user-visible before the camera flight settles. + // Record its owner now so a newer cross-scene LOAD can release it even if + // it supersedes this request during the flight. + this._loadedSceneId = scene.id; + if (layerResult.refused.length) { + if (this._loadAbort === controller) this._loadAbort = null; + return { started: false, reason: 'layers-refused' }; + } + if (seekState) { + this._setCameraView(seekState.camera || shot.camera); + this._setProgress(seekState.sceneProgress); + this._publishSceneClock(scene, shot, seekState.sceneElapsedSec, { + running: false, + seeking: true, + }); + if (this._loadAbort === controller) this._loadAbort = null; + this._updateStatus(`Seeked: ${scene.title} / ${shot.title}`); + return { started: true, shotId }; + } + const holdSec = this._effectiveShotHoldSec(scene, shot); + const flightShare = flyDuration / Math.max(0.001, flyDuration + holdSec); + const sceneTiming = this._sceneTimingForShot(scene, shot); + this._startShotProgress( + token, + flyDuration, + sceneTiming.startProgress, + sceneTiming.startProgress + sceneTiming.durationProgress * flightShare, + { + scene, + shot, + sceneElapsedFrom: sceneTiming.startElapsedSec, + sceneElapsedTo: sceneTiming.startElapsedSec + flyDuration, + }, + ); + const cameraTravel = this._beginShotTravel(scene, shot, flyDuration); + try { + // _flyCamera calls Cesium's flyTo synchronously before yielding. Publish + // the trail phase immediately afterward so its clock overlaps actual + // camera motion, never an earlier asynchronous layer-reconcile wait. + const flight = this._flyCamera(shot.camera, flyDuration, token); + this._publishShotTravel(scene, shot, cameraTravel); + await flight; + } catch (error) { + this._cancelActiveSceneTravel(); + if (!token.cancelled) { + clearInterval(this._shotProgressTimer); + this._shotProgressTimer = null; + } + throw error; + } if (token.cancelled) return; + this._settleShotLayerStates(scene, shot, token, cameraTravel); + if (token.cancelled) return; + this._startShotProgress( + token, + holdSec, + sceneTiming.startProgress + sceneTiming.durationProgress * flightShare, + sceneTiming.endProgress, + { + scene, + shot, + sceneElapsedFrom: sceneTiming.startElapsedSec + flyDuration, + sceneElapsedTo: sceneTiming.endElapsedSec, + }, + ); if (this._loadAbort === controller) this._loadAbort = null; this._shotOutcome('shot-loaded', scene, shot); this._updateRuntime(''); + return { started: true, shotId }; + } + + /** + * Treat a scene-owned release layer as OFF in shots that do not declare it. + * This keeps an appended event lens from leaking backward into the scene's + * original authored shots while leaving ordinary sparse layer state intact. + */ + _layerStatesForShot( + scene, + shot, + { cameraSettled = false, cameraTravel = null, sceneSeek = null } = {}, + ) { + const states = deepClone(shot?.layers || {}); + const shotIndex = + scene?.shots?.findIndex(({ id }) => id === shot?.id) ?? -1; + const sceneShotDurations = + scene?.shots?.map((sceneShot) => + this._shotRuntimeDurationSec(scene, sceneShot), + ) || []; + const sceneDurationSec = sceneShotDurations.reduce( + (sum, duration) => sum + duration, + 0, + ); + const authoredSceneElapsedSec = sceneShotDurations + .slice(0, Math.max(0, shotIndex)) + .reduce((sum, duration) => sum + duration, 0); + const sceneElapsedSec = sceneSeek + ? Math.max( + 0, + Math.min(sceneDurationSec, Number(sceneSeek.sceneElapsedSec) || 0), + ) + : authoredSceneElapsedSec; + const sourceRecipe = getSceneAppendRecipeById(shot?.sourcePackId); + for (const state of Object.values(states)) { + if (state?.params?.presentation !== 'scene-beat') continue; + state.params.sceneSurface ||= shot.sourcePackId + ? 'evidence-beat' + : 'panel-only'; + const runtimeControls = + sourceRecipe?.runtimeControlsByBeat?.[state.params.beatId]; + if (runtimeControls) { + state.params.sceneControls = { + ...state.params.sceneControls, + ...runtimeControls, + ...(runtimeControls.deferEvidenceUntilCameraSettled + ? { cameraSettled } + : {}), + ...(sceneSeek + ? { + timelineSeek: { + shotElapsedSec: Number(sceneSeek.shotElapsedSec) || 0, + flightDurationSec: Number(sceneSeek.flightDurationSec) || 0, + holdDurationSec: Number(sceneSeek.holdDurationSec) || 0, + holdElapsedSec: Number(sceneSeek.holdElapsedSec) || 0, + cameraProgress: clamp01( + Number(sceneSeek.cameraProgress) || 0, + ), + holdProgress: clamp01(Number(sceneSeek.holdProgress) || 0), + }, + } + : {}), + }; + if (runtimeControls.evidencePathDuringCamera && cameraTravel) { + state.params.sceneControls.cameraTravel = { ...cameraTravel }; + } + } + state.params.sceneContext = { + sceneId: scene.id, + sceneTitle: scene.title, + shotId: shot.id, + shotTitle: shot.title, + shotIndex, + shotCount: scene.shots.length, + durationSec: shot.durationSec || DEFAULT_SHOT_DURATION_SEC, + holdSec: this._effectiveShotHoldSec(scene, shot, states), + sceneElapsedSec, + sceneDurationSec, + sceneProgress: + sceneDurationSec > 0 ? sceneElapsedSec / sceneDurationSec : 0, + ...(sceneSeek + ? { + shotElapsedSec: Number(sceneSeek.shotElapsedSec) || 0, + shotProgress: clamp01(Number(sceneSeek.shotProgress) || 0), + seeking: true, + } + : {}), + }; + } + for (const layerId of scene?.releaseLayerIds || []) { + if (!Object.hasOwn(states, layerId)) states[layerId] = { enabled: false }; + } + return states; + } + + /** Keep installed append-pack shots on any surface declared by their source recipe. */ + _visualStateForShot(shot) { + const sourceRecipe = getSceneAppendRecipeById(shot?.sourcePackId); + const sceneBeatState = Object.values(shot?.layers || {}).find( + (state) => state?.params?.presentation === 'scene-beat', + ); + const controls = { + ...sceneBeatState?.params?.sceneControls, + ...sourceRecipe?.runtimeControlsByBeat?.[sceneBeatState?.params?.beatId], + }; + if (controls.imageryComparison === true) { + return { ...(shot.visual || {}), mapStack: 'esri-imagery' }; + } + if ( + sourceRecipe?.standaloneSurfaceBeatIds?.includes( + sceneBeatState?.params?.beatId, + ) + ) { + return { ...(shot.visual || {}), mapStack: 'esri-imagery' }; + } + const visual = sourceRecipe?.photorealSurfaceBeatIds?.includes( + sceneBeatState?.params?.beatId, + ) + ? { ...(shot.visual || {}), mapStack: 'photoreal' } + : shot?.visual || {}; + const isNepalShot = + shot?.layers?.['bhote-koshi-2026']?.enabled || + shot?.layers?.['bhote-koshi-locator']?.enabled; + // Check actual loaded capability, not the presence of a credential. A key + // can exist even when its tileset failed to initialize. Resolve at playback + // time so saved cameras and provider preferences remain portable. + if ( + isNepalShot && + visual.mapStack === 'photoreal' && + !this._isMapStackAvailable('photoreal') + ) { + return { ...visual, mapStack: 'esri-imagery' }; + } + return visual; + } + + /** Keep a shot on its final camera pose until its authored layer reveal finishes. */ + _effectiveShotHoldSec(scene, shot, resolvedStates = null) { + const states = resolvedStates || this._layerStatesForShot(scene, shot); + const recipe = getSceneAppendRecipeById(shot?.sourcePackId); + const overrides = Object.values(states) + .filter( + (state) => + state?.enabled && state.params?.presentation === 'scene-beat', + ) + .map((state) => recipe?.runtimeHoldSecByBeat?.[state.params.beatId]) + .filter((value) => Number.isFinite(value) && value >= 0); + const authoredHoldSec = overrides.length + ? Math.max(...overrides) + : Number(shot?.holdSec) || 0; + const overview = states['bhote-koshi-locator']; + const overviewHoldSec = + overview?.enabled && + overview.params?.presentation === 'bhote-koshi-incident-places' + ? INCIDENT_OVERVIEW_HOLD_SEC + : 0; + return Object.values(states).reduce( + (holdSec, state) => { + const controls = state?.params?.sceneControls; + return Math.max( + holdSec, + Number(controls?.minimumHoldSec) || 0, + controls?.evidenceSequence === true + ? Number(controls.evidenceSequenceDurationSec) || 0 + : 0, + ); + }, + Math.max(overviewHoldSec, authoredHoldSec), + ); + } + + /** Resolve one shot's authored flight plus any runtime-enforced hold. */ + _shotRuntimeDurationSec(scene, shot) { + const states = deepClone(shot?.layers || {}); + const sourceRecipe = getSceneAppendRecipeById(shot?.sourcePackId); + for (const state of Object.values(states)) { + if (state?.params?.presentation !== 'scene-beat') continue; + const runtimeControls = + sourceRecipe?.runtimeControlsByBeat?.[state.params.beatId]; + if (runtimeControls) { + state.params.sceneControls = { + ...state.params.sceneControls, + ...runtimeControls, + }; + } + } + return ( + (shot?.durationSec || DEFAULT_SHOT_DURATION_SEC) + + this._effectiveShotHoldSec(scene, shot, states) + ); + } + + /** Signal camera-dependent layers only after the authored flight completes. */ + _settleShotLayerStates(scene, shot, token = null, cameraTravel = null) { + if (token?.cancelled) return false; + const settledTravel = cameraTravel + ? { + ...cameraTravel, + active: false, + completed: true, + cancelled: false, + } + : null; + const states = this._layerStatesForShot(scene, shot, { + cameraSettled: true, + cameraTravel: settledTravel, + }); + for (const [layerId, state] of Object.entries(states)) { + if ( + !state?.enabled || + state.params?.sceneControls?.deferEvidenceUntilCameraSettled !== true + ) { + continue; + } + this.dataManager.setLayerParams(layerId, state.params, { + origin: 'scene', + }); + } + if (this._activeSceneTravel?.id === cameraTravel?.id) + this._activeSceneTravel = null; + return !token?.cancelled; + } + + /** Start one camera-owned travel phase that scene layers may follow. */ + _beginShotTravel(scene, shot, durationSec) { + const travel = { + id: ++this._sceneTravelGeneration, + durationSec: Math.max( + 0.2, + Number(durationSec) || DEFAULT_SHOT_DURATION_SEC, + ), + active: true, + completed: false, + cancelled: false, + }; + this._activeSceneTravel = { ...travel, scene, shot }; + return travel; + } + + /** Publish a travel phase only after Cesium has accepted the camera flight. */ + _publishShotTravel(scene, shot, cameraTravel) { + const states = this._layerStatesForShot(scene, shot, { cameraTravel }); + for (const [layerId, state] of Object.entries(states)) { + if ( + !state?.enabled || + state.params?.sceneControls?.evidencePathDuringCamera !== true + ) + continue; + this.dataManager.setLayerParams(layerId, state.params, { + origin: 'scene', + }); + } + } + + /** Revoke layer-owned travel motion before a newer load, STOP, or teardown. */ + _cancelActiveSceneTravel() { + // The opening locator owns an additional delayed approach/orbit even after + // the director's authored flight has settled. Revoke it before cancelling + // the camera, whose moveEnd/complete callbacks may already be queued. + this.dataManager?.layers + ?.get('bhote-koshi-locator') + ?.module.cancelSceneMotion?.(); + const active = this._activeSceneTravel; + if (!active) return false; + this._activeSceneTravel = null; + const cancelledTravel = { + id: active.id, + durationSec: active.durationSec, + active: false, + completed: false, + cancelled: true, + }; + const states = this._layerStatesForShot(active.scene, active.shot, { + cameraSettled: false, + cameraTravel: cancelledTravel, + }); + for (const [layerId, state] of Object.entries(states)) { + if ( + !state?.enabled || + state.params?.sceneControls?.evidencePathDuringCamera !== true + ) + continue; + let cancelled = false; + try { + cancelled = + this.dataManager.setLayerParams(layerId, state.params, { + origin: 'scene', + }) !== false; + } catch (error) { + console.warn( + `[Scenes] Could not cancel camera-led state for ${layerId}:`, + error, + ); + } + if (cancelled) continue; + // Fail closed: an exceptional manager/module must not leave its previous + // RAF or deadline clock moving after STOP or a superseding LOAD. + try { + Promise.resolve( + this.dataManager.setEnabled(layerId, false, { origin: 'scene' }), + ).catch((error) => + console.warn( + `[Scenes] Could not disable stale layer ${layerId}:`, + error, + ), + ); + } catch (error) { + console.warn( + `[Scenes] Could not disable stale layer ${layerId}:`, + error, + ); + } + } + return true; + } + + /** Replay exactly one authored shot from its preceding pose while keeping panels visible. */ + async replayShot(sceneId, shotId) { + if (this._destroyed) return { started: false, reason: 'destroyed' }; + if (this._running) return { started: false, reason: 'already-running' }; + const { scene, shot } = this._getShot(sceneId, shotId); + if (!scene || !shot) return { started: false, reason: 'shot-not-found' }; + const shotIndex = scene.shots.findIndex(({ id }) => id === shot.id); + const previousShot = + scene.shots[(shotIndex - 1 + scene.shots.length) % scene.shots.length]; + const result = await this.loadShot(sceneId, shotId, { + flyDuration: shot.durationSec || DEFAULT_SHOT_DURATION_SEC, + fromCamera: previousShot?.camera || null, + }); + return result || { started: false, reason: 'cancelled' }; + } + + /** Continue through this scene's remaining shots without entering recording preview. */ + async continueScene(sceneId, shotId) { + const { scene, shot } = this._getShot(sceneId, shotId); + if (!scene || !shot) return { started: false, reason: 'shot-not-found' }; + return this.startScene(sceneId, { + single: true, + afterShotId: shotId, + preview: false, + }); + } + + /** Load the adjacent shot inside one scene without wrapping at either end. */ + async loadAdjacentShot(sceneId, shotId, direction) { + if (this._destroyed) return false; + if (this._running) return false; + const scene = this._project.scenes.find(({ id }) => id === sceneId); + const shotIndex = scene?.shots?.findIndex(({ id }) => id === shotId) ?? -1; + const targetIndex = shotIndex + (direction < 0 ? -1 : 1); + const target = scene?.shots?.[targetIndex]; + if (!target) return false; + const result = await this.loadShot(scene.id, target.id); + return result?.started === true; + } + + /** Resolve absolute authored-clock timing for one shot. */ + _sceneTimingForShot(scene, shot) { + const durations = + scene?.shots?.map((item) => this._shotRuntimeDurationSec(scene, item)) || + []; + const shotIndex = + scene?.shots?.findIndex(({ id }) => id === shot?.id) ?? -1; + const totalSec = durations.reduce((sum, duration) => sum + duration, 0); + const startElapsedSec = durations + .slice(0, Math.max(0, shotIndex)) + .reduce((sum, duration) => sum + duration, 0); + const durationSec = shotIndex >= 0 ? durations[shotIndex] : 0; + const endElapsedSec = Math.min(totalSec, startElapsedSec + durationSec); + return { + shotIndex, + totalSec, + durationSec, + startElapsedSec, + endElapsedSec, + startProgress: totalSec > 0 ? startElapsedSec / totalSec : 0, + endProgress: totalSec > 0 ? endElapsedSec / totalSec : 1, + durationProgress: totalSec > 0 ? durationSec / totalSec : 0, + }; + } + + /** Interpolate an authored camera flight at a normalized time. */ + _cameraAtProgress(fromCamera, toCamera, progress) { + const target = toCamera || fromCamera; + const source = fromCamera || target; + if (!source || !target) return target || source || null; + const t = clamp01(Number(progress) || 0); + const eased = t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2; + const lerp = (from, to) => + Number(from) + (Number(to) - Number(from)) * eased; + const lerpAngle = (from, to) => { + const start = Number(from) || 0; + const delta = ((Number(to) - start + 540) % 360) - 180; + return start + delta * eased; + }; + return { + lat: lerp(source.lat, target.lat), + lon: lerp(source.lon, target.lon), + alt: lerp(source.alt, target.alt), + heading: lerpAngle(source.heading, target.heading), + pitch: lerp(source.pitch, target.pitch), + roll: lerpAngle(source.roll, target.roll), + }; + } + + /** Resolve a normalized scene-clock position to a shot, phase, and camera pose. */ + _sceneSeekState(scene, progress) { + if (!scene?.shots?.length) return null; + const normalized = clamp01(Number(progress) || 0); + const durations = scene.shots.map((shot) => + this._shotRuntimeDurationSec(scene, shot), + ); + const totalSec = durations.reduce((sum, duration) => sum + duration, 0); + const targetSec = normalized * Math.max(0, totalSec); + let startElapsedSec = 0; + let shotIndex = scene.shots.length - 1; + for (let index = 0; index < scene.shots.length; index += 1) { + const endElapsedSec = startElapsedSec + durations[index]; + if (targetSec < endElapsedSec || index === scene.shots.length - 1) { + shotIndex = index; + break; + } + startElapsedSec = endElapsedSec; + } + const shot = scene.shots[shotIndex]; + const flightDurationSec = shot.durationSec || DEFAULT_SHOT_DURATION_SEC; + const holdDurationSec = this._effectiveShotHoldSec(scene, shot); + const shotDurationSec = Math.max( + 0.001, + flightDurationSec + holdDurationSec, + ); + const shotElapsedSec = Math.max( + 0, + Math.min(shotDurationSec, targetSec - startElapsedSec), + ); + const cameraProgress = clamp01( + shotElapsedSec / Math.max(0.001, flightDurationSec), + ); + const holdElapsedSec = Math.max(0, shotElapsedSec - flightDurationSec); + const holdProgress = + holdDurationSec > 0 ? clamp01(holdElapsedSec / holdDurationSec) : 1; + const previousCamera = scene.shots[shotIndex - 1]?.camera || shot.camera; + return { + sceneProgress: totalSec > 0 ? targetSec / totalSec : 0, + sceneElapsedSec: targetSec, + sceneDurationSec: totalSec, + shotIndex, + shot, + shotElapsedSec, + shotProgress: clamp01(shotElapsedSec / shotDurationSec), + flightDurationSec, + holdDurationSec, + holdElapsedSec, + cameraProgress, + holdProgress, + camera: this._cameraAtProgress( + previousCamera, + shot.camera, + cameraProgress, + ), + }; + } + + /** Subscribe a scene-owned panel to the authoritative authored clock. */ + subscribeSceneClock(listener) { + if (typeof listener !== 'function') return () => {}; + this._sceneClockListeners.add(listener); + if (this._sceneClockSnapshot) listener({ ...this._sceneClockSnapshot }); + return () => this._sceneClockListeners.delete(listener); + } + + /** Publish one authoritative scene-clock snapshot to attached panels. */ + _publishSceneClock( + scene, + shot, + sceneElapsedSec, + { running = this._running, seeking = false } = {}, + ) { + if (!scene || !shot) return; + const timing = this._sceneTimingForShot(scene, shot); + const elapsedSec = Math.max( + 0, + Math.min(timing.totalSec, Number(sceneElapsedSec) || 0), + ); + this._sceneClockSnapshot = { + sceneId: scene.id, + shotId: shot.id, + shotIndex: timing.shotIndex, + shotCount: scene.shots.length, + sceneElapsedSec: elapsedSec, + sceneDurationSec: timing.totalSec, + sceneProgress: timing.totalSec > 0 ? elapsedSec / timing.totalSec : 0, + running: Boolean(running), + seeking: Boolean(seeking), + }; + for (const listener of this._sceneClockListeners) { + try { + listener({ ...this._sceneClockSnapshot }); + } catch (error) { + console.warn('[Scenes] Scene clock listener failed:', error); + } + } + } + + /** Wait for an interrupted run to release camera and layer ownership. */ + _waitForRunIdle() { + if (!this._running) return Promise.resolve(); + return new Promise((resolve) => this._runIdleResolvers.add(resolve)); + } + + /** Apply a new time inside the already-loaded shot without rebuilding its stack. */ + _seekLoadedShot(scene, seekState) { + if (this._destroyed) return false; + const shot = seekState?.shot; + if ( + !scene || + !shot || + this._loadedSceneId !== scene.id || + this._selectedShotId !== shot.id + ) + return false; + if (!this._claimCameraOwnership()) return false; + this._cancelActiveSceneTravel(); + this._loadAbort?.abort(); + this._loadAbort = null; + this._loadGeneration += 1; + clearInterval(this._shotProgressTimer); + this._shotProgressTimer = null; + const states = this._layerStatesForShot(scene, shot, { + cameraSettled: seekState.cameraProgress >= 1, + sceneSeek: seekState, + }); + let applied = true; + for (const [layerId, state] of Object.entries(states)) { + if (!state?.enabled || !state.params) continue; + if ( + this.dataManager.setLayerParams(layerId, state.params, { + origin: 'scene', + }) === false + ) { + applied = false; + } + } + if (!applied) return false; + this._setCameraView(seekState.camera || shot.camera); + this._setProgress(seekState.sceneProgress); + this._publishSceneClock(scene, shot, seekState.sceneElapsedSec, { + running: false, + seeking: true, + }); + this._updateStatus(`Seeked: ${scene.title} / ${shot.title}`); + return true; + } + + /** Seek the complete authored scene state to an exact clock position. */ + seekScene(sceneId, progress) { + if (this._destroyed) return Promise.resolve(false); + this.stopScene('Seeking scene clock'); + const generation = ++this._sceneSeekGeneration; + return this._trackWork(this._seekScene(sceneId, progress, generation)); + } + + async _seekScene(sceneId, progress, generation) { + const scene = this._project.scenes.find(({ id }) => id === sceneId); + if (!scene?.shots?.length) return false; + if (this._running) { + await this._waitForRunIdle(); + } + if (this._destroyed || generation !== this._sceneSeekGeneration) + return false; + const seekState = this._sceneSeekState(scene, progress); + if (!seekState) return false; + if (this._seekLoadedShot(scene, seekState)) return true; + const result = await this._loadShot(scene.id, seekState.shot.id, { + sceneSeek: seekState, + }); + return ( + !this._destroyed && + generation === this._sceneSeekGeneration && + result?.started === true + ); } /** @@ -769,7 +2004,11 @@ export class SceneDirector { return { signal, get cancelled() { - return director._loadGeneration !== generation; + return ( + director._destroyed || + signal?.aborted || + director._loadGeneration !== generation + ); }, }; } @@ -797,6 +2036,27 @@ export class SceneDirector { return true; } + /** Place the camera at an authored replay start pose without an intermediate flight. */ + _setCameraView(cameraState) { + if (!cameraState || typeof this.viewer?.camera?.setView !== 'function') + return false; + this.styleManager?.clearSearchedLocation?.(); + this.viewer.camera.cancelFlight?.(); + this.viewer.camera.setView({ + destination: Cesium.Cartesian3.fromDegrees( + cameraState.lon, + cameraState.lat, + cameraState.alt, + ), + orientation: { + heading: Cesium.Math.toRadians(cameraState.heading || 0), + pitch: Cesium.Math.toRadians(cameraState.pitch || -35), + roll: Cesium.Math.toRadians(cameraState.roll || 0), + }, + }); + return true; + } + /** * Build a flat playback queue of { scene, shot } pairs starting from the * given scene and wrapping around through all remaining scenes (round-robin). @@ -896,21 +2156,35 @@ export class SceneDirector { * @param {object} [options] * @param {boolean} [options.single=false] - Play only the named scene instead of * round-robining through the whole project (voice playback uses this). + * @param {string|null} [options.afterShotId=null] - Begin after this shot without wrapping + * @param {boolean} [options.preview=true] - Enter recording preview and release the scene on exit * @returns {Promise<{started: boolean, reason?: string, shots?: number}>} */ startScene(sceneId, options) { if (this._destroyed) return Promise.resolve({ started: false, reason: 'destroyed' }); + this._sceneSeekGeneration++; return this._trackWork(this._startScene(sceneId, options)); } - async _startScene(sceneId, { single = false } = {}) { + async _startScene( + sceneId, + { single = false, afterShotId = null, preview = true } = {}, + ) { if (this._running) return { started: false, reason: 'already-running' }; - const queue = this._buildPlaybackQueue( + let queue = this._buildPlaybackQueue( sceneId || this._selectedSceneId || this._project.scenes[0]?.id, { single }, ); + if (afterShotId !== null) { + const index = queue.findIndex( + ({ scene, shot }) => scene.id === sceneId && shot.id === afterShotId, + ); + if (index < 0) return { started: false, reason: 'shot-not-found' }; + queue = queue.slice(index + 1); + if (!queue.length) return { started: false, reason: 'scene-complete' }; + } if (!queue.length) { this._updateStatus('No shots to run'); return { started: false, reason: 'no-shots' }; @@ -928,13 +2202,17 @@ export class SceneDirector { // load cannot land a stale shot's layers on top of the run's first shot. // Aborting cancels a layer transition already in flight; bumping the // generation disowns everything the load has not yet started. + this._cancelActiveSceneTravel(); this._loadAbort?.abort(); this._loadAbort = null; this._loadGeneration++; + clearInterval(this._shotProgressTimer); + this._shotProgressTimer = null; // Transition to running state this._running = true; - this._setPlaybackActive(true); + this._previewRun = preview; + if (preview) this._setPlaybackActive(true); this._setButtons(true); this._setProgress(0); @@ -946,15 +2224,21 @@ export class SceneDirector { this._runAbort = new AbortController(); this._runToken = { cancelled: false, signal: this._runAbort.signal }; const token = this._runToken; - this.styleManager.setRecordingMode(true, { - hidePanels: true, - hudMode: 'full', - safeFrame: '16:9', - }); + if (preview) { + this.styleManager.setRecordingMode(true, { + hidePanels: true, + hudMode: 'full', + safeFrame: '16:9', + }); + } // Pre-compute total duration for the progress bar const estimatedDurationSec = queue.reduce((sum, item) => { - return sum + (item.shot.durationSec || 0) + (item.shot.holdSec || 0); + return ( + sum + + (item.shot.durationSec || 0) + + this._effectiveShotHoldSec(item.scene, item.shot) + ); }, 0); // Initialize telemetry accumulator for this run @@ -971,12 +2255,31 @@ export class SceneDirector { this._logEvent('scene_run_start', { count: queue.length }); this._setPlaybackKeyboardEnabled(true); + let activeScene = null; try { + const loadedScene = this._project.scenes.find( + (scene) => scene.id === this._loadedSceneId, + ); + if (loadedScene && loadedScene.id !== queue[0].scene.id) { + const released = await this._releaseSceneLayers(loadedScene, token); + if (!released) + throw new Error(`Could not leave scene: ${loadedScene.title}`); + this._loadedSceneId = null; + } + // Main shot sequencing loop for (let idx = 0; idx < queue.length; idx++) { if (token.cancelled) break; const { scene, shot } = queue[idx]; + if (activeScene && activeScene.id !== scene.id) { + await this._releaseSceneLayers(activeScene); + this._loadedSceneId = null; + activeScene = null; + if (token.cancelled) break; + } + activeScene = scene; + // Update UI selection to track the active shot this._selectedSceneId = scene.id; this._selectedShotId = shot.id; @@ -986,7 +2289,6 @@ export class SceneDirector { this._updateStatus( `Running ${idx + 1}/${queue.length}: ${scene.title} / ${shot.title}`, ); - this._updateRuntime(`${scene.title} · ${shot.title}`); this._logEvent('shot_start', { sceneId: scene.id, @@ -1004,21 +2306,36 @@ export class SceneDirector { // switch can hold this shot for seconds; without a re-check the layer // pass below still runs and the operator watches layers keep toggling // after they hit Stop. Cancellation is re-read after each one. - await this.styleManager.applyVisualState(shot.visual || {}, { - isCurrent: () => !token.cancelled, - }); + await this.styleManager.applyVisualState( + this._visualStateForShot(shot), + { + isCurrent: () => !token.cancelled, + }, + ); if (token.cancelled) break; - await this._applyLayerStates(shot.layers || {}, token); - if (token.cancelled) break; - await this._flyCamera( - shot.camera, - shot.durationSec || DEFAULT_SHOT_DURATION_SEC, + const flightDuration = shot.durationSec || DEFAULT_SHOT_DURATION_SEC; + await this._applyLayerStates( + this._layerStatesForShot(scene, shot), token, ); if (token.cancelled) break; - // Hold on the final frame before transitioning to the next shot - await this._sleep((shot.holdSec || 0) * 1000, token); + this._loadedSceneId = scene.id; + const sceneTiming = this._startSceneClockTicker(scene, shot, token); + const cameraTravel = this._beginShotTravel(scene, shot, flightDuration); + const flight = this._flyCamera(shot.camera, flightDuration, token); + this._publishShotTravel(scene, shot, cameraTravel); + await flight; if (token.cancelled) break; + this._settleShotLayerStates(scene, shot, token, cameraTravel); + if (token.cancelled) break; + // Hold on the final frame before transitioning to the next shot + await this._holdShot(scene, shot, token); + if (token.cancelled) break; + clearInterval(this._sceneClockTimer); + this._sceneClockTimer = null; + this._publishSceneClock(scene, shot, sceneTiming.endElapsedSec, { + running: true, + }); this._logEvent('shot_end', { sceneId: scene.id, @@ -1038,6 +2355,10 @@ export class SceneDirector { message: error.message || 'unknown error', }); } finally { + if (preview) { + await this._releaseSceneLayers(activeScene); + if (activeScene?.id === this._loadedSceneId) this._loadedSceneId = null; + } this._finishRun(); } } @@ -1080,10 +2401,31 @@ export class SceneDirector { * @param {string} [reason='Stopped'] - Human-readable cancellation reason */ stopScene(reason = 'Stopped') { + this._sceneSeekGeneration++; + this._loadAbort?.abort(); + this._loadAbort = null; + this._loadGeneration++; + clearInterval(this._shotProgressTimer); + this._shotProgressTimer = null; + this._cancelActiveSceneTravel(); + this.viewer.camera.cancelFlight(); + if (this._sceneClockSnapshot) { + this._sceneClockSnapshot = { + ...this._sceneClockSnapshot, + running: false, + stopped: true, + }; + for (const listener of this._sceneClockListeners) { + try { + listener({ ...this._sceneClockSnapshot }); + } catch { + /* Observer cannot block cancellation. */ + } + } + } if (!this._running || !this._runToken) return; this._runToken.cancelled = true; this._runAbort?.abort(); - this.viewer.camera.cancelFlight(); this._updateStatus(reason); this._logEvent('scene_stopped', { reason }); } @@ -1194,11 +2536,20 @@ export class SceneDirector { targetStates, registered, )) { - const settled = await this.dataManager.setEnabled( - id, - enabled, - signal ? { signal } : undefined, - ); + let settled; + if (params && typeof this.dataManager.restoreLayerState === 'function') { + const outcome = await this.dataManager.restoreLayerState( + id, + { enabled, params }, + { ...(signal ? { signal } : {}), origin: 'scene' }, + ); + settled = outcome?.succeeded === true; + } else { + settled = await this.dataManager.setEnabled(id, enabled, { + ...(signal ? { signal } : {}), + origin: 'scene', + }); + } if (token?.cancelled) return abort(); if (settled === false) { refused.push(id); @@ -1207,10 +2558,10 @@ export class SceneDirector { ); continue; } - applied.push(id); - if (params) { - this.dataManager.setLayerParams(id, params); + if (params && typeof this.dataManager.restoreLayerState !== 'function') { + this.dataManager.setLayerParams(id, params, { origin: 'scene' }); } + applied.push(id); } if (refused.length) { @@ -1220,6 +2571,54 @@ export class SceneDirector { return { applied, refused, cancelled: false }; } + /** + * Release only the layers a scene explicitly owns beyond its final shot. + * Normal recipes remain sparse and leave undeclared operator layers alone. + * Event lenses opt into this hook so their panels, imagery, and map-stack + * ownership cannot leak into the next recipe or survive a stopped run. + * + * @param {Object|null} scene - Scene carrying optional releaseLayerIds + * @param {{ cancelled?: boolean, signal?: AbortSignal }} [token] - Optional LOAD/run token + * @returns {Promise} True only when every owned layer is released + */ + async _releaseSceneLayers(scene, token = null) { + const layerIds = Array.isArray(scene?.releaseLayerIds) + ? scene.releaseLayerIds + : []; + let released = true; + for (const layerId of layerIds) { + if (token?.cancelled) return false; + try { + const settled = await this.dataManager.setEnabled(layerId, false, { + origin: 'scene', + signal: token?.signal, + }); + if (settled === false) { + released = false; + console.warn( + `[Scenes] Scene-owned layer refused release: ${layerId}`, + ); + this._logEvent('scene_layer_release_refused', { + sceneId: scene.id, + layerId, + }); + } + } catch (error) { + released = false; + console.warn( + `[Scenes] Scene-owned layer release failed: ${layerId}`, + error, + ); + this._logEvent('scene_layer_release_error', { + sceneId: scene.id, + layerId, + message: error?.message || 'unknown error', + }); + } + } + return released && !token?.cancelled; + } + /** * Leave a Context mode that isolates the globe, before a shot's layers land. * @@ -1322,12 +2721,47 @@ export class SceneDirector { }); } - /** - * Cancellable sleep that polls the token every ~70ms, allowing prompt - * interruption without waiting for the full duration. - * @param {number} ms - Total sleep time in milliseconds - * @param {{ cancelled: boolean }} token - Cancellation token - */ + /** Let an opt-in media owner finish playback and its fade before the next flight. */ + async _holdShot(scene, shot, token) { + const readers = Object.entries( + this._layerStatesForShot(scene, shot), + ).flatMap(([id, state]) => { + const module = this.dataManager?.layers?.get(id)?.module; + if (!state.enabled || typeof module?.getSceneShotMediaHold !== 'function') + return []; + const read = () => module.getSceneShotMediaHold(state.params?.beatId); + const initial = read(); + return initial + ? [ + { + read, + maxWaitMs: Math.min( + 20000, + Math.max(0, Number(initial.maxWaitMs) || 0), + ), + }, + ] + : []; + }); + if (!readers.length) { + await this._sleep(this._effectiveShotHoldSec(scene, shot) * 1000, token); + return; + } + const began = Date.now(); + while (!token.cancelled && !token.signal?.aborted) { + const pending = readers.filter(({ read }) => read()?.pending === true); + if (!pending.length) return; + if (pending.some(({ maxWaitMs }) => Date.now() - began >= maxWaitMs)) { + this.stopScene('Scene media timed out'); + throw new Error( + 'Scene media did not finish within its bounded playback window', + ); + } + await this._sleep(70, token); + } + } + + /** Cancellable sleep, polling every ~70 ms for prompt interruption. */ async _sleep(ms, token) { if (ms <= 0 || token.cancelled) return; const endAt = Date.now() + ms; @@ -1353,22 +2787,114 @@ export class SceneDirector { }, 100); } + /** Track one LOAD/replay flight and hold without pretending a full run is active. */ + _startShotProgress(token, seconds, from, to, sceneClock = null) { + clearInterval(this._shotProgressTimer); + this._shotProgressTimer = null; + if (token.cancelled || this._running) return; + this._setProgress(from); + if (sceneClock) { + this._publishSceneClock( + sceneClock.scene, + sceneClock.shot, + sceneClock.sceneElapsedFrom, + { running: false }, + ); + } + if (seconds <= 0) { + this._setProgress(to); + if (sceneClock) { + this._publishSceneClock( + sceneClock.scene, + sceneClock.shot, + sceneClock.sceneElapsedTo, + { running: false }, + ); + } + return; + } + const startedAt = Date.now(); + const timer = setInterval(() => { + if (token.cancelled || this._running) { + clearInterval(timer); + if (this._shotProgressTimer === timer) this._shotProgressTimer = null; + return; + } + const fraction = clamp01((Date.now() - startedAt) / (seconds * 1000)); + this._setProgress(from + (to - from) * fraction); + if (sceneClock) { + this._publishSceneClock( + sceneClock.scene, + sceneClock.shot, + sceneClock.sceneElapsedFrom + + (sceneClock.sceneElapsedTo - sceneClock.sceneElapsedFrom) * + fraction, + { running: false }, + ); + } + if (fraction >= 1) { + clearInterval(timer); + if (this._shotProgressTimer === timer) this._shotProgressTimer = null; + } + }, 100); + timer.unref?.(); + this._shotProgressTimer = timer; + } + + /** Drive the public authored clock through one running shot. */ + _startSceneClockTicker(scene, shot, token) { + clearInterval(this._sceneClockTimer); + const timing = this._sceneTimingForShot(scene, shot); + const startedAt = Date.now(); + this._publishSceneClock(scene, shot, timing.startElapsedSec, { + running: true, + }); + const timer = setInterval(() => { + if (token.cancelled || !this._running) { + clearInterval(timer); + if (this._sceneClockTimer === timer) this._sceneClockTimer = null; + return; + } + const shotElapsedSec = Math.min( + timing.durationSec, + (Date.now() - startedAt) / 1000, + ); + this._publishSceneClock( + scene, + shot, + timing.startElapsedSec + shotElapsedSec, + { + running: true, + }, + ); + }, 50); + timer.unref?.(); + this._sceneClockTimer = timer; + return timing; + } + /** * Clean up after a scene run (whether completed, errored, or cancelled). - * Stops the progress ticker, exits recording mode, finalizes telemetry, + * Stops the progress ticker, exits recording mode only when owned, finalizes telemetry, * and resets UI buttons to the idle state. */ _finishRun() { clearInterval(this._progressTimer); this._progressTimer = null; + clearInterval(this._sceneClockTimer); + this._sceneClockTimer = null; this._setPlaybackKeyboardEnabled(false); // Covers the error path too: a run that threw mid-shot must not leave a // layer transition running against a director that has stopped watching. this._runAbort?.abort(); this._runAbort = null; + this._cancelActiveSceneTravel(); - this.styleManager.setRecordingMode(false); - this._setPlaybackActive(false); + if (this._previewRun) { + this.styleManager.setRecordingMode(false); + this._setPlaybackActive(false); + } + this._previewRun = false; this._updateRuntime(''); this._running = false; @@ -1383,6 +2909,27 @@ export class SceneDirector { this._runToken = null; this._setButtons(false); + if (this._sceneClockSnapshot) { + const scene = this._project.scenes.find( + ({ id }) => id === this._sceneClockSnapshot.sceneId, + ); + const shot = scene?.shots?.find( + ({ id }) => id === this._sceneClockSnapshot.shotId, + ); + if (scene && shot) { + this._publishSceneClock( + scene, + shot, + this._sceneClockSnapshot.sceneElapsedSec, + { + running: false, + seeking: this._sceneClockSnapshot.seeking, + }, + ); + } + } + for (const resolve of this._runIdleResolvers) resolve(); + this._runIdleResolvers.clear(); } /** diff --git a/src/scenes/director.test.mjs b/src/scenes/director.test.mjs index b6fc19454..def1b4969 100644 --- a/src/scenes/director.test.mjs +++ b/src/scenes/director.test.mjs @@ -17,7 +17,550 @@ import test from 'node:test'; import { SceneDirector } from './director.js'; import { SCENE_TRACKING_PARAM_KEYS } from './scenePolicy.js'; -import { SCENE_RECIPES } from './recipes.js'; +import { SCENE_RECIPES, getSceneAppendRecipeById } from './recipes.js'; + +const NEPAL_ORIGINAL_SHOT_TITLES = [ + 'Global Incident Context', + 'Nepal-Focused Globe Rotation', + 'Bhote Koshi Regional Approach', + 'Bhote Koshi Nearby Cities', + 'Bhote Koshi Incident Corridor', + 'Bhote Koshi Flood Path', + 'Bhote Koshi Corridor Overview', + 'Bhote Koshi Upper Valley', +]; + +function nepalProjectFixture() { + const project = structuredClone(PROJECT_FIXTURE); + project.scenes[0].title = 'Nepal Flood Incident'; + project.scenes[0].shots = NEPAL_ORIGINAL_SHOT_TITLES.map((title, index) => ({ + ...structuredClone(PROJECT_FIXTURE.scenes[0].shots[index % 2]), + id: `nepal-original-${index + 1}`, + title, + camera: { + lat: 27 + index / 10, + lon: 85 + index / 10, + alt: 1000 + index * 100, + heading: index * 10, + pitch: -35 - index, + roll: 0, + }, + visual: { + ...structuredClone(PROJECT_FIXTURE.scenes[0].shots[index % 2].visual), + mapStack: 'photoreal', + }, + layers: { + ...structuredClone(PROJECT_FIXTURE.scenes[0].shots[index % 2].layers), + 'bhote-koshi-locator': { + enabled: index > 0, + params: { presentation: `fixture-${index + 1}` }, + }, + }, + })); + project.scenes[0].shots[7].layers['bhote-koshi-locator'] = { + enabled: true, + params: { presentation: 'bhote-koshi-trigger-record' }, + }; + project.scenes[0].shots[7].holdSec = 8; + return project; +} + +function legacyThreeShotNepalProjectFixture() { + const project = structuredClone(PROJECT_FIXTURE); + project.scenes[0].title = 'Nepal Flood Incident'; + project.scenes[0].shots = [0, 1, 2].map((index) => ({ + ...structuredClone(PROJECT_FIXTURE.scenes[0].shots[index % 2]), + id: `legacy-nepal-${index + 1}`, + title: `Shot ${index + 1}`, + camera: { + lat: 20 + index, + lon: 80 + index, + alt: 1000000 + index * 100000, + heading: index * 15, + pitch: -50 - index, + roll: 0, + }, + })); + return project; +} + +function legacyDefaultProjectWithoutNepalFixture() { + const project = structuredClone(PROJECT_FIXTURE); + project.scenes[0].id = 'bhote-koshi-flood'; + project.scenes[0].title = 'Bhote Koshi Flood Reconstruction'; + project.scenes[0].shots = [{ + ...structuredClone(PROJECT_FIXTURE.scenes[0].shots[0]), + id: 'standalone-shot', + title: 'Shot 1', + }]; + return project; +} + + +test('Mailung clip trim estimates seven seconds and its exit, including older saved holds', () => { + const { director, restore } = makeDirector(); + try { + const scene = director._project.scenes[0]; + const shot = scene.shots[0]; + shot.sourcePackId = 'bhote-koshi-nepal-evidence-pack'; + shot.durationSec = 4.2; + shot.holdSec = 15; + shot.layers = { 'bhote-koshi-2026': { enabled: true, params: { + presentation: 'scene-beat', beatId: 'mailung-bazzar', + } } }; + assert.equal(director._effectiveShotHoldSec(scene, shot), 7.65); + assert.equal(director._shotRuntimeDurationSec(scene, shot), 4.2 + 7.65); + const state = director._layerStatesForShot(scene, shot)['bhote-koshi-2026']; + assert.equal(state.params.sceneContext.holdSec, 7.65); + assert.equal(state.params.sceneControls.mediaPlaybackHoldSec, 7); + assert.equal(state.params.sceneControls.minimumHoldSec, 7.65); + assert.equal(shot.holdSec, 15, 'runtime correction does not rewrite saved shots'); + shot.layers['bhote-koshi-2026'].params.beatId = 'dandagaun'; + assert.equal(director._effectiveShotHoldSec(scene, shot), 15, 'other shots keep their authored hold'); + } finally { restore(); } +}); + +test('Incident Corridor gives all overview pins time to reveal without rewriting saved shots', () => { + const { director, restore } = makeDirector(); + try { + const scene = director._project.scenes[0]; + const shot = scene.shots[0]; + shot.holdSec = 0.9; + shot.layers = { 'bhote-koshi-locator': { + enabled: true, params: { presentation: 'bhote-koshi-incident-places' }, + } }; + assert.equal(director._effectiveShotHoldSec(scene, shot), 11); + assert.equal(shot.holdSec, 0.9); + shot.holdSec = 15; + assert.equal(director._effectiveShotHoldSec(scene, shot), 15); + shot.holdSec = 0.9; + shot.layers['bhote-koshi-locator'].params.presentation = 'bhote-koshi-flood-path'; + assert.equal(director._effectiveShotHoldSec(scene, shot), 0.9); + shot.layers['bhote-koshi-locator'].params.presentation = 'bhote-koshi-incident-places'; + shot.layers['bhote-koshi-locator'].enabled = false; + assert.equal(director._effectiveShotHoldSec(scene, shot), 0.9); + } finally { + restore(); + } +}); + + +test('scene clock seek resolves the exact shot phase and camera in both directions', async () => { + const { director, restore } = makeDirector(); + const loads = []; + director._loadShot = async (sceneId, shotId, options) => { + loads.push({ sceneId, shotId, options }); + return { started: true, shotId }; + }; + try { + assert.equal(await director.seekScene('scene-1', 0), true); + assert.equal(await director.seekScene('scene-1', 0.99), true); + assert.equal(await director.seekScene('scene-1', 0.25), true); + assert.equal(await director.seekScene('missing', 0.5), false); + assert.deepEqual(loads.map(({ sceneId, shotId }) => ({ sceneId, shotId })), [ + { sceneId: 'scene-1', shotId: 'shot-a' }, + { sceneId: 'scene-1', shotId: 'shot-b' }, + { sceneId: 'scene-1', shotId: 'shot-a' }, + ]); + assert.equal(loads[0].options.sceneSeek.sceneProgress, 0); + assert.equal(loads[0].options.sceneSeek.cameraProgress, 0); + assert.ok(loads[1].options.sceneSeek.cameraProgress > 0.9); + assert.equal(loads[1].options.sceneSeek.shotIndex, 1); + assert.equal(loads[2].options.sceneSeek.sceneProgress, 0.25); + assert.equal(loads[2].options.sceneSeek.cameraProgress, 0.5); + assert.equal(loads[2].options.sceneSeek.camera.lat, 10); + assert.equal(loads[2].options.sceneSeek.camera.lon, 20); + } finally { + restore(); + } +}); + +test('scene clock subscribers receive authoritative forward playback snapshots', () => { + const { director, restore } = makeDirector(); + const snapshots = []; + const unsubscribe = director.subscribeSceneClock((snapshot) => snapshots.push(snapshot)); + try { + const scene = director._project.scenes[0]; + director._publishSceneClock(scene, scene.shots[0], 0.1, { running: true }); + director._publishSceneClock(scene, scene.shots[1], 0.3, { running: true }); + unsubscribe(); + director._publishSceneClock(scene, scene.shots[1], 0.4, { running: false }); + assert.deepEqual(snapshots.map(({ shotId, sceneElapsedSec, running }) => ({ + shotId, sceneElapsedSec, running, + })), [ + { shotId: 'shot-a', sceneElapsedSec: 0.1, running: true }, + { shotId: 'shot-b', sceneElapsedSec: 0.3, running: true }, + ]); + } finally { + restore(); + } +}); + + +test('the Nepal evidence pack appends once and applies the approved corridor framing', () => { + const project = nepalProjectFixture(); + const originalShots = structuredClone(project.scenes[0].shots); + const { director, restore } = makeDirector({ project }); + try { + const scene = director._project.scenes[0]; + const originalShotIds = scene.shots.map(({ id }) => id); + const first = director.appendShotPack('scene-1', 'bhote-koshi-nepal-evidence-pack'); + assert.deepEqual(first, { + appended: true, + updated: false, + shotCount: 17, + patchedShotCount: 14, + firstShotId: scene.shots[8].id, + }); + assert.deepEqual(scene.shots.slice(0, 8).map(({ id }) => id), originalShotIds); + assert.equal(scene.shots.length, 25); + assert.ok(scene.releaseLayerIds.includes('bhote-koshi-2026')); + assert.ok(scene.releaseLayerIds.includes('bhote-koshi-locator')); + assert.equal(scene.appliedShotPacks[0].id, 'bhote-koshi-nepal-evidence-pack'); + assert.equal(scene.appliedShotPacks[0].version, 18); + assert.deepEqual( + Object.keys(scene.appliedShotPacks[0].shotBindings), + [...NEPAL_ORIGINAL_SHOT_TITLES, ...scene.shots.slice(8).map(({ title }) => title)], + ); + assert.deepEqual( + scene.shots.map((shot) => shot.visual.mapStack), + [ + ...Array(25).fill('photoreal'), + ], + ); + assert.deepEqual( + scene.shots.map((shot) => shot.layers['bhote-koshi-2026'].params.beatId), + [ + 'immediate-collapse-viewpoint', + 'debris-dammed-lake', + 'gyirong-border-gate', + 'timure-cluster', + 'syabru-besi', + 'bidur-trishuli-bridge', + 'gyirong-border-gate', + 'immediate-collapse-viewpoint', + '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', 'final-view', + ], + ); + assert.deepEqual( + scene.shots[8].layers['bhote-koshi-2026'].params.sceneControls, + { evidenceMediaAutoplay: true }, + ); + assert.deepEqual( + scene.shots[9].layers['bhote-koshi-2026'].params.sceneControls, + { evidenceMediaAutoplay: true }, + ); + assert.deepEqual( + scene.shots[11].layers['bhote-koshi-2026'].params.sceneControls, + { + imageryComparison: true, + evidenceSequence: true, + evidenceSequenceDurationSec: 2.5, + deferEvidenceUntilCameraSettled: true, + evidenceRevealDurationSec: 2.5, + }, + ); + assert.deepEqual( + scene.shots.slice(12, 14).map( + (shot) => shot.layers['bhote-koshi-2026'].params.sceneControls, + ), + [ + { + imageryComparison: true, + evidenceSequence: true, + evidenceSequenceDurationSec: 4, + }, + { + imageryComparison: true, + evidenceSequence: true, + evidenceSequenceDurationSec: 4, + }, + ], + ); + assert.deepEqual( + scene.shots[21].layers['bhote-koshi-2026'].params.sceneControls, + { + evidenceSequence: true, + evidenceSequenceDurationSec: 4, + }, + ); + for (const shot of scene.shots) { + assert.equal(shot.layers['bhote-koshi-2026'].enabled, true); + assert.equal(shot.layers['bhote-koshi-2026'].params.presentation, 'scene-beat'); + } + assert.deepEqual( + scene.shots.slice(0, 8).filter((_, index) => index !== 4).map(({ camera }) => camera), + originalShots.filter((_, index) => index !== 4).map(({ camera }) => camera), + ); + assert.deepEqual(scene.shots[4].camera, { + lat: 28.0529, + lon: 85.2189, + alt: 126931, + heading: 0, + pitch: -90, + roll: 0, + }); + assert.equal(scene.shots[4].holdSec, 11); + assert.deepEqual( + scene.shots.slice(0, 8).map((shot) => shot.layers['bhote-koshi-locator']), + originalShots.map((shot) => shot.layers['bhote-koshi-locator']), + ); + assert.equal(new Set(scene.shots.map(({ id }) => id)).size, 25); + assert.deepEqual( + director.appendShotPack('scene-1', 'bhote-koshi-nepal-evidence-pack'), + { appended: false, reason: 'already-appended' }, + ); + assert.equal(scene.shots.length, 25); + } finally { + restore(); + } +}); + +test('installed v12 Nepal pack inserts ten points without replacing renamed cameras', () => { + const first = makeDirector({ project: nepalProjectFixture() }); + let legacy; + const recipe = getSceneAppendRecipeById('bhote-koshi-nepal-evidence-pack'); + try { + first.director.appendShotPack('scene-1', recipe.id); + legacy = structuredClone(first.director._project); + const scene = legacy.scenes[0]; + scene.shots = scene.shots.filter((shot) => !shot.sourcePackId + || recipe.previousRequiredSourcePackBeatIds.includes(shot.layers['bhote-koshi-2026'].params.beatId)); + scene.appliedShotPacks[0].version = 12; + const ids = new Set(scene.shots.map(({ id }) => id)); + scene.appliedShotPacks[0].shotBindings = Object.fromEntries( + Object.entries(scene.appliedShotPacks[0].shotBindings).filter(([, id]) => ids.has(id))); + scene.shots[8].title = 'My collapse camera'; + scene.shots[8].camera.heading = 149; + } finally { first.restore(); } + const originalShots = structuredClone(legacy.scenes[0].shots); + const upgraded = makeDirector({ project: legacy }); + try { + const scene = upgraded.director._project.scenes[0]; + assert.equal(scene.shots.length, 25); + for (const old of originalShots) { + const current = scene.shots.find(({ id }) => id === old.id); + assert.ok(current); + assert.deepEqual(current.camera, old.camera); + assert.equal(current.title, old.title); + } + assert.deepEqual(scene.shots.filter((shot) => shot.sourcePackId).map((shot) => + shot.layers['bhote-koshi-2026'].params.beatId), recipe.requiredSourcePackBeatIds); + assert.equal(scene.appliedShotPacks[0].version, 18); + assert.equal(upgraded.director.appendShotPack(scene.id, recipe.id).reason, 'already-appended'); + } finally { upgraded.restore(); } +}); + + +test('a legacy three-shot Nepal browser project bootstraps to the current 25-shot sequence', () => { + const project = legacyThreeShotNepalProjectFixture(); + const originalIds = project.scenes[0].shots.map(({ id }) => id); + const originalCameras = project.scenes[0].shots.map(({ camera }) => structuredClone(camera)); + const { director, restore } = makeDirector({ project }); + try { + const scene = director._project.scenes[0]; + assert.equal(scene.shots.length, 25); + assert.deepEqual(scene.shots.map(({ title }) => title), + getSceneAppendRecipeById('bhote-koshi-nepal-evidence-pack').requiredShotTitles); + assert.deepEqual(scene.shots.slice(0, 3).map(({ id }) => id), originalIds); + assert.deepEqual(scene.shots.slice(0, 3).map(({ camera }) => camera), originalCameras); + assert.equal(scene.appliedShotPacks[0].id, 'bhote-koshi-nepal-evidence-pack'); + assert.equal(scene.appliedShotPacks[0].version, 18); + assert.equal(director._selectedSceneId, scene.id); + assert.equal(director._selectedShotId, scene.shots[0].id); + } finally { + restore(); + } +}); + +test('the Nepal evidence pack refuses a partial inventory without mutating the scene', () => { + const project = nepalProjectFixture(); + project.scenes[0].shots[3].title = 'Renamed Nearby Cities'; + const { director, restore } = makeDirector({ project }); + try { + const sceneBefore = structuredClone(director._project.scenes[0]); + assert.deepEqual( + director.appendShotPack('scene-1', 'bhote-koshi-nepal-evidence-pack'), + { + appended: false, + updated: false, + reason: 'shot-inventory-mismatch', + }, + ); + assert.deepEqual(director._project.scenes[0], sceneBefore); + } finally { + restore(); + } +}); + +test('the Nepal pack upgrades the upper-valley shots without duplicating evidence beats', () => { + const initial = makeDirector({ project: nepalProjectFixture() }); + initial.director.appendShotPack('scene-1', 'bhote-koshi-nepal-evidence-pack'); + const project = structuredClone(initial.director._project); + initial.restore(); + project.scenes[0].appliedShotPacks = [{ + id: 'bhote-koshi-nepal-evidence-pack', + version: 4, + }]; + for (const shot of project.scenes[0].shots.slice(0, 8)) { + delete shot.layers['bhote-koshi-2026']; + } + const originalLength = project.scenes[0].shots.length; + const originalIds = project.scenes[0].shots.map(({ id }) => id); + const originalCameras = project.scenes[0].shots.map(({ camera }) => structuredClone(camera)); + const originalLocators = project.scenes[0].shots.map( + (shot) => structuredClone(shot.layers['bhote-koshi-locator']), + ); + const { director, restore } = makeDirector({ project }); + try { + const scene = director._project.scenes[0]; + assert.deepEqual( + director.appendShotPack('scene-1', 'bhote-koshi-nepal-evidence-pack'), + { appended: false, reason: 'already-appended' }, + ); + assert.equal(scene.shots.length, originalLength); + assert.deepEqual(scene.shots.map(({ id }) => id), originalIds); + assert.deepEqual(scene.shots.map(({ camera }) => camera), originalCameras); + assert.deepEqual( + scene.shots.map((shot) => shot.layers['bhote-koshi-locator']), + originalLocators, + ); + assert.deepEqual(scene.shots[8].camera, { + lat: 28.417, + lon: 85.3937, + alt: 9850, + heading: 146, + pitch: -34, + roll: 0, + }); + assert.equal(scene.appliedShotPacks[0].version, 18); + assert.equal(Object.keys(scene.appliedShotPacks[0].shotBindings).length, 25); + assert.ok(scene.shots.every((shot) => ( + shot.layers['bhote-koshi-2026']?.enabled === true + && shot.layers['bhote-koshi-2026']?.params?.presentation === 'scene-beat' + ))); + const afterUpgrade = structuredClone(scene); + assert.deepEqual( + director.appendShotPack('scene-1', 'bhote-koshi-nepal-evidence-pack'), + { appended: false, reason: 'already-appended' }, + ); + assert.deepEqual(scene, afterUpgrade); + } finally { + restore(); + } +}); + + +test('an older default project gains the complete selectable Nepal scene once', () => { + const project = legacyDefaultProjectWithoutNepalFixture(); + const { director, restore } = makeDirector({ + project, + data: { registered: [...REGISTERED, 'bhote-koshi-2026', 'bhote-koshi-locator'] }, + }); + try { + const nepal = director._project.scenes.find(({ title }) => title === 'Nepal Flood Incident'); + assert.ok(nepal); + assert.equal(nepal.shots.length, 25); + assert.equal(nepal.shots[0].title, 'Global Incident Context'); + assert.equal(nepal.shots.at(-1).title, 'Final view'); + assert.deepEqual(director._project.installedBuiltInSceneIds, ['bhote-koshi-nepal-scene']); + } finally { + restore(); + } +}); + +test('a previously installed Nepal scene stays deleted when its marker remains', () => { + const project = legacyDefaultProjectWithoutNepalFixture(); + project.installedBuiltInSceneIds = ['bhote-koshi-nepal-scene']; + const { director, restore } = makeDirector({ project }); + try { + assert.equal( + director._project.scenes.some(({ title }) => title === 'Nepal Flood Incident'), + false, + ); + } finally { + restore(); + } +}); + +test('public defaults include Nepal without an extra standalone flood recipe', () => { + assert.equal(SCENE_RECIPES.some((item) => item.id === 'bhote-koshi-flood'), false); + assert.equal(SCENE_RECIPES.filter((item) => item.id === 'bhote-koshi-nepal-scene').length, 1); +}); + + +test('an existing public default project gains Nepal without replacing authored shots', () => { + const project = structuredClone(PROJECT_FIXTURE); + project.scenes[0].id = 'flights-radar'; + const original = structuredClone(project.scenes[0].shots); + const { director, restore } = makeDirector({ project }); + try { + assert.deepEqual(director._project.scenes[0].shots.map(({ id, camera }) => ({ id, camera })), + original.map(({ id, camera }) => ({ id, camera }))); + assert.equal(director._project.scenes[1].title, 'Nepal Flood Incident'); + assert.equal(director._project.scenes[1].shots.length, 25); + assert.deepEqual(director._project.installedBuiltInSceneIds, ['bhote-koshi-nepal-scene']); + } finally { restore(); } +}); + +test('Nepal comparison shots load Esri beneath Vantor even from a saved OSM or photoreal shot', async () => { + const { director, styleManager, restore } = makeDirector({ + project: nepalProjectFixture(), + data: { registered: [...REGISTERED, 'bhote-koshi-2026', 'bhote-koshi-locator'] }, + }); + try { + director.appendShotPack('scene-1', 'bhote-koshi-nepal-evidence-pack'); + const scene = director._project.scenes[0]; + const comparisonShots = scene.shots.filter((shot) => + director._layerStatesForShot(scene, shot)['bhote-koshi-2026']?.params?.sceneControls?.imageryComparison); + assert.ok(comparisonShots.length >= 2, 'the real comparison beats must be exercised'); + for (const shot of comparisonShots) { + for (const prior of ['osm', 'photoreal']) { + shot.visual.mapStack = prior; + await director.loadShot(scene.id, shot.id, { flyDuration: 0.2 }); + assert.equal(styleManager.visualStates.at(-1).mapStack, 'esri-imagery', shot.title); + } + } + } finally { + await director.destroy(); + restore(); + } +}); + +test('all saved Nepal shots choose a usable map in keyed and keyless runtimes without rewriting the project', async () => { + let photorealAvailable = false; + const { director, styleManager, restore } = makeDirector({ + project: nepalProjectFixture(), + isMapStackAvailable: (id) => id === 'photoreal' && photorealAvailable, + data: { registered: [...REGISTERED, 'bhote-koshi-2026', 'bhote-koshi-locator'] }, + }); + try { + director.appendShotPack('scene-1', 'bhote-koshi-nepal-evidence-pack'); + const scene = director._project.scenes[0]; + const savedShots = structuredClone(scene.shots); + for (const available of [false, true, false]) { + photorealAvailable = available; + for (const shot of scene.shots) { + const comparison = director._layerStatesForShot(scene, shot) + ['bhote-koshi-2026']?.params?.sceneControls?.imageryComparison; + const visual = director._visualStateForShot(shot); + assert.equal(visual.mapStack, available && !comparison ? 'photoreal' : 'esri-imagery', shot.title); + } + await director.loadShot(scene.id, scene.shots[0].id, { flyDuration: 0.2 }); + assert.equal(styleManager.visualStates.at(-1).mapStack, available ? 'photoreal' : 'esri-imagery'); + } + assert.deepEqual(scene.shots, savedShots); + const unrelated = { visual: { mapStack: 'photoreal' }, layers: { flights: { enabled: true } } }; + assert.equal(director._visualStateForShot(unrelated), unrelated.visual, 'other scenes keep their provider policy'); + } finally { + await director.destroy(); + restore(); + } +}); /** The layer registry as main.js builds it (src/main.js dataManager.register calls). */ const REGISTERED = [ @@ -98,6 +641,10 @@ function fakeDataManager({ registered = REGISTERED, refuse = () => false } = {}) const committed = []; return { setEnabledCalls, + subscribeVisibilityRequests(listener) { + this.visibilityListener = listener; + return () => { this.visibilityListener = null; }; + }, setParamsCalls, committed, getAll: () => registered.map((id) => ({ id, enabled: !!enabled.get(id) })), @@ -174,13 +721,67 @@ function makeDirector(options = {}) { const viewer = fakeViewer(); const styleManager = fakeStyleManager(options.style); const dataManager = fakeDataManager(options.data); - const director = new SceneDirector(viewer, styleManager, dataManager); + const director = new SceneDirector(viewer, styleManager, dataManager, { + isMapStackAvailable: options.isMapStackAvailable, + }); // Telemetry is only accumulated during a run; observable-failure assertions // need the accumulator without driving a whole run. director._activeRun = { events: [] }; return { director, viewer, styleManager, dataManager, restore }; } +test('scene camera waits for provider completion and fade rather than the saved media hold', async () => { + const { director, viewer, dataManager, restore } = makeDirector(); + let release; + let pending = true; + const gate = new Promise((resolve) => { release = resolve; }); + try { + const scene = director._project.scenes[0]; + scene.shots[0].layers.flights.params = { beatId: 'trimmed' }; + dataManager.layers = new Map([['flights', { module: { + getSceneShotMediaHold: (id) => id === 'trimmed' ? { pending, maxWaitMs: 20000 } : null, + } }]]); + const waits = []; + director._sleep = async (ms) => { waits.push(ms); if (ms === 70) await gate; }; + const run = director.startScene('scene-1', { single: true, preview: false }); + await settle(40); + assert.equal(viewer.flights.length, 1, 'no next flight while source is starting, playing or fading'); + assert.deepEqual(waits, [70]); + pending = false; release(); + await run; + assert.equal(viewer.flights.length, 2, 'the next shot starts immediately after owned media completion'); + } finally { release(); await director.destroy(); restore(); } +}); + +test('Stop cancels a provider-owned shot hold and late completion cannot fly the next camera', async () => { + const { director, viewer, dataManager, restore } = makeDirector(); + let release; + const gate = new Promise((resolve) => { release = resolve; }); + try { + dataManager.layers = new Map([['flights', { module: { + getSceneShotMediaHold: () => ({ pending: true, maxWaitMs: 20000 }), + } }]]); + director._sleep = () => gate; + const run = director.startScene('scene-1', { single: true, preview: false }); + await settle(40); + assert.equal(viewer.flights.length, 1); + director.stopScene('test stop'); release(); await run; + assert.equal(viewer.flights.length, 1); + assert.equal(director.running, false); + } finally { release(); await director.destroy(); restore(); } +}); + +test('a provider-owned hold fails boundedly if its owner never settles', async () => { + const { director, restore } = makeDirector(); + try { + director.dataManager.layers = new Map([['flights', { module: { + getSceneShotMediaHold: () => ({ pending: true, maxWaitMs: 0 }), + } }]]); + const scene = director._project.scenes[0]; + await assert.rejects(director._holdShot(scene, scene.shots[0], { cancelled: false }), /bounded playback window/); + } finally { await director.destroy(); restore(); } +}); + /** The layer map a shipped recipe declares, in normalized form. */ function recipeLayers(recipeId) { const recipe = SCENE_RECIPES.find((item) => item.id === recipeId); @@ -189,6 +790,104 @@ function recipeLayers(recipeId) { ); } +test('destroyed directors refuse seek, replay, adjacent and continuation without writes', async () => { + const { director, viewer, dataManager, restore } = makeDirector(); + try { + await director.destroy(); + director._loadedSceneId = 'scene-1'; + assert.equal(await director.seekScene('scene-1', 0), false); + assert.equal((await director.replayShot('scene-1', 'shot-a')).started, false); + assert.equal(await director.loadAdjacentShot('scene-1', 'shot-a', 1), false); + assert.equal((await director.continueScene('scene-1', 'shot-a')).started, false); + assert.equal(viewer.flights.length, 0); + assert.equal(dataManager.setParamsCalls.length, 0); + } finally { restore(); } +}); + +test('replay, adjacent and seek report layer refusal rather than success', async () => { + const { director, restore } = makeDirector({ data: { refuse: () => true } }); + try { + assert.equal((await director.replayShot('scene-1', 'shot-a')).started, false); + assert.equal(await director.loadAdjacentShot('scene-1', 'shot-a', 1), false); + assert.equal(await director.seekScene('scene-1', 0), false); + } finally { await director.destroy(); restore(); } +}); + +test('a seek waiting for run teardown is revoked by newer Stop, Load, Start or destroy', async () => { + for (const action of ['stop', 'load', 'start', 'destroy']) { + const { director, restore } = makeDirector(); + try { + let release; + director._waitForRunIdle = () => new Promise((resolve) => { release = resolve; }); + director._running = true; + director._runToken = { cancelled: false }; + const seek = director.seekScene('scene-1', 0); + assert.equal(director._pendingWork.has(seek), true); + let newer; + if (action === 'stop') director.stopScene(); + if (action === 'load') newer = director.loadShot('scene-1', 'shot-b'); + if (action === 'start') newer = director.startScene('scene-1'); + if (action === 'destroy') newer = director.destroy(); + director._running = false; + release(); + assert.equal(await seek, false, action); + await newer; + } finally { await director.destroy(); restore(); } + } +}); + +test('explicit scene-layer OFF revokes continuation during enable, flight and hold; internal OFF does not', async () => { + for (const phase of ['enable', 'flight', 'hold']) { + const { director, dataManager, restore } = makeDirector(); + try { + director._project.scenes[0].releaseLayerIds = ['flights']; + let release; + const gate = new Promise((resolve) => { release = resolve; }); + if (phase === 'enable') { + const enable = dataManager.setEnabled.bind(dataManager); + dataManager.setEnabled = async (...args) => { await gate; return enable(...args); }; + } else if (phase === 'flight') director._flyCamera = () => gate; + else director._sleep = () => gate; + director._project.scenes[0].shots[0].holdSec = 1; + const run = director.startScene('scene-1', { single: true, preview: false }); + await settle(40); + const token = director._runToken; + dataManager.visibilityListener({ layerId: 'flights', enabled: false, origin: 'scene' }); + assert.equal(token.cancelled, false); + dataManager.visibilityListener({ layerId: 'flights', enabled: false, origin: 'user' }); + assert.equal(token.cancelled, true, phase); + dataManager.visibilityListener({ layerId: 'flights', enabled: false, origin: 'tool' }); + release(); + await run; + assert.equal(dataManager.committed.some(({ id, enabled }) => id === 'traffic' && enabled), false); + } finally { await director.destroy(); restore(); } + } +}); + +test('scene preview owns recording chrome while panel playback leaves it available', async () => { + for (const preview of [true, false]) { + const { director, styleManager, restore } = makeDirector(); + const recording = []; + const playback = []; + let release; + const flight = new Promise((resolve) => { release = resolve; }); + director._flyCamera = () => flight; + styleManager.setRecordingMode = (active) => recording.push(active); + director._setPlaybackActive = (active) => playback.push(active); + try { + const run = director.startScene('scene-1', { single: true, preview }); + await settle(40); + assert.deepEqual(recording, preview ? [true] : []); + assert.deepEqual(playback, preview ? [true] : []); + director.stopScene(); + release(); + await run; + assert.deepEqual(recording, preview ? [true, false] : []); + assert.deepEqual(playback, preview ? [true, false] : []); + } finally { release(); await director.destroy(); restore(); } + } +}); + test('the director reconciles only the layers a shot declares', async () => { // Regression: _applyLayerStates walked the LIVE registry and forced every // undeclared layer off, tearing down CCTV/vessels/fires with no restore pass. diff --git a/src/scenes/nepalEvidencePack.js b/src/scenes/nepalEvidencePack.js new file mode 100644 index 000000000..eaca287bc --- /dev/null +++ b/src/scenes/nepalEvidencePack.js @@ -0,0 +1,349 @@ +import { SOURCE_PATH_BEAT_IDS } from '../data/bhoteKoshiShotPaths.js'; + +/** + * Nepal evidence expansion. Coordinates and camera framing come from + * the source event pack; existing authored cameras remain owned by the Scene. + */ +const ADDITIONAL_SHOTS = [ + { + id: 'second-landslide', + title: 'Second landslide', + lat: 28.399234, + lon: 85.535806, + alt: 10145, + heading: 224, + pitch: -38, + hold: 11, + }, + { + id: 'dhunche', + title: 'Dhunche', + lat: 28.161431, + lon: 85.324278, + alt: 6212, + heading: 208, + pitch: -39, + hold: 16, + }, + { + id: 'mailung-upper-trishuli', + title: 'Mailung / Upper Trishuli bridge', + lat: 28.140642, + lon: 85.265996, + alt: 6353, + heading: 211, + pitch: -40, + hold: 13, + }, + { + id: 'mailung-bazzar', + title: 'Mailung Bazzar, Dandaguan', + lat: 28.126526, + lon: 85.248027, + alt: 6331, + heading: 212, + pitch: -40, + hold: 15, + }, + { + id: 'dandagaun', + title: 'Dandagaun, Rasuwa', + lat: 28.124424, + lon: 85.245154, + alt: 6311, + heading: 214, + pitch: -40, + hold: 15, + }, + { + id: 'dandagaun-viewpoint', + title: 'Dandagaun viewpoint restaurant', + lat: 28.086663, + lon: 85.229914, + alt: 6740, + heading: 215, + pitch: -41, + hold: 13, + }, + { + id: 'betrawati-bazaar', + title: 'Betrawati Bazaar', + lat: 28.029827, + lon: 85.227694, + alt: 7034, + heading: 216, + pitch: -41, + hold: 12, + }, + { + id: 'bhainse', + title: 'Bhainse', + lat: 28.023087, + lon: 85.229449, + alt: 8051, + heading: 219, + pitch: -41, + hold: 13, + }, + { + id: 'devighat-taadi-khola-bridge', + title: 'Devighat / Taadi Khola Bridge', + lat: 27.921132, + lon: 85.177113, + alt: 9056, + heading: 224, + pitch: -42, + hold: 13, + }, + { + id: 'charaudi', + title: 'Charaudi', + lat: 27.865095, + lon: 84.838424, + alt: 10347, + heading: 232, + pitch: -42, + hold: 11, + }, + { + id: 'final-view', + title: 'Final view', + lat: 28.0834, + lon: 85.206, + alt: 113520, + heading: 64, + pitch: -83, + roll: 174, + duration: 4, + hold: 11, + locatorPresentation: 'bhote-koshi-incident-places', + }, +]; +const EVIDENCE_ORDER = [ + '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', + 'final-view', +]; + +// Per-reach motion timing keeps the schematic front ahead of the fixed 4.2s +// camera flights without pretending short urban reaches and long valley runs +// should advance at one global speed. +const PATH_DURATION_BY_BEAT = Object.freeze({ + 'debris-dammed-lake': 4.8, + 'second-landslide': 6.5, + 'timure-cluster': 2.1, + 'syabru-besi': 2.8, + dhunche: 2.5, + 'mailung-upper-trishuli': 2.9, + 'mailung-bazzar': 2.1, + dandagaun: 1.2, + 'dandagaun-viewpoint': 2.5, + 'betrawati-bazaar': 2.9, + bhainse: 1.5, + 'bidur-trishuli-bridge': 2.6, +}); + +/** Extend the six-beat Nepal pack without replacing existing authored shots. */ +export function expandNepalEvidencePack(base) { + const layerId = base.requiredSourcePackLayerId; + const extraShots = ADDITIONAL_SHOTS.map( + ({ id, hold, duration = 4.2, locatorPresentation, ...camera }) => ({ + ...camera, + roll: Number.isFinite(Number(camera.roll)) ? Number(camera.roll) : 0, + duration, + hold, + mapStack: 'photoreal', + layers: { + [layerId]: { + enabled: true, + params: { + presentation: 'scene-beat', + beatId: id, + beatReveal: 0.36, + split: 0.5, + }, + }, + ...(locatorPresentation + ? { + 'bhote-koshi-locator': { + enabled: true, + params: { presentation: locatorPresentation }, + }, + } + : {}), + }, + }), + ); + const byBeat = new Map( + [...base.cameraPath, ...extraShots].map((shot) => [ + shot.layers[layerId].params.beatId, + shot, + ]), + ); + const cameraPath = EVIDENCE_ORDER.map((id) => byBeat.get(id)); + const runtimeControlsByBeat = { ...base.runtimeControlsByBeat }; + const cameraLedPathBeats = new Set([ + 'timure-cluster', + 'syabru-besi', + ...SOURCE_PATH_BEAT_IDS.slice(2), + ]); + const mediaLedPathBeats = new Set(['debris-dammed-lake', 'second-landslide']); + const finalSourcedPathBeatId = SOURCE_PATH_BEAT_IDS.at(-1); + const postSourceHistoryBeats = new Set([ + 'devighat-taadi-khola-bridge', + 'charaudi', + 'final-view', + ]); + for (const shot of cameraPath) { + const id = shot.layers[layerId].params.beatId; + const isFinalView = id === 'final-view'; + const authoredPathDurationSec = PATH_DURATION_BY_BEAT[id]; + runtimeControlsByBeat[id] = { + ...runtimeControlsByBeat[id], + evidenceMediaAutoplay: !isFinalView, + ...(isFinalView + ? {} + : { + mediaPlaybackHoldSec: id === 'mailung-bazzar' ? 7 : 6, + mediaExitDurationSec: 0.65, + }), + deferEvidenceUntilCameraSettled: !isFinalView, + evidenceRevealDurationSec: 0.9, + evidencePath: SOURCE_PATH_BEAT_IDS.includes(id) + ? 'source' + : ['gyirong-border-gate', 'timure-cluster', 'syabru-besi'].includes(id) + ? 'comparison' + : 'none', + ...(cameraLedPathBeats.has(id) + ? { + evidencePathDuringCamera: true, + evidencePathPersistent: true, + ...(authoredPathDurationSec + ? { evidencePathTravelDurationSec: authoredPathDurationSec } + : {}), + } + : {}), + ...(mediaLedPathBeats.has(id) + ? { + evidencePathDuringMedia: true, + evidencePathPersistent: true, + evidencePathStartDelaySec: 0, + } + : {}), + }; + if (SOURCE_PATH_BEAT_IDS.includes(id)) { + runtimeControlsByBeat[id] = { + ...runtimeControlsByBeat[id], + evidenceHoldCard: true, + evidencePathHistory: true, + evidencePathDurationSec: + authoredPathDurationSec || Math.max(4, shot.hold), + minimumHoldSec: Math.max(4, shot.hold), + }; + } + if (SOURCE_PATH_BEAT_IDS.slice(2).includes(id)) { + runtimeControlsByBeat[id] = { + ...runtimeControlsByBeat[id], + evidencePathHistory: true, + evidencePathElevation: 'source', + }; + } + if (postSourceHistoryBeats.has(id)) { + runtimeControlsByBeat[id] = { + ...runtimeControlsByBeat[id], + evidencePath: 'history', + evidencePathHistory: true, + evidencePathHistoryBeatId: finalSourcedPathBeatId, + evidencePathPersistent: true, + evidencePathElevation: 'source', + }; + } + const pathDurationSec = + Number(runtimeControlsByBeat[id].evidencePathDurationSec) || + (!cameraLedPathBeats.has(id) && + runtimeControlsByBeat[id].evidenceSequence === true + ? Number(runtimeControlsByBeat[id].evidenceSequenceDurationSec) || 0 + : 0); + const mediaPresentationSec = isFinalView + ? 0 + : (id === 'mailung-bazzar' ? 7 : 6) + 0.65; + const mediaAndPathHoldSec = + pathDurationSec > 0 && !cameraLedPathBeats.has(id) + ? mediaLedPathBeats.has(id) + ? Math.max(mediaPresentationSec, pathDurationSec) + : mediaPresentationSec + pathDurationSec + : mediaPresentationSec; + runtimeControlsByBeat[id] = { + ...runtimeControlsByBeat[id], + minimumHoldSec: + id === 'mailung-bazzar' + ? mediaAndPathHoldSec + : Math.max( + Number(runtimeControlsByBeat[id].minimumHoldSec) || 0, + mediaAndPathHoldSec, + ), + }; + } + // Keep the downstream sequence on its sourced river profile. Coarse Google + // mesh samples can contain cliff-height spikes that reverse apparent motion. + // Preserve each authored readable hold after the media-first timing floor. + for (const [id, seconds, sequenceSeconds = seconds] of [ + ['immediate-collapse-viewpoint', 10], + ['gyirong-border-gate', 8, 2.5], + ['timure-cluster', 12], + ['syabru-besi', 11], + ]) { + runtimeControlsByBeat[id] = { + ...runtimeControlsByBeat[id], + evidenceHoldCard: true, + evidenceSequenceDurationSec: sequenceSeconds, + minimumHoldSec: Math.max( + Number(runtimeControlsByBeat[id].minimumHoldSec) || 0, + seconds, + ), + }; + } + const finalView = byBeat.get('final-view'); + return Object.freeze({ + ...base, + version: 18, + cameraPath, + runtimeControlsByBeat, + // Clip trims must also replace older saved holds, not just lower a floor. + runtimeHoldSecByBeat: { 'mailung-bazzar': 7 + 0.65 }, + expansionFromVersion: 17, + previousRequiredSourcePackBeatIds: EVIDENCE_ORDER.slice(0, -1), + previousRequiredSourcePackBeatIdVariants: [ + base.requiredSourcePackBeatIds, + EVIDENCE_ORDER.slice(0, -1), + ], + requiredSourcePackBeatIds: EVIDENCE_ORDER, + requiredShotTitles: [ + ...base.requiredShotTitles.slice(0, -base.cameraPath.length), + ...cameraPath.map(({ title }) => title), + ], + adoptExistingShotTitles: ['Final view'], + shotPatches: [ + ...(base.shotPatches || []), + { + title: 'Final view', + layers: finalView.layers, + }, + ], + photorealSurfaceBeatIds: EVIDENCE_ORDER, + }); +} diff --git a/src/scenes/recipes.js b/src/scenes/recipes.js index 7c6469117..e04c8692b 100644 --- a/src/scenes/recipes.js +++ b/src/scenes/recipes.js @@ -3,7 +3,594 @@ * Each recipe is deterministic so repeated runs produce similar footage. */ -export const SCENE_RECIPES = [ +import { expandNepalEvidencePack } from './nepalEvidencePack.js'; + +const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({ + id: 'bhote-koshi-nepal-evidence-pack', + version: 12, + title: 'Bhote Koshi Evidence Sequence', + style: 'normal', + ui: { hidePanels: false, hudMode: 'full', safeFrame: '16:9' }, + layers: { + 'bhote-koshi-locator': false, + }, + releaseLayerIds: ['bhote-koshi-2026', 'bhote-koshi-locator'], + post: { + bloom: 0, + sharpen: true, + detectionMode: 'OFF', + mapStack: 'photoreal', + }, + legacySceneBootstrap: { + targetSceneTitle: 'Nepal Flood Incident', + fromShotTitles: ['Shot 1', 'Shot 2', 'Shot 3'], + cameraPath: [ + { + title: 'Global Incident Context', + lat: 27.5962, + lon: 78.5718, + alt: 15628002, + heading: 0, + pitch: -90, + roll: 0, + duration: 4, + hold: 0.9, + layers: { + 'bhote-koshi-locator': { + enabled: true, + params: { presentation: 'nepal-context' }, + }, + }, + }, + { + title: 'Nepal-Focused Globe Rotation', + lat: 28.7248, + lon: 81.3226, + alt: 2068966, + heading: 0, + pitch: -90, + roll: 0, + duration: 4, + hold: 0.9, + layers: { + 'bhote-koshi-locator': { + enabled: true, + params: { presentation: 'nepal-context' }, + }, + }, + }, + { + title: 'Bhote Koshi Regional Approach', + lat: 27.0354, + lon: 84.6287, + alt: 809543, + heading: 0, + pitch: -90, + roll: 0, + duration: 4, + hold: 0.9, + layers: { + 'bhote-koshi-locator': { + enabled: true, + params: { presentation: 'bhote-koshi-regional' }, + }, + }, + }, + { + title: 'Bhote Koshi Nearby Cities', + lat: 27.891, + lon: 85.428, + alt: 225734, + heading: 0, + pitch: -90, + roll: 0, + duration: 4, + hold: 0.9, + layers: { + 'bhote-koshi-locator': { + enabled: true, + params: { presentation: 'bhote-koshi-city-context' }, + }, + }, + }, + { + title: 'Bhote Koshi Incident Corridor', + lat: 28.0529, + lon: 85.2189, + alt: 126931, + heading: 0, + pitch: -90, + roll: 0, + duration: 4, + hold: 11, + layers: { + 'bhote-koshi-locator': { + enabled: true, + params: { presentation: 'bhote-koshi-incident-places' }, + }, + }, + }, + { + title: 'Bhote Koshi Flood Path', + lat: 28.1691, + lon: 85.352, + alt: 60328, + heading: 0, + pitch: -90, + roll: 0, + duration: 4, + hold: 0.9, + layers: { + 'bhote-koshi-locator': { + enabled: true, + params: { presentation: 'bhote-koshi-flood-path' }, + }, + }, + }, + { + title: 'Bhote Koshi Corridor Overview', + lat: 27.8986, + lon: 84.8723, + alt: 53247, + heading: 60, + pitch: -48, + roll: 0, + duration: 4, + hold: 0.9, + layers: { + 'bhote-koshi-locator': { + enabled: true, + params: { presentation: 'bhote-koshi-path-overview' }, + }, + }, + }, + { + title: 'Bhote Koshi Upper Valley', + lat: 28.4187, + lon: 85.4341, + alt: 7977, + heading: 155, + pitch: -28, + roll: 0, + duration: 4, + hold: 8, + layers: { + 'bhote-koshi-locator': { + enabled: true, + params: { presentation: 'bhote-koshi-trigger-record' }, + }, + }, + }, + ], + }, + requiredShotTitles: [ + 'Global Incident Context', + 'Nepal-Focused Globe Rotation', + 'Bhote Koshi Regional Approach', + 'Bhote Koshi Nearby Cities', + 'Bhote Koshi Incident Corridor', + 'Bhote Koshi Flood Path', + 'Bhote Koshi Corridor Overview', + 'Bhote Koshi Upper Valley', + 'Upper Valley Collapse', + 'Debris-Dammed Lake', + 'Nepal-China Border Gate', + 'Timure Evidence Cluster', + 'Syabru Besi Passage', + 'Bidur / Trishuli Consequence', + ], + requiredSourcePackBeatIds: [ + 'immediate-collapse-viewpoint', + 'debris-dammed-lake', + 'gyirong-border-gate', + 'timure-cluster', + 'syabru-besi', + 'bidur-trishuli-bridge', + ], + requiredSourcePackLayerId: 'bhote-koshi-2026', + photorealSurfaceBeatIds: [ + 'gyirong-border-gate', + 'timure-cluster', + 'syabru-besi', + 'bidur-trishuli-bridge', + ], + runtimeControlsByBeat: { + 'immediate-collapse-viewpoint': { evidenceMediaAutoplay: true }, + 'debris-dammed-lake': { evidenceMediaAutoplay: true }, + 'gyirong-border-gate': { + imageryComparison: true, + evidenceSequence: true, + evidenceSequenceDurationSec: 2.5, + deferEvidenceUntilCameraSettled: true, + evidenceRevealDurationSec: 2.5, + }, + 'timure-cluster': { + imageryComparison: true, + evidenceSequence: true, + evidenceSequenceDurationSec: 4, + }, + 'syabru-besi': { + imageryComparison: true, + evidenceSequence: true, + evidenceSequenceDurationSec: 4, + }, + 'bidur-trishuli-bridge': { + evidenceSequence: true, + evidenceSequenceDurationSec: 4, + }, + }, + shotPatches: [ + { + title: 'Global Incident Context', + visual: { mapStack: 'photoreal' }, + layers: { + 'bhote-koshi-2026': { + enabled: true, + params: { + presentation: 'scene-beat', + beatId: 'immediate-collapse-viewpoint', + beatReveal: 0.36, + split: 0.5, + }, + }, + }, + }, + { + title: 'Nepal-Focused Globe Rotation', + visual: { mapStack: 'photoreal' }, + layers: { + 'bhote-koshi-2026': { + enabled: true, + params: { + presentation: 'scene-beat', + beatId: 'debris-dammed-lake', + beatReveal: 0.36, + split: 0.5, + }, + }, + }, + }, + { + title: 'Bhote Koshi Regional Approach', + visual: { mapStack: 'photoreal' }, + layers: { + 'bhote-koshi-2026': { + enabled: true, + params: { + presentation: 'scene-beat', + beatId: 'gyirong-border-gate', + beatReveal: 0.36, + split: 0.5, + }, + }, + }, + }, + { + title: 'Bhote Koshi Nearby Cities', + visual: { mapStack: 'photoreal' }, + layers: { + 'bhote-koshi-2026': { + enabled: true, + params: { + presentation: 'scene-beat', + beatId: 'timure-cluster', + beatReveal: 0.36, + split: 0.5, + }, + }, + }, + }, + { + title: 'Bhote Koshi Incident Corridor', + camera: { + lat: 28.0529, + lon: 85.2189, + alt: 126931, + heading: 0, + pitch: -90, + roll: 0, + }, + holdSec: 11, + visual: { mapStack: 'photoreal' }, + layers: { + 'bhote-koshi-2026': { + enabled: true, + params: { + presentation: 'scene-beat', + beatId: 'syabru-besi', + beatReveal: 0.36, + split: 0.5, + }, + }, + }, + }, + { + title: 'Bhote Koshi Flood Path', + visual: { mapStack: 'photoreal' }, + layers: { + 'bhote-koshi-2026': { + enabled: true, + params: { + presentation: 'scene-beat', + beatId: 'bidur-trishuli-bridge', + beatReveal: 0.36, + split: 0.5, + }, + }, + }, + }, + { + title: 'Bhote Koshi Corridor Overview', + visual: { mapStack: 'photoreal' }, + layers: { + 'bhote-koshi-2026': { + enabled: true, + params: { + presentation: 'scene-beat', + beatId: 'gyirong-border-gate', + beatReveal: 0.36, + split: 0.5, + }, + }, + }, + }, + { + title: 'Bhote Koshi Upper Valley', + holdSec: 8, + visual: { mapStack: 'photoreal' }, + layers: { + 'bhote-koshi-2026': { + enabled: true, + params: { + presentation: 'scene-beat', + beatId: 'immediate-collapse-viewpoint', + beatReveal: 0.36, + split: 0.5, + }, + }, + 'bhote-koshi-locator': { + enabled: true, + params: { + presentation: 'bhote-koshi-trigger-record', + }, + }, + }, + }, + { + title: 'Upper Valley Collapse', + camera: { + lat: 28.417, + lon: 85.3937, + alt: 9850, + heading: 146, + pitch: -34, + roll: 0, + }, + layers: { + 'bhote-koshi-2026': { + enabled: true, + params: { + presentation: 'scene-beat', + beatId: 'immediate-collapse-viewpoint', + beatReveal: 0.36, + split: 0.5, + sceneControls: { evidenceMediaAutoplay: true }, + }, + }, + }, + }, + { + title: 'Nepal-China Border Gate', + visual: { mapStack: 'photoreal' }, + }, + { + title: 'Timure Evidence Cluster', + visual: { mapStack: 'photoreal' }, + }, + { + title: 'Syabru Besi Passage', + visual: { mapStack: 'photoreal' }, + }, + { + title: 'Bidur / Trishuli Consequence', + visual: { mapStack: 'photoreal' }, + }, + ], + cameraPath: [ + { + title: 'Upper Valley Collapse', + lat: 28.417, + lon: 85.3937, + alt: 9850, + heading: 146, + pitch: -34, + roll: 0, + duration: 4.2, + hold: 1.4, + layers: { + 'bhote-koshi-2026': { + enabled: true, + params: { + presentation: 'scene-beat', + beatId: 'immediate-collapse-viewpoint', + beatReveal: 0.36, + split: 0.5, + sceneControls: { evidenceMediaAutoplay: true }, + }, + }, + }, + }, + { + title: 'Debris-Dammed Lake', + lat: 28.392914, + lon: 85.570921, + alt: 10744, + heading: 232, + pitch: -37, + roll: 0, + duration: 4.2, + hold: 1.4, + layers: { + 'bhote-koshi-2026': { + enabled: true, + params: { + presentation: 'scene-beat', + beatId: 'debris-dammed-lake', + beatReveal: 0.36, + split: 0.5, + sceneControls: { evidenceMediaAutoplay: true }, + }, + }, + }, + }, + { + title: 'Nepal-China Border Gate', + mapStack: 'photoreal', + lat: 28.316789, + lon: 85.399734, + alt: 5900, + heading: 208, + pitch: -42, + roll: 0, + duration: 4.2, + hold: 2.5, + layers: { + 'bhote-koshi-2026': { + enabled: true, + params: { + presentation: 'scene-beat', + beatId: 'gyirong-border-gate', + beatReveal: 0.36, + split: 0.5, + sceneControls: { + imageryComparison: true, + evidenceSequence: true, + evidenceSequenceDurationSec: 2.5, + deferEvidenceUntilCameraSettled: true, + evidenceRevealDurationSec: 2.5, + }, + }, + }, + }, + }, + { + title: 'Timure Evidence Cluster', + mapStack: 'photoreal', + lat: 28.285968, + lon: 85.379136, + alt: 5032, + heading: 198, + pitch: -40, + roll: 0, + duration: 4.2, + hold: 4, + layers: { + 'bhote-koshi-2026': { + enabled: true, + params: { + presentation: 'scene-beat', + beatId: 'timure-cluster', + beatReveal: 0.36, + split: 0.5, + sceneControls: { + imageryComparison: true, + evidenceSequence: true, + evidenceSequenceDurationSec: 4, + }, + }, + }, + }, + }, + { + title: 'Syabru Besi Passage', + mapStack: 'photoreal', + lat: 28.210306, + lon: 85.361527, + alt: 5893, + heading: 205, + pitch: -38, + roll: 0, + duration: 4.2, + hold: 4, + layers: { + 'bhote-koshi-2026': { + enabled: true, + params: { + presentation: 'scene-beat', + beatId: 'syabru-besi', + beatReveal: 0.36, + split: 0.5, + sceneControls: { + imageryComparison: true, + evidenceSequence: true, + evidenceSequenceDurationSec: 4, + }, + }, + }, + }, + }, + { + title: 'Bidur / Trishuli Consequence', + mapStack: 'photoreal', + lat: 27.981737, + lon: 85.196273, + alt: 7422, + heading: 215, + pitch: -40, + roll: 0, + duration: 4.2, + hold: 4, + layers: { + 'bhote-koshi-2026': { + enabled: true, + params: { + presentation: 'scene-beat', + beatId: 'bidur-trishuli-bridge', + beatReveal: 0.36, + split: 0.5, + sceneControls: { + evidenceSequence: true, + evidenceSequenceDurationSec: 4, + }, + }, + }, + }, + }, + ], +}); + +const BHOTE_KOSHI_NEPAL_APPEND_RECIPE = BHOTE_KOSHI_NEPAL_BASE_RECIPE + ? expandNepalEvidencePack(BHOTE_KOSHI_NEPAL_BASE_RECIPE) + : null; + +// Fresh browser origins have no legacy three-shot Nepal project for the +// append recipe to upgrade. Seed that exact legacy shape so SceneDirector can +// run the same idempotent bootstrap used by existing browser-saved projects. +const BHOTE_KOSHI_NEPAL_BOOTSTRAP_RECIPE = BHOTE_KOSHI_NEPAL_APPEND_RECIPE + ? Object.freeze({ + id: 'bhote-koshi-nepal-scene', + title: 'Nepal Flood Incident', + durationSec: 15, + style: BHOTE_KOSHI_NEPAL_APPEND_RECIPE.style, + ui: BHOTE_KOSHI_NEPAL_APPEND_RECIPE.ui, + layers: BHOTE_KOSHI_NEPAL_APPEND_RECIPE.layers, + releaseLayerIds: BHOTE_KOSHI_NEPAL_APPEND_RECIPE.releaseLayerIds, + post: BHOTE_KOSHI_NEPAL_APPEND_RECIPE.post, + installAlongsideSceneId: 'bhote-koshi-flood', + installAlongsideFallbackSceneId: 'flights-radar', + cameraPath: + BHOTE_KOSHI_NEPAL_APPEND_RECIPE.legacySceneBootstrap.cameraPath + .slice(0, 3) + .map((keyframe, index) => ({ + ...keyframe, + title: `Shot ${index + 1}`, + })), + }) + : null; + +const EVENT_RECIPES = [BHOTE_KOSHI_NEPAL_BOOTSTRAP_RECIPE].filter(Boolean); + +const PUBLIC_SCENE_RECIPES = [ { id: 'flights-radar', title: 'Global Flights Radar', @@ -377,6 +964,21 @@ export const SCENE_RECIPES = [ }, ]; +/** Build the recipe list without mutating stored user-authored projects. */ +export function createSceneRecipes({ localDemoRecipes = EVENT_RECIPES } = {}) { + return [...localDemoRecipes, ...PUBLIC_SCENE_RECIPES]; +} + +export const SCENE_RECIPES = createSceneRecipes(); + +export const SCENE_APPEND_RECIPES = BHOTE_KOSHI_NEPAL_APPEND_RECIPE + ? [BHOTE_KOSHI_NEPAL_APPEND_RECIPE] + : []; + export function getSceneRecipeById(id) { return SCENE_RECIPES.find((recipe) => recipe.id === id) || null; } + +export function getSceneAppendRecipeById(id) { + return SCENE_APPEND_RECIPES.find((recipe) => recipe.id === id) || null; +} diff --git a/src/scenes/scenePolicy.test.mjs b/src/scenes/scenePolicy.test.mjs index fbe240cac..0b8e252b9 100644 --- a/src/scenes/scenePolicy.test.mjs +++ b/src/scenes/scenePolicy.test.mjs @@ -41,6 +41,7 @@ function sweepLayerParamKeys() { /** The layer registry as main.js builds it (src/main.js dataManager.register calls). */ const REGISTERED = new Set([ + 'bhote-koshi-2026', 'bhote-koshi-locator', 'flights', 'military', 'earthquakes', 'satellites', 'rocket-launches', 'traffic', 'cctv', 'radio', 'bikeshare', 'ais-live-vessels', 'military-installations', 'military-awareness', 'local-datacenters', 'local-dams', diff --git a/src/sharelink.celestial.test.mjs b/src/sharelink.celestial.test.mjs index fc9e3ae03..4226aa5c7 100644 --- a/src/sharelink.celestial.test.mjs +++ b/src/sharelink.celestial.test.mjs @@ -66,7 +66,7 @@ test('share links parse explicit celestial on and off states', () => { }); test('unknown-only v2 layer tokens are invalid, while historical l fields stay inert', () => { - const invalid = makeManager('#v=2&lat=10&lon=20&l=z').parseInitialHash(); + const invalid = makeManager('#v=2&lat=10&lon=20&l=unknown').parseInitialHash(); assert.equal(invalid.layerState, null); assert.equal(invalid.layerStateInvalid, true); for (const hash of ['#lat=10&lon=20&l=z', '#v=1&lat=10&lon=20&l=z']) { @@ -76,6 +76,12 @@ test('unknown-only v2 layer tokens are invalid, while historical l fields stay i } }); +test('Nepal locator token is valid in v2 share links', () => { + const parsed = makeManager('#v=2&lat=10&lon=20&l=z').parseInitialHash(); + assert.deepEqual(parsed.layerState.enabledLayerIds, ['bhote-koshi-locator']); + assert.equal(parsed.layerStateInvalid, false); +}); + test('share-link serialization emits the current celestial state', () => { const manager = makeManager(); manager.onToggleChange(false, false, { celestialRingEnabled: false }); diff --git a/src/standalone/application.js b/src/standalone/application.js index 599e004c9..3aaa9bde8 100644 --- a/src/standalone/application.js +++ b/src/standalone/application.js @@ -43,6 +43,12 @@ export function createStandaloneApplication({ loaderStatus, }); catalog = createStandaloneCatalog({ + nepalBoundaryResolver: (signal) => + scene.operations.annotationResolver.resolveRegionRingForQuery( + 'Nepal', + signal, + placeSearch, + ), signal: context.signal, surface: scene.operations.surface, }); diff --git a/src/standalone/catalog.js b/src/standalone/catalog.js index eda304975..7e4e66808 100644 --- a/src/standalone/catalog.js +++ b/src/standalone/catalog.js @@ -6,6 +6,7 @@ export { createStandaloneReferenceSources } from './layerSources.js'; /** Create fresh layer instances using the existing standalone source choices. */ export function createStandaloneCatalog({ + nepalBoundaryResolver, signal = new AbortController().signal, surface = createSurfaceServices({ terrainSource: createApplicationRequestServices().terrain, @@ -13,6 +14,7 @@ export function createStandaloneCatalog({ }), } = {}) { return createApplicationCatalog({ + nepalBoundaryResolver, surface, sources: createStandaloneLayerSources(), signal, diff --git a/src/ui/sceneControls.test.mjs b/src/ui/sceneControls.test.mjs index c4ed99f93..7f0611110 100644 --- a/src/ui/sceneControls.test.mjs +++ b/src/ui/sceneControls.test.mjs @@ -25,6 +25,11 @@ function fixture() { appendChild(element) { this.children.push(element); } + setAttribute(name, value) { + this[name] = value; + } + focus() {} + select() {} set textContent(value) { this._text = value; this.children = []; @@ -164,6 +169,8 @@ test('Scene controls dispatch explicit actions and revoke replaced shot-row list f.elements.start.click(); label.click(); label.dispatchEvent(new Event('dblclick')); + label.children[0].value = 'Renamed'; + label.children[0].dispatchEvent(new Event('blur')); load.click(); assert.deepEqual(f.calls, [ ['capture'], @@ -343,6 +350,8 @@ test('the real director preserves a selected shot label for the following double label.click(); assert.equal(f.elements.shots.children[0].children[0].children[0], label); label.dispatchEvent(new Event('dblclick')); + label.children[0].value = 'Renamed'; + label.children[0].dispatchEvent(new Event('blur')); assert.equal(director._getSelectedScene().shots[0].title, 'Renamed'); } finally { await director?.destroy(); @@ -351,6 +360,35 @@ test('the real director preserves a selected shot label for the following double } }); +test('inline shot names save on Enter, cancel on Escape, and reject blank names', () => { + const f = fixture(); + const key = (name) => Object.assign(new Event('keydown'), { key: name }); + try { + const label = f.elements.shots.children[0].children[0].children[0]; + for (const [value, commitKey] of [ + ['Cancelled', 'Escape'], + [' ', 'Enter'], + [' New name ', 'Enter'], + ]) { + label.dispatchEvent(new Event('dblclick')); + const input = label.children[0]; + input.value = value; + input.dispatchEvent(key(commitKey)); + input.dispatchEvent(new Event('blur')); + } + assert.deepEqual(f.calls, [ + ['renameShot', 'scene-a', 'shot-a', 'New name'], + ]); + assert.equal( + label.textContent, + '', + 'the model owns the next render', + ); + } finally { + f.restore(); + } +}); + test('Scene controls consume current state, preserve rows on progress, and unsubscribe on destruction', () => { const f = fixture(); try { diff --git a/src/ui/scenePresentation.js b/src/ui/scenePresentation.js index 700fc63dc..94cacd022 100644 --- a/src/ui/scenePresentation.js +++ b/src/ui/scenePresentation.js @@ -61,10 +61,36 @@ export function renderSceneShots( const label = document.createElement('div'); label.className = 'scene-shot-label'; label.textContent = shot.title; + label.title = 'Double-click to rename'; listen(label, 'click', () => select(shot.id)); listen(label, 'dblclick', () => { - const title = window.prompt('Shot title', shot.title); - if (title) rename(scene.id, shot.id, title); + if (label.children.length) return; + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'scene-shot-rename'; + input.setAttribute('aria-label', 'Shot name'); + input.value = shot.title; + let finished = false; + const finish = (save) => { + if (finished) return; + finished = true; + const title = input.value.trim(); + label.textContent = shot.title; + if (save && title && title !== shot.title) + rename(scene.id, shot.id, title); + }; + listen(input, 'keydown', (event) => { + event.stopPropagation(); + if (event.key === 'Enter' || event.key === 'Escape') { + event.preventDefault(); + finish(event.key === 'Enter'); + } + }); + listen(input, 'blur', () => finish(true)); + label.textContent = ''; + label.appendChild(input); + input.focus(); + input.select(); }); const actions = document.createElement('div'); actions.className = 'scene-shot-actions'; diff --git a/src/ui/styles/bhote-koshi.css b/src/ui/styles/bhote-koshi.css new file mode 100644 index 000000000..952e4d07b --- /dev/null +++ b/src/ui/styles/bhote-koshi.css @@ -0,0 +1,613 @@ +/* Bhote Koshi 2026 event reconstruction */ +#bhote-koshi-event-panel { + position: fixed; + top: var(--right-stack-safe-top, 26vh); + right: var(--right-rail-x); + bottom: auto; + z-index: 142; + width: min(330px, calc(100vw - 40px)); + max-height: calc(100vh - var(--right-stack-safe-top, 26vh) - 88px); + overflow-y: auto; + color: var(--text-primary); + background: var(--glass-bg); + border: 1px solid var(--glass-border); + border-radius: 6px; + box-shadow: 0 18px 52px rgba(0, 0, 0, 0.48), inset 0 0 28px rgba(14, 78, 92, 0.12); + backdrop-filter: blur(24px) saturate(1.4); + font-family: var(--font-mono); + letter-spacing: 0; + pointer-events: auto; + scrollbar-width: none; +} + +#bhote-koshi-event-panel::-webkit-scrollbar { + display: none; +} + +#bhote-koshi-event-panel [hidden], +#bhote-koshi-split-line[hidden] { + display: none !important; +} + +#bhote-koshi-embedded-media-root { + position: absolute; + inset: 0; + z-index: 7; + overflow: hidden; + pointer-events: none; +} + +.bhote-embedded-media-warmup { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; + pointer-events: none; + clip-path: inset(50%); +} + +.bhote-embedded-callout { + position: absolute; + box-sizing: border-box; + width: min(356px, calc(100% - 36px)); + max-height: min(270px, calc(100% - 36px)); + overflow: visible; + color: var(--text-primary); + font-family: var(--font-mono); + pointer-events: none; +} + +.bhote-embedded-callout[hidden] { + display: none !important; +} + +.bhote-embedded-callout-leader { + position: absolute; + top: 0; + left: 0; + z-index: 0; + width: 1px; + height: 1px; + overflow: visible; + pointer-events: none; +} + +.bhote-embedded-callout-leader circle { + opacity: 0; + transform: scale(0.2); + transform-box: fill-box; + transform-origin: center; +} + +.bhote-embedded-callout-leader path { + opacity: 0; + stroke-dasharray: 1; + stroke-dashoffset: 1; +} + +.bhote-embedded-callout-body { + position: relative; + z-index: 1; + overflow: hidden; + background: rgba(5, 13, 19, 0.96); + border: 1px solid rgba(107, 232, 255, 0.32); + box-shadow: 0 18px 46px rgba(0, 0, 0, 0.56); + opacity: 0; + transform: translateY(8px) scale(0.98); + pointer-events: auto; +} + +.bhote-embedded-callout-player { + width: 100%; + height: min(200px, 34vh); + aspect-ratio: 16 / 9; + min-height: 0; + overflow: hidden; + background: #03080d; + scrollbar-width: thin; + scrollbar-color: rgba(107, 232, 255, 0.36) transparent; +} + +.bhote-embedded-callout.is-youtube { + width: min(280px, calc(100% - 36px)); + max-height: min(228px, calc(100% - 36px)); +} + +.bhote-embedded-callout.is-youtube .bhote-embedded-callout-player { + height: min(158px, 27vh); +} + +.bhote-embedded-callout.is-portrait { + width: min(214px, calc(100% - 36px)); + max-height: min(390px, calc(100% - 36px)); +} + +.bhote-embedded-callout.is-portrait .bhote-embedded-callout-player { + width: 100%; + height: min(320px, 48vh); + aspect-ratio: 9 / 16; +} + +.bhote-embedded-callout.is-entering .bhote-embedded-callout-leader circle { + animation: bhote-embedded-anchor-enter 240ms ease-out both; +} + +.bhote-embedded-callout.is-entering .bhote-embedded-callout-leader path { + animation: bhote-embedded-leader-enter 420ms 150ms ease-out both; +} + +.bhote-embedded-callout.is-entering .bhote-embedded-callout-body { + animation: bhote-embedded-body-enter 420ms 510ms ease-out both; +} + +.bhote-embedded-callout.is-leaving .bhote-embedded-callout-body { + animation: bhote-embedded-body-exit 240ms ease-in both; +} + +.bhote-embedded-callout.is-leaving .bhote-embedded-callout-leader path { + animation: bhote-embedded-leader-exit 300ms 190ms ease-in both; +} + +.bhote-embedded-callout.is-leaving .bhote-embedded-callout-leader circle { + animation: bhote-embedded-anchor-exit 160ms 460ms ease-in both; +} + +@keyframes bhote-embedded-anchor-enter { + from { opacity: 0; transform: scale(0.2); } + to { opacity: 1; transform: scale(1); } +} + +@keyframes bhote-embedded-leader-enter { + from { opacity: 0; stroke-dashoffset: 1; } + to { opacity: 1; stroke-dashoffset: 0; } +} + +@keyframes bhote-embedded-body-enter { + from { opacity: 0; transform: translateY(8px) scale(0.98); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +@keyframes bhote-embedded-body-exit { + from { opacity: 1; transform: translateY(0) scale(1); } + to { opacity: 0; transform: translateY(6px) scale(0.985); } +} + +@keyframes bhote-embedded-leader-exit { + from { opacity: 1; stroke-dashoffset: 0; } + to { opacity: 0; stroke-dashoffset: 1; } +} + +@keyframes bhote-embedded-anchor-exit { + from { opacity: 1; transform: scale(1); } + to { opacity: 0; transform: scale(0.2); } +} + +@media (prefers-reduced-motion: reduce) { + .bhote-embedded-callout.is-entering .bhote-embedded-callout-leader circle, + .bhote-embedded-callout.is-entering .bhote-embedded-callout-leader path, + .bhote-embedded-callout.is-entering .bhote-embedded-callout-body { + animation: none; + opacity: 1; + transform: none; + stroke-dashoffset: 0; + } +} + +.bhote-embedded-callout.is-x .bhote-embedded-callout-player { + max-height: min(200px, 34vh); + aspect-ratio: auto; + overflow: auto; +} + +.bhote-embedded-callout:not(.is-x) .bhote-embedded-callout-player iframe { + display: block; + width: 100%; + height: 100%; + min-height: 0; + border: 0; +} + +.bhote-facebook-player-host { + width: 100%; + height: 100%; +} + +.bhote-embedded-callout.is-x .bhote-embedded-callout-player iframe { + display: block; + width: 100% !important; + max-width: 100% !important; + min-height: 0; + border: 0; +} + +.bhote-embedded-callout.is-x .bhote-embedded-callout-player > * { + margin: 0 auto !important; +} + +.bhote-embedded-callout.is-unavailable .bhote-embedded-callout-player { + min-height: 92px; + background: repeating-linear-gradient( + 135deg, + rgba(107, 232, 255, 0.06), + rgba(107, 232, 255, 0.06) 12px, + rgba(3, 8, 13, 0.92) 12px, + rgba(3, 8, 13, 0.92) 24px + ); +} + +.bhote-embedded-callout footer { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 2px 8px; + padding: 7px 9px 8px; + border-top: 3px solid #6be8ff; +} + +.bhote-embedded-callout footer strong { + overflow: hidden; + font-size: 11px; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.bhote-embedded-callout footer small { + grid-column: 1; + color: rgba(151, 211, 224, 0.7); + font-size: 8px; + line-height: 1.35; +} + +.bhote-embedded-callout footer a { + grid-column: 2; + grid-row: 1 / span 2; + align-self: center; + color: #6be8ff; + font-size: 8px; + text-decoration: none; +} + +.bhote-embedded-callout footer a:hover, +.bhote-embedded-callout footer a:focus-visible { + text-decoration: underline; +} + +#right-context-rail.bhote-event-active > :not(#bhote-koshi-event-panel) { + display: none !important; +} + +.bhote-event-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; + padding: 15px 16px 12px; + border-bottom: 1px solid rgba(95, 213, 236, 0.18); +} + +.bhote-event-kicker, +.bhote-event-section-head, +.bhote-event-status, +.bhote-event-evidence summary, +.bhote-event-report small { + font-size: 9px; + line-height: 1.35; + text-transform: uppercase; + color: var(--accent); + opacity: 0.72; +} + +.bhote-event-title { + margin-top: 5px; + font-size: 17px; + line-height: 1.2; + color: var(--text-primary); +} + +.bhote-event-icon-btn { + flex: 0 0 30px; + width: 30px; + height: 30px; + padding: 0; + border: 1px solid rgba(151, 211, 224, 0.26); + border-radius: 4px; + color: var(--text-primary); + background: var(--accent-dim); + font: inherit; + font-size: 18px; + cursor: pointer; +} + +.bhote-event-status-row { + display: flex; + gap: 8px; + padding: 11px 16px 0; +} + +.bhote-event-status { + padding: 4px 7px; + border: 1px solid rgba(80, 206, 231, 0.24); + border-radius: 3px; + background: var(--accent-dim); +} + +.bhote-event-status.reconstructed { + color: var(--accent); + border-color: rgba(80, 206, 231, 0.24); + background: var(--accent-dim); +} + +.bhote-event-section { + padding: 13px 16px 0; +} + +.bhote-event-section-head, +.bhote-event-dates, +.bhote-event-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.bhote-event-section-head span:last-child { + color: rgba(222, 240, 245, 0.88); +} + +.bhote-event-range { + width: 100%; + height: 4px; + margin: 11px 0 8px; + accent-color: var(--accent); + cursor: ew-resize; +} + +.bhote-event-range.flood { + accent-color: var(--accent); + cursor: pointer; +} + +.bhote-event-dates { + font-size: 8px; + color: rgba(174, 208, 216, 0.62); +} + +.bhote-event-story-nav { + display: grid; + grid-template-columns: 34px minmax(0, 1fr) 34px; + align-items: stretch; + gap: 6px; + margin-top: 10px; +} + +.bhote-event-story-nav > button { + width: 34px; + min-height: 54px; + padding: 0; + border: 1px solid rgba(88, 196, 218, 0.3); + border-radius: 4px; + color: var(--accent); + background: rgba(7, 25, 35, 0.9); + font: 22px/1 'JetBrains Mono', 'SFMono-Regular', Consolas, monospace; + cursor: pointer; +} + +.bhote-event-story-current { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-content: center; + column-gap: 9px; + min-width: 0; + padding: 7px 10px; + border: 1px solid rgba(88, 196, 218, 0.18); + background: rgba(6, 19, 27, 0.72); +} + +.bhote-event-story-current span, +.bhote-event-story-current small { + color: rgba(116, 216, 235, 0.7); + font-size: 8px; + line-height: 1.25; +} + +.bhote-event-story-current strong { + overflow: hidden; + color: var(--text-primary); + font-size: 11px; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.bhote-event-story-current small { + grid-column: 1 / -1; + margin-top: 4px; + overflow: hidden; + color: rgba(116, 216, 235, 0.7); + text-overflow: ellipsis; + white-space: nowrap; +} + +.bhote-event-actions { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; + justify-content: flex-start; + margin-top: 9px; +} + +.bhote-event-actions button, +.bhote-event-report { + border: 1px solid rgba(88, 196, 218, 0.24); + border-radius: 4px; + color: var(--text-primary); + background: rgba(7, 25, 35, 0.9); + font: inherit; + font-size: 9px; + letter-spacing: 0; + cursor: pointer; +} + +.bhote-event-actions button { + min-width: 0; + min-height: 30px; + padding: 0 5px; + overflow: hidden; + font-size: 8px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.bhote-event-actions button:disabled, +.bhote-event-story-nav > button:disabled { + opacity: 0.3; + cursor: default; +} + +.bhote-event-actions button:first-child { + color: var(--accent); + border-color: rgba(88, 196, 218, 0.36); +} + +.bhote-event-actions button[data-action="cinematic"][aria-pressed="true"] { + color: #071017; + border-color: var(--accent); + background: var(--accent); +} + +.bhote-event-evidence { + margin: 13px 16px 0; + border-top: 1px solid rgba(95, 213, 236, 0.16); + border-bottom: 1px solid rgba(95, 213, 236, 0.16); +} + +.bhote-event-evidence summary { + padding: 10px 0; + cursor: pointer; +} + +.bhote-event-report-list { + display: grid; + gap: 6px; + padding-bottom: 10px; +} + +.bhote-event-report { + display: grid; + gap: 3px; + width: 100%; + padding: 8px 9px; + text-align: left; +} + +.bhote-event-report span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.bhote-event-report small { + color: rgba(115, 210, 228, 0.58); +} + +.bhote-event-credit { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin: 10px 16px 0; + padding: 8px 0 0; + border-top: 1px solid rgba(95, 213, 236, 0.16); + color: rgba(116, 216, 235, 0.62); + font-size: 8px; + line-height: 1.35; +} + +.bhote-event-credit button { + flex: 0 0 auto; + min-height: 26px; + padding: 0 8px; + border: 1px solid rgba(88, 196, 218, 0.24); + border-radius: 4px; + color: var(--text-primary); + background: rgba(7, 25, 35, 0.9); + font: inherit; + cursor: pointer; +} + +.bhote-event-caveat { + margin: 11px 16px 14px; + font-size: 8px; + line-height: 1.5; + color: rgba(179, 204, 210, 0.62); +} + +#bhote-koshi-split-line { + position: fixed; + top: 0; + bottom: 0; + left: var(--bhote-koshi-split, 50%); + z-index: 8; + width: 1px; + pointer-events: none; + background: rgba(218, 241, 247, 0.76); + box-shadow: 0 0 12px rgba(94, 216, 239, 0.72); +} + +#bhote-koshi-split-line span { + display: none; +} + +#bhote-koshi-split-line .bhote-koshi-split-handle { + position: absolute; + top: 50%; + left: 50%; + width: 36px; + height: 36px; + padding: 0; + border: 1px solid rgba(180, 215, 222, 0.72); + border-radius: 50%; + outline: 0; + color: rgba(239, 247, 249, 0.96); + background: rgba(4, 17, 27, 0.96); + box-shadow: + 0 0 0 5px rgba(62, 112, 125, 0.3), + 0 0 13px rgba(94, 216, 239, 0.3); + font: 700 14px/1 var(--font-mono); + transform: translate(-50%, -50%); + pointer-events: auto; + touch-action: none; + cursor: ew-resize; +} + +#bhote-koshi-split-line .bhote-koshi-split-handle:focus-visible { + outline: 2px solid #fff; + outline-offset: 4px; +} + +@media (max-width: 720px) { + #bhote-koshi-event-panel { + right: 12px; + bottom: 88px; + width: calc(100vw - 24px); + max-height: 48vh; + overflow-y: auto; + } + +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body { + width: 100%; + height: 100%; + overflow: hidden; + background: var(--bg-dark); + font-family: var(--font-sans); + color: var(--text-primary); + -webkit-font-smoothing: antialiased; +} diff --git a/src/ui/styles/scenes.css b/src/ui/styles/scenes.css index 58fdfed81..767291181 100644 --- a/src/ui/styles/scenes.css +++ b/src/ui/styles/scenes.css @@ -244,12 +244,24 @@ } .scene-shot-label { + min-width: 0; + flex: 1; font-family: var(--font-mono); font-size: 10px; letter-spacing: 1px; color: var(--text-primary); } +.scene-shot-rename { + width: 100%; + min-width: 0; + box-sizing: border-box; + font: inherit; + color: var(--text-primary); + background: var(--bg-panel, #101820); + border: 1px solid var(--accent, #00d4ff); +} + .scene-shot-meta { margin-top: 4px; font-family: var(--font-mono); diff --git a/style.css b/style.css index 3b9872835..b38bafaba 100644 --- a/style.css +++ b/style.css @@ -9,6 +9,7 @@ @import './src/ui/styles/radio.css'; @import './src/ui/styles/cctv.css'; @import './src/ui/styles/scenes.css'; +@import './src/ui/styles/bhote-koshi.css'; @import './src/ui/styles/recording.css'; @import './src/ui/styles/responsive.css'; @import './src/ui/styles/command-dock.css';