-▶️ **From the project behind the viral God's Eye View series** *(formerly WorldView)* — [5M+ on YouTube](https://youtube.com/playlist?list=PL6qSg2I-7_koPbDnSMo0QeeHX_RknA2uv&si=nBGYMoHWQw41v93Q) · [25M+ across socials](https://www.google.com/search?q=god%27s+eye+view)
+▶️ **From the project behind the viral God's Eye View series** _(formerly WorldView)_ — [5M+ on YouTube](https://youtube.com/playlist?list=PL6qSg2I-7_koPbDnSMo0QeeHX_RknA2uv&si=nBGYMoHWQw41v93Q) · [25M+ across socials](https://www.google.com/search?q=god%27s+eye+view)
[](https://x.com/bilawalsidhu/status/2093798887815348521)
@@ -24,7 +24,7 @@ Photorealistic 3D globe. Live aircraft, ships, satellites, earthquakes, traffic,
**[#8 Product of the Day](https://www.producthunt.com/products/god-s-eye-view?launch=god-s-eye-view)** · Hunted by Chris Messina, creator of the hashtag
-*“pretty cool”* — [Brendan Eich](https://x.com/BrendanEich/status/2094592096401490266), creator of JavaScript and co-founder of Mozilla and Brave · Featured on **[Pinokio](https://pinokio.co/posts/01m1m4p9xxm3qw7dnnpj2wr93g)**
+_“pretty cool”_ — [Brendan Eich](https://x.com/BrendanEich/status/2094592096401490266), creator of JavaScript and co-founder of Mozilla and Brave · Featured on **[Pinokio](https://pinokio.co/posts/01m1m4p9xxm3qw7dnnpj2wr93g)**
⚡ **Start without API keys.** Install with [Pinokio](https://pinokio.co/apps/github-com-bilawalsidhu-gods-eye-view) or run locally from the terminal. Add optional keys inside the app. **[→ Quick Start](#-quick-start)**
@@ -146,10 +146,10 @@ reopens the same panel.
- **Where keys land:** Pinokio → the app's ignored `pinokio/ENVIRONMENT`; a
terminal clone → the repo-root `.env`. Either file is made owner-only
- *before* a secret is written into it. These are local plaintext files,
+ _before_ a secret is written into it. These are local plaintext files,
excluded from Git; the app uses your keys to contact the providers.
- **Keys you already have stay yours:** values from your shell or the macOS
- Keychain show as *configured externally* and are read-only to the panel.
+ Keychain show as _configured externally_ and are read-only to the panel.
- **What to get first:** the free [Cesium ion](https://cesium.com/ion) token
(eligible personal, non-commercial use; current terms and quotas apply) for
photorealistic 3D and world terrain; a Google Maps key only for the
@@ -188,7 +188,7 @@ Choose a first-run mission, or try these in order. The GIFs show Google Photorea

-4. **Look through a public camera.** Turn on **CCTV** over Austin, London, California, or Finland. The feeds aren't webcam embeds — they project *into* the 3D city. Cycle coverage to **VIEWSHED** and every camera draws its estimated coverage volume — where it reaches, and where it goes blind.
+4. **Look through a public camera.** Turn on **CCTV** over Austin, London, California, or Finland. The feeds aren't webcam embeds — they project _into_ the 3D city. Cycle coverage to **VIEWSHED** and every camera draws its estimated coverage volume — where it reaches, and where it goes blind.

@@ -200,8 +200,8 @@ Choose a first-run mission, or try these in order. The GIFs show Google Photorea

-7. **Talk to it** *(needs an OpenAI key)*: *"Take me to LAX and select the nearest airborne aircraft."*
-8. **Come home.** Hit **Reset Globe** — or just say *"zoom out to a globe view."*
+7. **Talk to it** _(needs an OpenAI key)_: _"Take me to LAX and select the nearest airborne aircraft."_
+8. **Come home.** Hit **Reset Globe** — or just say _"zoom out to a globe view."_
**Keyboard:** `1`–`7` visual styles · `H` HUD · `D` detection · `C` cockpit · `Esc` out.
@@ -219,7 +219,7 @@ The cockpit even carries its own briefing strip: nearby live signals, regional h

-*Why cockpit mode exists: you're riding a real aircraft over real terrain — and you get to pick which sensor you see the world through.*
+_Why cockpit mode exists: you're riding a real aircraft over real terrain — and you get to pick which sensor you see the world through._
---
@@ -229,19 +229,21 @@ The cockpit even carries its own briefing strip: nearby live signals, regional h
Click **GEV MIC**, grant the microphone, and just talk. This is more than a voice-controlled remote:
-- **🧠 It knows what it's looking at.** The agent pulls live scene context before answering — including coordinates, street names, active layers, and view scale. Ask *"what city is this?"* mid-flight and it knows.
-- **🎯 Entity Q&A.** Click any plane, ship, or datacenter and ask *"what's this?"* It answers using the object's live telemetry.
+- **🧠 It knows what it's looking at.** The agent pulls live scene context before answering — including coordinates, street names, active layers, and view scale. Ask _"what city is this?"_ mid-flight and it knows.
+- **🎯 Entity Q&A.** Click any plane, ship, or datacenter and ask _"what's this?"_ It answers using the object's live telemetry.
- **👁️ Visual grounding.** At street level, it reads a viewport screenshot to identify legible signage and building names, and is instructed never to hallucinate labels.
-- **🎬 Cinematic framing.** *"Show me the planes overhead"* pulls the camera back, angles it, and frames the live traffic like a director.
+- **🎬 Cinematic framing.** _"Show me the planes overhead"_ pulls the camera back, angles it, and frames the live traffic like a director.
- **🔒 Honest and secure.** The agent only confirms actions that succeeded. Your `OPENAI_API_KEY` never touches the browser; the client only gets a short-lived session token.
Twenty-eight tools, four jobs — the commands below come straight from the product's voice test suite and tool playbook:
**🎥 Direct it** — drone-operator camera verbs:
-> 🗣️ *"Take me to Tokyo."* · *"Orbit around this area slowly."* · *"Draw the walking route from the Capitol to Zilker Park."* → *"Fly the route we just drew."* · *"Zoom out to a globe view."*
+
+> 🗣️ _"Take me to Tokyo."_ · _"Orbit around this area slowly."_ · _"Draw the walking route from the Capitol to Zilker Park."_ → _"Fly the route we just drew."_ · _"Zoom out to a globe view."_
**🖊️ Annotate it** — a whiteboard over the real world:
-> 🗣️ *"Outline the state of Texas."* · *"Annotate the Texas State Capitol and its grounds"* — it draws the **actual enclosing boundary**, not a circle. · *"How far is the Eiffel Tower from the Louvre?"* — a connector arrow appears and it speaks the distance. Everything persists until you say *"clear the map."*
+
+> 🗣️ _"Outline the state of Texas."_ · _"Annotate the Texas State Capitol and its grounds"_ — it draws the **actual enclosing boundary**, not a circle. · _"How far is the Eiffel Tower from the Louvre?"_ — a connector arrow appears and it speaks the distance. Everything persists until you say _"clear the map."_
**✍️ Or draw it yourself** — DISPLAY ▸ **Draw**: pick Area, Line or Pin, click the vertices on the real world, double-click to finish, label it. Same whiteboard, same persistence, no microphone needed.
@@ -250,52 +252,56 @@ Twenty-eight tools, four jobs — the commands below come straight from the prod

**🔎 Interrogate it** — analyst queries against the live layers:
-> 🗣️ *"How many flights are over Texas right now?"* · *"Which ships are headed to Oakland?"* · *"What is the biggest fire near Los Angeles?"* · *"Is anything flying above forty thousand feet?"* · *"When does the ISS pass over next?"*
+
+> 🗣️ _"How many flights are over Texas right now?"_ · _"Which ships are headed to Oakland?"_ · _"What is the biggest fire near Los Angeles?"_ · _"Is anything flying above forty thousand feet?"_ · _"When does the ISS pass over next?"_
**🎛️ Operate it** — the whole console, hands-free:
-> 🗣️ *"Switch to night vision and turn on the flights layer."* · *"Turn on the camera viewsheds."* · *"Play a news radio station near Austin."* · *"Track that plane."* → *"Enter Cockpit."*
+
+> 🗣️ _"Switch to night vision and turn on the flights layer."_ · _"Turn on the camera viewsheds."_ · _"Play a news radio station near Austin."_ · _"Track that plane."_ → _"Enter Cockpit."_
**And the rapid-fire tier** — one sentence each:
-> 🗣️ *"Show me global infrastructure."* (stages the layers and pulls back to the globe) · *"Play Orbital Watch."* (a full cinematic scene) · *"Set detection density to fifty percent."* · *"Next contact — helicopters only."* (mid-cockpit) · *"Show me space missions."* · *"Switch to OSM."* · *"Sharpen the image a touch."* · *"Switch to the tactical layout."* · *"What's turned on right now?"*
+
+> 🗣️ _"Show me global infrastructure."_ (stages the layers and pulls back to the globe) · _"Play Orbital Watch."_ (a full cinematic scene) · _"Set detection density to fifty percent."_ · _"Next contact — helicopters only."_ (mid-cockpit) · _"Show me space missions."_ · _"Switch to OSM."_ · _"Sharpen the image a touch."_ · _"Switch to the tactical layout."_ · _"What's turned on right now?"_

-*Ask for radio near anywhere and the globe starts broadcasting — every station is a real place you can fly to.*
+_Ask for radio near anywhere and the globe starts broadcasting — every station is a real place you can fly to._
---
## 🛰️ What's on the Globe
-Fourteen layers and map sources. **Twelve have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.)
+Fifteen layers and map sources. **Thirteen have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.)
-| Layer | What you get | Source | Auth |
-|-------|--------------|--------|------|
-| 🗺️ **Map Stack** | Esri satellite imagery, Google Photorealistic 3D, OSM, plus additional ion-hosted stacks | Esri / Google / Ion / OSM | 🟢 Esri satellite + OSM · 🟡 ion-hosted Google 3D + world terrain · 🔴 direct Google + place search |
-| ✈️ **Live Flights** | 11,000+ live aircraft + route history | OpenSky + adsb.lol | 🟢 (🟡 optional for more polling credits) |
-| 🎖️ **Military Flights** | ADS-B military traffic in amber | adsb.lol | 🟢 |
-| 🚢 **Live Vessels** | Thousands of ships worldwide | AISStream | 🟡 |
-| 🛰️ **Satellites** | 838-object catalog, color-coded by class with a live legend — the **DENSE** chip drops in the whole Starlink shell | CelesTrak | 🟢 |
-| 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 |
-| 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds |
-| 📹 **CCTV Mesh** | ~3,600 public cameras projected *into* the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · New South Wales (Live Traffic NSW) · Calgary. Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
-| 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 |
-| 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 |
-| 🧭 **Directions** | Click A and B on the globe for a street-following drive, walk or cycle route draped on the terrain with turn-by-turn steps — then FLY the camera along it. No key, no geocoder, no mic | OSRM on FOSSGIS servers (OpenStreetMap) | 🟢 |
-| 🔥 **Active Fires** | Live NASA FIRMS detections, trailing 24h | NASA FIRMS | 🟡 |
-| 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🟡 optional token raises the allowance) |
-| 🎖️ **Mapped Installations** | Viewport-bounded military-site context from community mapping — incomplete by nature, and labeled that way | OpenStreetMap | 🟢 |
+| Layer | What you get | Source | Auth |
+| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------- |
+| 🗺️ **Map Stack** | Esri satellite imagery, Google Photorealistic 3D, OSM, plus additional ion-hosted stacks | Esri / Google / Ion / OSM | 🟢 Esri satellite + OSM · 🟡 ion-hosted Google 3D + world terrain · 🔴 direct Google + place search |
+| ✈️ **Live Flights** | 11,000+ live aircraft + route history | OpenSky + adsb.lol | 🟢 (🟡 optional for more polling credits) |
+| 🎖️ **Military Flights** | ADS-B military traffic in amber | adsb.lol | 🟢 |
+| 🚢 **Live Vessels** | Thousands of ships worldwide | AISStream | 🟡 |
+| 🛰️ **Satellites** | 838-object catalog, color-coded by class with a live legend — the **DENSE** chip drops in the whole Starlink shell | CelesTrak | 🟢 |
+| 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 |
+| 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds |
+| 📹 **CCTV Mesh** | ~3,600 public cameras projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · New South Wales (Live Traffic NSW) · Calgary. Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
+| 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 |
+| 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 |
+| 🧭 **Directions** | Click A and B on the globe for a street-following drive, walk or cycle route draped on the terrain with turn-by-turn steps — then FLY the camera along it. No key, no geocoder, no mic | OSRM on FOSSGIS servers (OpenStreetMap) | 🟢 |
+| 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland | Operator GTFS-Realtime feeds | 🟢 |
+| 🔥 **Active Fires** | Live NASA FIRMS detections, trailing 24h | NASA FIRMS | 🟡 |
+| 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🟡 optional token raises the allowance) |
+| 🎖️ **Mapped Installations** | Viewport-bounded military-site context from community mapping — incomplete by nature, and labeled that way | OpenStreetMap | 🟢 |
**The basemap ladder — what each tier buys you:**
-| You have | The globe you get |
-|---|---|
-| 🟢 Nothing | Esri World Imagery satellite basemap + keyless terrain, in 2D. OSM takes over automatically if Esri is unreachable; if terrain is unavailable the globe continues without it |
-| 🟡 A free Cesium ion token | **Google Photorealistic 3D cities** and world terrain — eligible personal, non-commercial use; current ion terms and quotas apply |
-| 🔴 A Google Maps key | The same 3D direct from Google, plus in-app place search — the billing-enabled, metered route |
+| You have | The globe you get |
+| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| 🟢 Nothing | Esri World Imagery satellite basemap + keyless terrain, in 2D. OSM takes over automatically if Esri is unreachable; if terrain is unavailable the globe continues without it |
+| 🟡 A free Cesium ion token | **Google Photorealistic 3D cities** and world terrain — eligible personal, non-commercial use; current ion terms and quotas apply |
+| 🔴 A Google Maps key | The same 3D direct from Google, plus in-app place search — the billing-enabled, metered route |

-*The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED ESTIMATE`, scrubbable 0.25×–4×.*
+_The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED ESTIMATE`, scrubbable 0.25×–4×._
**Also on the globe:** neighborhood overlays · an optional cockpit WX cloud effect. **Bundled static infrastructure:** Datacenters (4,351), Dams (704), and Submarine Cables (712).
@@ -309,33 +315,33 @@ Fourteen layers and map sources. **Twelve have a keyless path.** Some offer addi
Once the basics click, run these:
-| Mission | How |
-|---|---|
-| **🚁 Ask the planet** | *"Why are all these military helicopters flying in circles?"* Select a military track — it silently backfills ~24 h of real trace history — and see what it's been doing, resolved as stacked 3D loops. |
-| **✈️ Final approach** | Click-track an airliner lining up for a runway, hop into the **cockpit**, and ride it down. |
-| **🌃 Night watch** | Fly to your own city, switch to **NVG**, and let the detection mesh and HUD read the scene. |
-| **🚢 Port call** | Vessels on over the Port of Long Beach. Click a tanker for its tactical card and wake trail — then hit **NEAREST** in the CCTV panel and look at the same water through a public camera. |
-| **📻 Tokyo FM** | Orbit Shibuya with the **Radio** layer on — then drag the analog tuner needle: every position snaps to a real station and the globe flies to whoever's broadcasting. |
-| **🔥 Fire line** | FIRMS over California. Click a detection — the camera dives to it — read the intensity, then hit **NEAREST** in the CCTV panel for a ground view. |
-| **🚶 Ask for a walking route** *🎙️* | Tell the world where you want to go and watch a real street-following route trace itself through the 3D city — then *"fly it"*: banked turns, eased ends, a camera that leads the path like a drone shot. |
-| **📏 Measure LAX to DFW** *🎙️* | *"How far is LAX from DFW?"* — an arrow spans the country, the distance lands in the caption, and the endpoints stay pinned to the real world as you orbit. |
-| **🚀 Launch replay** | Open **Space Missions**, pick a launch from the last 30 days, and ride the T-minus countdown through ascent to orbit — scrub it at 0.25×–4×. Labeled `RECONSTRUCTED ESTIMATE`, because it is one. |
-| **🪦 Walk the boneyard** | Fly from regional context down into dense, fully resolved rows of retired aircraft. |
-| **🏗️ Orbit Three Gorges** | Sweep the dam and its terrain at a glance — then flip on the **Dams** layer and find 703 more. |
+| Mission | How |
+| ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **🚁 Ask the planet** | _"Why are all these military helicopters flying in circles?"_ Select a military track — it silently backfills ~24 h of real trace history — and see what it's been doing, resolved as stacked 3D loops. |
+| **✈️ Final approach** | Click-track an airliner lining up for a runway, hop into the **cockpit**, and ride it down. |
+| **🌃 Night watch** | Fly to your own city, switch to **NVG**, and let the detection mesh and HUD read the scene. |
+| **🚢 Port call** | Vessels on over the Port of Long Beach. Click a tanker for its tactical card and wake trail — then hit **NEAREST** in the CCTV panel and look at the same water through a public camera. |
+| **📻 Tokyo FM** | Orbit Shibuya with the **Radio** layer on — then drag the analog tuner needle: every position snaps to a real station and the globe flies to whoever's broadcasting. |
+| **🔥 Fire line** | FIRMS over California. Click a detection — the camera dives to it — read the intensity, then hit **NEAREST** in the CCTV panel for a ground view. |
+| **🚶 Ask for a walking route** _🎙️_ | Tell the world where you want to go and watch a real street-following route trace itself through the 3D city — then _"fly it"_: banked turns, eased ends, a camera that leads the path like a drone shot. |
+| **📏 Measure LAX to DFW** _🎙️_ | _"How far is LAX from DFW?"_ — an arrow spans the country, the distance lands in the caption, and the endpoints stay pinned to the real world as you orbit. |
+| **🚀 Launch replay** | Open **Space Missions**, pick a launch from the last 30 days, and ride the T-minus countdown through ascent to orbit — scrub it at 0.25×–4×. Labeled `RECONSTRUCTED ESTIMATE`, because it is one. |
+| **🪦 Walk the boneyard** | Fly from regional context down into dense, fully resolved rows of retired aircraft. |
+| **🏗️ Orbit Three Gorges** | Sweep the dam and its terrain at a glance — then flip on the **Dams** layer and find 703 more. |
-*🎙️ = voice missions — they need an OpenAI key.*
+_🎙️ = voice missions — they need an OpenAI key._

-*Ask the planet: a military contact's last ~24 hours of real trace history, resolved as stacked 3D loops.*
+_Ask the planet: a military contact's last ~24 hours of real trace history, resolved as stacked 3D loops._

-*"Draw the walking route… now fly it" — banked turns, eased ends, the camera leading the path like a drone shot.*
+_"Draw the walking route… now fly it" — banked turns, eased ends, the camera leading the path like a drone shot._

-*Walk the boneyard: rows of retired airframes, fully resolved in 3D.*
+_Walk the boneyard: rows of retired airframes, fully resolved in 3D._
---
@@ -343,10 +349,10 @@ Once the basics click, run these:
How the globe handles live data:
-- **World-stable icons.** Aircraft and ships point along their *true real-world heading* at every camera angle — tracked or not, looking straight down or across the horizon — via per-frame screen-space course projection. No spinning, no viewport-locking.
+- **World-stable icons.** Aircraft and ships point along their _true real-world heading_ at every camera angle — tracked or not, looking straight down or across the horizon — via per-frame screen-space course projection. No spinning, no viewport-locking.
- **Smooth motion from choppy data.** Live feeds arrive every 15–30s; the globe renders one interval behind real time and interpolates between known fixes. Dead reckoning fills the gaps.
- **Honest satellites.** SGP4 propagation with orbit rings that stay locked to their satellites via GMST realignment — no drift, no per-second flicker.
-- **Sits on the real ground.** Entity heights run through a real vertical datum — geoid-aware, sampled against the *rendered* terrain mesh — so aircraft park on aprons and cameras stand on street corners instead of floating.
+- **Sits on the real ground.** Entity heights are aligned to work with Google 3D tiles, so aircraft park on aprons and cameras stand on street corners instead of floating.
- **Caching and request budgets.** An OpenSky credit governor, a TomTom daily tile budget, and disk-cached TLEs reduce repeated requests. These controls do not replace provider quotas or billing controls.
- **Server-side credentials.** Every API that touches a private key (OpenAI, AISStream, OpenSky OAuth, camera frames) is brokered through a hardened server-side proxy with SSRF protection, response caps, and sanitized errors. The only keys the browser sees are Google Maps and Cesium ion (restrict both at the provider).
- **No framework.** Vanilla JavaScript, **CesiumJS**, and **Vite** — plus **Google Photorealistic 3D Tiles** for the planet and the **OpenAI Realtime API** for voice. Fast to read, fast to hack on.
@@ -382,25 +388,25 @@ and configuration details.
Six keys. Four have a free tier, and the two 🔴 ones are metered:
-| | Key | Why | Get it |
-|---|-----|-----|--------|
-| 🟡 | **Cesium ion** | 🗺️ Google Photorealistic 3D, world terrain, and additional ion-hosted imagery stacks. The free Community plan is for eligible individual, personal/non-commercial use and has quotas | [cesium.com/ion](https://cesium.com/ion) — use a public `assets:read` token and check current [pricing/eligibility](https://cesium.com/platform/cesium-ion/pricing/) |
-| 🔴 | **Google Maps** | Direct Google Photorealistic 3D + Google place search ([Map Tiles API](https://developers.google.com/maps/documentation/tile)) | [Google Cloud Console](https://console.cloud.google.com/) — URL-restrict it |
-| 🔴 | **OpenAI** | 🎙️ The voice experience + AI HUD summary. The mini model works; the standard model is noticeably smarter. Want Gemini or another provider behind the mic? PRs welcome | [platform.openai.com](https://platform.openai.com) — metered, see costs below |
-| 🟡 | **AISStream** | 🚢 Live global ships | [aisstream.io](https://aisstream.io) — free signup |
-| 🟡 | **NASA FIRMS** | 🔥 Live active fires | [firms.modaps.eosdis.nasa.gov](https://firms.modaps.eosdis.nasa.gov/api/map_key/) — free |
-| 🟡 | **TomTom** | 🚦 Live flow speeds and congestion colors for the simulated traffic layer | [developer.tomtom.com](https://developer.tomtom.com) — free tier available |
+| | Key | Why | Get it |
+| --- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| 🟡 | **Cesium ion** | 🗺️ Google Photorealistic 3D, world terrain, and additional ion-hosted imagery stacks. The free Community plan is for eligible individual, personal/non-commercial use and has quotas | [cesium.com/ion](https://cesium.com/ion) — use a public `assets:read` token and check current [pricing/eligibility](https://cesium.com/platform/cesium-ion/pricing/) |
+| 🔴 | **Google Maps** | Direct Google Photorealistic 3D + Google place search ([Map Tiles API](https://developers.google.com/maps/documentation/tile)) | [Google Cloud Console](https://console.cloud.google.com/) — URL-restrict it |
+| 🔴 | **OpenAI** | 🎙️ The voice experience + AI HUD summary. The mini model works; the standard model is noticeably smarter. Want Gemini or another provider behind the mic? PRs welcome | [platform.openai.com](https://platform.openai.com) — metered, see costs below |
+| 🟡 | **AISStream** | 🚢 Live global ships | [aisstream.io](https://aisstream.io) — free signup |
+| 🟡 | **NASA FIRMS** | 🔥 Live active fires | [firms.modaps.eosdis.nasa.gov](https://firms.modaps.eosdis.nasa.gov/api/map_key/) — free |
+| 🟡 | **TomTom** | 🚦 Live flow speeds and congestion colors for the simulated traffic layer | [developer.tomtom.com](https://developer.tomtom.com) — free tier available |

-*What the TomTom key buys you: rush-hour density painted on the city — then dive from the jam straight into the camera watching it.*
+_What the TomTom key buys you: rush-hour density painted on the city — then dive from the jam straight into the camera watching it._
### Cherry on top
-| | Key | Why | Get it |
-|---|-----|-----|--------|
-| 🟡 | **OpenSky** | ✈️ More flight-polling credits (🟢 anonymous works without) | [opensky-network.org](https://opensky-network.org) |
-| 🟡 | **Launch Library 2** | 🚀 Higher space-missions request allowance (🟢 works without) | [thespacedevs.com](https://thespacedevs.com) |
+| | Key | Why | Get it |
+| --- | -------------------- | ------------------------------------------------------------- | -------------------------------------------------- |
+| 🟡 | **OpenSky** | ✈️ More flight-polling credits (🟢 anonymous works without) | [opensky-network.org](https://opensky-network.org) |
+| 🟡 | **Launch Library 2** | 🚀 Higher space-missions request allowance (🟢 works without) | [thespacedevs.com](https://thespacedevs.com) |
Add these if you need higher polling allowances.
@@ -437,12 +443,12 @@ OpenSky can run fully anonymous (`OPENSKY_AUTH_MODE=anon`), or import OAuth cred
Honest numbers, roughly, as of mid-2026 — always check the provider pricing pages:
-| | Cost reality |
-|---|---|
-| **🟢 Most layers** | **$0, no signup.** OpenSky anon, USGS, CelesTrak, adsb.lol, city CCTV, Radio Browser, GBFS, Launch Library 2, bundled datasets. |
-| **🟡 The free-key tier** | **$0 with a signup.** AISStream, FIRMS, TomTom, OpenSky, plus Cesium ion for eligible personal/non-commercial use. Provider quotas and eligibility still apply. |
-| **🗺️ Google 3D tiles** | **Free through an eligible Cesium ion Community account within its quota; metered through a direct Google key.** Use the direct route for GEV place search or commercial deployment, verify current provider terms, and set budget alerts where billing is enabled. |
-| **🔴 OpenAI voice** | **The one that costs real money — so the app meters it for you.** Realtime audio runs a few cents per active minute; an evening of heavy use is single-digit dollars. A live session-spend readout sits next to the mic, with an STD/MINI model toggle, a $2 warning, and a **$5 hard cap that ends the session**. The voice context window is kept deliberately short too. |
+| | Cost reality |
+| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **🟢 Most layers** | **$0, no signup.** OpenSky anon, USGS, CelesTrak, adsb.lol, city CCTV, Radio Browser, GBFS, Launch Library 2, bundled datasets. |
+| **🟡 The free-key tier** | **$0 with a signup.** AISStream, FIRMS, TomTom, OpenSky, plus Cesium ion for eligible personal/non-commercial use. Provider quotas and eligibility still apply. |
+| **🗺️ Google 3D tiles** | **Free through an eligible Cesium ion Community account within its quota; metered through a direct Google key.** Use the direct route for GEV place search or commercial deployment, verify current provider terms, and set budget alerts where billing is enabled. |
+| **🔴 OpenAI voice** | **The one that costs real money — so the app meters it for you.** Realtime audio runs a few cents per active minute; an evening of heavy use is single-digit dollars. A live session-spend readout sits next to the mic, with an STD/MINI model toggle, a $2 warning, and a **$5 hard cap that ends the session**. The voice context window is kept deliberately short too. |
Google's direct 3D route is surprisingly generous: the first 1,000 Photorealistic
3D Tiles sessions each month are currently free, and one root request supports
@@ -500,9 +506,9 @@ First — thank you. To everyone who watched the God-view demos and went off to
So here it is. Step inside the spy-thriller cockpit — except the data is real — and let's turn this into our shared sandbox for making sense of the world, and have fun doing it. This repo is the baseline, it stays open, and the whole point is for you to break things and bolt on layers we haven't thought of yet.
-One heads-up from the inside: build in this space for a week and you learn that **the present is the cheap part**. The moment you try to go back in time — tiling, serving, and scrubbing *what happened* and *what changed* at any real resolution — the data gets expensive and the compute gets brutal. That's the long game.
+One heads-up from the inside: build in this space for a week and you learn that **the present is the cheap part**. The moment you try to go back in time — tiling, serving, and scrubbing _what happened_ and _what changed_ at any real resolution — the data gets expensive and the compute gets brutal. That's the long game.
-**Update — a hosted version is coming.** We originally planned to keep this repository as the open-source client and build a separate professional product. Then the launch happened, and the loudest request wasn't another feature — it was *"just give me a link."* So we're building an official hosted God's Eye View at [Halfpixel](https://halfpixel.ai): no installation, just open it in your browser. The hosted version is the easiest way into this open-source project. More soon.
+**Update — a hosted version is coming.** We originally planned to keep this repository as the open-source client and build a separate professional product. Then the launch happened, and the loudest request wasn't another feature — it was _"just give me a link."_ So we're building an official hosted God's Eye View at [Halfpixel](https://halfpixel.ai): no installation, just open it in your browser. The hosted version is the easiest way into this open-source project. More soon.
---
diff --git a/SECURITY.md b/SECURITY.md
index 9bf718b74..a21ea99a4 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -64,6 +64,7 @@ The data proxies under `server/providers/` are written so the browser cannot tur
- **No arbitrary-URL fetching.** The CCTV frame proxy fetches only server-registered camera/frame URLs — clients cannot pass an upstream URL to fetch (SSRF mitigation). Other proxies target fixed upstream hosts.
- **Radio is not an audio relay.** `/api/radio/stations` contacts only allowlisted Radio Browser HTTPS hosts and paths, rejects redirects, rejects any hostname with a loopback/private/link-local/metadata/non-public A or AAAA result, and pins each TLS connection to a validated address. It returns normalized public HTTPS stream URLs; `/api/radio/click/:uuid` applies the same destination policy and accepts only station IDs from the current bounded catalog. The browser then connects directly to the broadcaster after an explicit playback action, so the broadcaster sees the listener's IP address. GEV never proxies, caches, records, or redistributes audio.
- **No verbatim client headers upstream.** The CCTV media route is the one route that relays a request header (`Range`, for video seeking). It is parsed and canonicalized before it is forwarded: one `bytes=` range only, with every accepted form — explicit span, open-ended and suffix — bounded to the same 64 MiB ceiling the relay applies to a declared response body. Multi-range, malformed, inverted and non-`bytes` values are dropped and the request proceeds without a `Range`, as RFC 7233 §3.1 prescribes. Bounding the request bounds what is asked for: a response that declares no length — live streamed media, or a chunked body from an upstream that ignores the `Range` — has no ceiling. An upstream request the browser has stopped waiting for is cancelled rather than left running, whether the viewer leaves before the headers arrive or during the body.
+- **Transit fetches registered feeds only.** `/api/transit/vehicles/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/cameraSensitivity.js b/src/data/cameraSensitivity.js new file mode 100644 index 000000000..87933dc9e --- /dev/null +++ b/src/data/cameraSensitivity.js @@ -0,0 +1,93 @@ +// src/data/cameraSensitivity.js +/** + * Shared claims on `camera.percentageChanged`. + * + * `camera.changed` fires once the view has moved by this fraction, and it is a + * single number on the camera that every listener in the app shares. Several + * layers want it finer than the default — traffic, bikeshare and transit all + * ask for 0.05 so they notice they have entered a coverage area — and each of + * them used to write the number directly and hand back whatever it remembered. + * + * That cannot work, because a number is not an owner. Two layers asking for the + * same 0.05 write the same value, so when the first one leaves it sees its own + * number still in place, concludes nothing else has claimed it, and restores + * the coarse default out from under a layer that is still running. The symptom + * is a camera-driven layer that quietly stops noticing the camera: Transit is + * turned off and Bikeshare stops loading new cities until something else nudges + * it. + * + * So ownership is tracked explicitly. Claims are named, the camera gets the + * finest value anyone has asked for, and the original value returns only when + * the last claim is gone. Claiming twice under one name replaces that claim. + * + * This is bookkeeping, not policy: it never decides what a layer should ask + * for, only that nobody's answer is taken away while they still need it. + */ + +/** @type {WeakMap