feat: add Nepal flood incident scene

This commit is contained in:
manjunath22466 committed 2026-09-15 19:48:11 +05:30
1 parent 76c20be277
commit 60768ccc0b
48 files changed
+15431 -161

No files matched your search

+28
View File
@@ -1,5 +1,14 @@
# Changelog
- Cancel the Nepal Upper Valley locator's pending approach and orbit on scene Stop, replacement, seek, and teardown; late camera callbacks cannot take over a newer shot.
- Keep completed flood history visible when later Nepal media-only shots arrive and reveal their source cards.
- Reconcile an already-playing Nepal video when the YouTube API attaches, so missed playback notifications cannot truncate the seven-second clip or replay an already-ended clip.
- Clamp the Nepal flood trail and surge marker to the active terrain or photoreal surface so refined 3D tiles cannot bury the path.
- Preserve Nepal shot camera and map ownership through the public layer lifecycle; passive restoration does not start standalone playback.
- Widen opaque sensor halos to 3 display pixels with a 30 px fleet core while retaining at least 60% opaque core coverage, adding contrast margin through thermal blur and bloom on bright roofs.
- Exclude Noir's intentionally vignetted outer field from transit core measurements and add separate Boston live oblique selection/readability coverage.
@@ -12,6 +21,25 @@
- Reject isolated transit history outliers without changing the active playback bracket; rejected live reports retain the last accepted route, mode and detection text.
- Isolate optional scene-card presentation callbacks from the shared overlay projection path, preserving ordinary-layer allocation budgets and recovering from failed animation callbacks.
- Rename scene shots inline with a double-click; Enter or focus loss saves, and Escape cancels.
- Stop scene playback and delayed media when its event layer is explicitly closed; reject stale seek and replay completions. Ship only the Nepal incident default, without a separate reconstruction recipe.
- Keep Nepal flood and locator components available through Scenes without separate entries in Data Layers.
- Start the shared Nepal flood route at the Debris-Dammed Lake river reach, removing the earlier upstream section from overview paths.
- Remove the redundant WITNESS panel shortcut; retain per-shot source media and Open Original links.
- Follow the Mailung Bazzar, Dandaguan video's source clock through 0:07 and advance after its brief fade-out, including older saved scenes. Bound delayed or blocked playback and cancel late provider callbacks on Stop or replacement.
- Keep the completed flood trail visible during the Debris-Dammed Lake to Second landslide camera handoff, without revealing the next reach early.
- Preserve loaded base imagery when successive scene shots use the same provider, avoiding a bare-globe flash at shot handoffs.
- Add the Nepal Flood Incident scene with geographic labels, synchronized event controls, linked witness sources, and Vantor comparison imagery over Esri. Nepal uses Google 3D when available and falls back to Esri with keyless terrain without rewriting saved shots. Bundled event imagery and derived data retain their separate non-commercial terms.
- Add an optional Nominatim geocoding adapter with configurable search/reverse endpoints, cancellation, bounded responses and retryable upstream errors. Extract portable response-reading and Overpass lexical helpers while retaining existing server exports.
- Expose reference feed factories independently of standalone catalog construction; preserve source choices and asset attribution.
+3 -1
View File
@@ -87,10 +87,12 @@ How to read this:
---
## Bundled snapshots (committed under `src/data/local_data/`)
## Bundled snapshots
Static datasets shipped in the repo for an out-of-the-box experience. **None are MIT** — each keeps its own license (see the carve-out in [LICENSE](LICENSE)). Each folder also has its own provenance README.
The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `public/events/`, contains Vantor imagery crops and a GeoPera-derived river centerline under **CC BY-NC 4.0**, separately from the MIT code. The derived coordinate dataset in `src/data/bhoteKoshiFloodPath.js` has the same non-commercial restriction and is compiled into the Nepal scene. Commercial users must obtain separate permission or exclude both the event pack and that source-derived dataset from their source and build. Deleting only `public/events/bhote-koshi-2026/` does not remove all restricted data; excluding the scene also requires removing its registrations and imports before building. Linked witness posts and geolocation-map records retain their owners' terms; no social-media clips or cached posters are bundled. Other snapshots below are under `src/data/local_data/`.
| Dataset | Folder | License | Commercial use? | Attribution |
| --------------------------------------------------------------------------- | --------------------------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | --------------------------------------------------------------------------- |
| **Datacenters** (~4.3K) | `datacenters/` | **ODbL 1.0** (OpenStreetMap extract) | ✅ (attribution + share-alike on data) | "© OpenStreetMap contributors" |
+8
View File
@@ -38,6 +38,14 @@ remain subject to their own licenses and terms. Notably:
Open Database License (ODbL) 1.0 — attribution + share-alike on the data.
- NASA FIRMS active-fire snapshot: CC0 / U.S. public domain (citation
requested).
- Bhote Koshi event imagery and derived river centerline
(public/events/bhote-koshi-2026/ and the coordinate dataset in
src/data/bhoteKoshiFloodPath.js): Vantor / GeoPera, CC BY-NC 4.0.
NOT for commercial use without separate permission. Source-linked witness
posts retain their owners' terms; no witness clips are bundled. See the
folder README for attribution and modification notices. The derived
coordinates remain third-party data even though they are embedded in a
JavaScript source file; the MIT grant does not cover that dataset.
Live sources (Google Maps, OpenSky, adsb.lol, AISStream, CelesTrak, USGS,
OSM Overpass, City of Austin, GBFS) are fetched at runtime under each
+25 -1
View File
@@ -1,5 +1,29 @@
# God's Eye View Current State
Nepal flood lines classify the active terrain or photoreal surface, and the surge marker follows that surface. Geographic route coordinates are unchanged; dynamic lines do not rely on unsupported depth-failure materials. Later media-only shots keep their completed source-path history throughout arrival and card reveal.
Scene Stop, replacement, seek, and teardown revoke the Upper Valley locator's delayed camera approach and orbit while preserving its visible callout. A revoked timer or camera callback cannot start another flight or interfere with a newer shot.
Nepal scene ownership follows the parameters prepared by the public layer manager before activation, without depending on enable-time origin metadata. Enabling a scene beat preserves its selected map and camera; passive restoration does not schedule standalone playback.
Animated scene cards sample their optional scale, opacity and leader callbacks once per projected frame. A failed callback suppresses that frame and can recover on the next one; ordinary layers keep the shared projection path without animation evaluation.
Nepal Flood Incident is accessed through Scenes. Its flood and locator components remain registered for scene playback but are hidden from the Data Layers menu. Closing the event stops the active scene, including pending loads and media. The default catalog does not install a separate reconstruction scene; existing saved scenes remain untouched.
Double-click a shot name to edit it inline. Enter or moving focus saves a nonempty name, Escape cancels, and saved names persist with the scene project.
The Nepal overview route and lake-shot trail share the river-centerline starting point nearest Debris-Dammed Lake. No earlier upstream section is included; evidence pins remain separate from the schematic river geometry.
The event panel omits the WITNESS shortcut. Shot media, source attribution, Open Original links and corridor navigation remain available.
The Mailung Bazzar, Dandaguan YouTube embed follows the provider's playback clock through 0:07, then the scene advances after its 0.65-second media exit. The controller reconciles provider state and source time when the API attaches and while waiting for playback, including missed PLAYING notifications and an already-ended clip. Startup is bounded to five seconds, and stalled playback has a bounded timeout; unavailable or blocked media cannot strand the scene. The authored estimate remains seven seconds plus the exit, replacing older saved holds at runtime without rewriting them. Stop, replacement and teardown revoke pending playback callbacks.
Persistent media-led Nepal shots preserve their completed upstream flood path during camera travel. The next reach still reveals after arrival; direct loads reconstruct the prefix and backward replay trims downstream history.
Repeated map-stack requests retain the live imagery layer when its provider is unchanged. Scene handoffs keep loaded tiles while real source changes, switch cancellation, and tile-error fallbacks retain their existing behavior.
Nepal Flood Incident is available in Scenes, with a 25-shot sequence, geographic labels, an event panel, scene scrubbing and shot playback. Comparison shots use Esri beneath Vantor imagery. Other Nepal shots use Google 3D when its tileset is available and fall back to Esri otherwise; without an ion token, the existing map stack uses Re:Earth terrain. Saved shots retain their authored camera and map preferences across keyed and keyless runtimes. The bundled pack contains historical 2021 and post-event 2026 imagery, not an isolated before/after measurement; the animated flood path is schematic, not a hazard model. Witness media opens or embeds original sources without bundled clips. See `public/events/bhote-koshi-2026/README.md` for provenance and non-commercial data terms.
Place search accepts an explicit Nominatim provider with independently configured search and reverse endpoints. The default offline/Google/Photon/local-fallback order is unchanged when no provider is selected. Provider adapters share normalized coordinates, viewport framing and reverse labels; roads and boundary geometry remain separate services. Portable capped-response and Overpass lexical helpers are exported independently of the Node server.
Reference feed construction is exported through `sources/reference`; the cable source also has a dedicated `layers/submarine-cables/source` entry. Standalone catalog compatibility remains available. Source choices, data and attribution are unchanged.
@@ -731,7 +755,7 @@ Non-object or array-valued properties reject the response instead of being treat
Launch payloads with missing records now say PAYLOAD DATA UNAVAILABLE. Missing names use Unnamed payload; absent or invalid mass stays unknown instead of appearing as 0 KG.
Updated: August 24, 2026
Updated: September 15, 2026
## Aircraft and vessel server modules
+55
View File
@@ -0,0 +1,55 @@
# Bhote Koshi 2026 event pack
A non-commercial visualization of the 26 August 2026 Bhote Koshi outburst flood.
This is not an official hazard model.
## Provenance and licenses
| File | Source and modifications | License |
| --- | --- | --- |
| `pre.webp` | Vantor WorldView-2 scene `10300100C86CED00`, observed 2021-10-16; natural-color crop of 85.295–85.379 E, 28.135–28.292 N, resampled to 2048×4352 and WebP-compressed | CC BY-NC 4.0 |
| `post.webp` | Vantor WorldView-3 scene `B040001100881410`, observed 2026-08-27; same bounds and output geometry, with source clouds retained | CC BY-NC 4.0 |
| `event.json` | GeoPera reconstruction, commit `59ee5bd`; every fourth published centerline point inside the image bounds, plus sixteen curated evidence records linking public witness posts and the GeoGeorgeShadrach geolocation map | CC BY-NC 4.0 for the derived centerline; linked posts and map records retain their owners' terms |
| `src/data/bhoteKoshiFloodPath.js` (outside this directory) | Simplified GeoPera centerline from the Debris-Dammed Lake observation to Trishuli Bazaar, compiled into the scene and locator | CC BY-NC 4.0 for the coordinate dataset, separately from the executable code |
These third-party assets are **not covered by the application's MIT license**.
Keep attribution and the [CC BY-NC 4.0 license](https://creativecommons.org/licenses/by-nc/4.0/).
For commercial use, obtain separate permission or exclude both this pack and the
derived coordinate dataset in `src/data/bhoteKoshiFloodPath.js` from the source
and build. Deleting this directory alone does not remove the compiled river
data. Excluding the Nepal scene also requires removing its registrations and
imports before building.
Sources:
- [Vantor Open Data Program](https://vantor.com/company/open-data-program)
- [Historical source image](https://vantor-opendata.s3.amazonaws.com/events/Nepal-Flooding-Aug-2026/10300100C86CED00.tif)
- [Post-event source image](https://vantor-opendata.s3.amazonaws.com/events/Nepal-Flooding-Aug-2026/B040001100881410.tif)
- [GeoPera reconstruction](https://github.com/geo-pera/bhotekoshi-2026-reconstruction)
- [GeoGeorgeShadrach public geolocation-map introduction](https://x.com/geogeorgeology/status/2093632283442053371)
Witness clips and cached posters are not bundled. Cards link to the original
source, or use the original platform's embed where available. An unavailable
embed leaves the source link accessible. Capture times remain unverified.
Esri basemap imagery and runtime terrain are fetched separately, not bundled;
their provider attribution remains visible in the application.
The Nepal sequence supports both keyed and keyless map setups. Google 3D is used
for authored 3D shots when available; otherwise those shots use Esri imagery.
Without a Cesium ion token, terrain comes from Re:Earth / Mapterhorn. With an
ion token, the configured Cesium terrain remains available. Vantor comparison
shots use Esri in either mode. Provider resolution does not rewrite saved shots.
## Interpretation limits
The 2021 image is historical context, not an immediate pre-event baseline.
Differences also span development, seasons, sensors, and the July 2025
Rasuwagadhi flood. The amber corridor is a schematic progression along the
published river centerline, not modeled arrival time. Its width and timing
must not be used for emergency planning, exposure analysis, or casualty claims.
Evidence cards identify observations outside the Vantor comparison rectangle.
The Rasuwagadhi witness focus uses the 2021 context image because the 2026
scene does not provide useful coverage at that point. The Timure record groups
five source-map placements, not five independent timing confirmations.
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 981 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

+19
View File
@@ -588,6 +588,7 @@
"src/ui/styles/radio.css",
"src/ui/styles/cctv.css",
"src/ui/styles/scenes.css",
"src/ui/styles/bhote-koshi.css",
"src/ui/styles/recording.css",
"src/ui/styles/responsive.css",
"src/ui/styles/command-dock.css",
@@ -603,6 +604,11 @@
"scenes": {
"exports": ["./scenes"],
"modules": [
"src/scenes/nepalEvidencePack.js",
"src/data/bhoteKoshiIncidentPlaces.js",
"src/data/bhoteKoshiShotPaths.js",
"src/data/bhoteKoshiFloodPath.js",
"src/maps/catalog.js",
"src/scenes/director.js",
"src/scenes/recipes.js",
"src/scenes/scenePolicy.js",
@@ -1117,6 +1123,13 @@
"./standalone/catalog"
],
"modules": [
"src/scenes/nepalEvidencePack.js",
"src/data/bhoteKoshiEvent.js",
"src/data/bhoteKoshiEmbeddedMedia.js",
"src/data/bhoteKoshiLocator.js",
"src/data/bhoteKoshiIncidentPlaces.js",
"src/data/bhoteKoshiFloodPath.js",
"src/data/bhoteKoshiShotPaths.js",
"src/annotations/annotationEngine.js",
"src/annotations/annotationResolver.js",
"src/annotations/hybridAnnotationRenderer.js",
@@ -1684,6 +1697,12 @@
"application-layer-construction": {
"exports": ["./application/layers"],
"modules": [
"src/data/bhoteKoshiEvent.js",
"src/data/bhoteKoshiEmbeddedMedia.js",
"src/data/bhoteKoshiLocator.js",
"src/data/bhoteKoshiIncidentPlaces.js",
"src/data/bhoteKoshiFloodPath.js",
"src/data/bhoteKoshiShotPaths.js",
"src/annotations/annotationResolver.js",
"src/annotations/resolver.js",
"src/app/catalog.js",
+139 -32
View File
@@ -2860,6 +2860,55 @@ async function main() {
tick();
setTimeout(() => { done = true; res(); }, ms);
});
// A timer can expire while the camera is new but the model/cache still
// belongs to the old frame. Observe the first completed exit frame,
// including a wrong result; never wait for visibility or height to pass.
window.__dfObserveTrackedExit = async ({ scene, trackedEntity, trackedModel,
getTrackedModel, getTrackedEntity, getTrackedId, getCameraHeight,
exitHeight, read, now = Date.now, setTimer = setTimeout, clearTimer = clearTimeout }) => {
const deadline = now() + 5000;
let removePre, removePost, removeError, timer;
let preFrame = null;
try {
return await new Promise((resolve) => {
let finished = false;
const finish = (result) => { if (!finished) { finished = true; resolve(result); } };
const valid = () => {
try {
if (now() > deadline) return 'tracked exit observation exceeded its 5000 ms deadline';
if (getTrackedEntity() !== trackedEntity || getTrackedId() !== 'aaa097') return 'tracked exit fixture identity changed';
if (!trackedModel || getTrackedModel() !== trackedModel) return 'tracked exit model identity changed';
if (!(getCameraHeight() > exitHeight)) return 'tracked exit camera left the exit regime';
return null;
} catch { return 'tracked exit fixture validation failed'; }
};
removePre = scene.preUpdate.addEventListener(() => {
if (finished) return;
const error = valid();
if (error) { finish({ error }); return; }
if (preFrame == null) preFrame = scene.frameState.frameNumber;
});
removePost = scene.postRender.addEventListener(() => {
if (finished || preFrame == null) return;
const error = valid();
if (error) { finish({ error }); return; }
if (scene.frameState.frameNumber === preFrame) {
finish({ error: 'tracked exit observation has no new rendered frame' }); return;
}
try {
const snapshot = read();
finish(snapshot ? { ...snapshot, observationFrame: scene.frameState.frameNumber }
: { error: 'tracked exit snapshot missing' });
} catch { finish({ error: 'tracked exit snapshot failed' }); }
});
removeError = scene.renderError.addEventListener(() => finish({ error: 'tracked exit render failed' }));
timer = setTimer(() => finish({ error: 'tracked exit update did not complete within 5000 ms' }), 5000);
scene.requestRender();
});
} finally {
clearTimer(timer); removePre?.(); removePost?.(); removeError?.();
}
};
// Cesium Models in the scene, split by visibility (duck-typed the same
// way the harness excludes them from height probes).
//
@@ -3378,10 +3427,30 @@ async function main() {
// the billboard — which MUST be floored, or a tracked grounded contact
// renders buried at any distance the operator pulls out to. This is the
// coverage the toggle used to reach; the zoom regime reaches it now.
const findOwnModel = () => {
let found = null;
const walk = (collection) => {
for (let i = 0; i < collection.length; i++) {
const item = collection.get(i);
if (typeof item?.get === 'function' && typeof item.length === 'number') walk(item);
else if (item?.id === 'aaa097' && item.activeAnimations !== undefined
&& item.minimumPixelSize !== undefined) found = item;
}
};
walk(v.scene.primitives);
return found;
};
const trackedEntity = v.trackedEntity;
const trackedModel = findOwnModel();
const achievedOut = await window.__dfZoomAbove(window.__dfRegime.exit + 5000);
await window.__dfSettle(1500);
const zoomedOut = readTracked();
if (!zoomedOut) return { error: 'tracked entity has no position (zoomed out)' };
const zoomedOut = await window.__dfObserveTrackedExit({
scene: v.scene, trackedEntity, trackedModel, getTrackedModel: findOwnModel,
getTrackedEntity: () => v.trackedEntity,
getTrackedId: () => fl.getTrackedInfo()?.icao24,
getCameraHeight: window.__dfCamH, exitHeight: window.__dfRegime.exit,
read: readTracked,
});
if (zoomedOut.error) return { error: zoomedOut.error };
const out = {
rawH: d0.h,
@@ -3643,11 +3712,10 @@ async function main() {
}
// ---- F3: the LOADING window is billboard-owned, not model-owned ------
// A fleet model is registered with Cesium's default show=true the instant
// its glTF resolves, but the handoff waits for `ready`; and a tracked
// model is null for the whole load even though the regime is already
// active. Both windows leave the BILLBOARD as the visual, so both must
// stay floored — ownership means actually rendering.
// Stress the fleet ownership predicate with synthetic show=true/ready=false
// flags. Production admission keeps the model hidden; these shadows do
// not simulate Cesium's internal loading/draw state. The tracked check
// below separately samples the no-rendering-model window.
const dfLoading = await evalPage(async () => {
const v = window.__godsEyeView.viewer;
const Cesium = await import('/node_modules/cesium/Build/Cesium/index.js');
@@ -3663,13 +3731,8 @@ async function main() {
};
const out = {};
// (a) FLEET: reach the real admitted-but-not-ready window. A fleet model
// is registered in `_models` with Cesium's default show=true the moment
// its glTF resolves, while the billboard handoff waits for `ready` — so
// for a tick the model claims the visual it is not drawing. That window
// is ~one fleet tick wide, so hold the state open by shadowing the two
// flags on the instance (the same defineProperty trick the ground-3d
// group uses on `tilesLoaded`).
// (a) FLEET: retain the adversarial flags until this contact has passed
// through its installed fleet update and that frame has completed.
const findFleetModel = (id) => {
let found = null;
const walk = (coll) => {
@@ -3698,8 +3761,26 @@ async function main() {
// assigns `model.show = false` for a not-ready model, and assigning to a
// non-writable own property throws in strict mode — which lands inside
// Cesium's render loop and stops rendering for the rest of the run.
const priorReady = Object.getOwnPropertyDescriptor(fleetModel, 'ready');
const priorShow = Object.getOwnPropertyDescriptor(fleetModel, 'show');
let armed = false;
let processedFrame = null;
let sample = null;
let removePostRender = null;
try {
Object.defineProperty(fleetModel, 'ready', { get: () => false, set: () => {}, configurable: true });
Object.defineProperty(fleetModel, 'show', { get: () => true, set: () => {}, configurable: true });
Object.defineProperty(fleetModel, 'show', {
get: () => true,
set: (value) => {
// Both the not-ready handoff and horizon cull clear show AFTER
// computing this contact's display floor. Neither a timer nor an
// unrelated rendered frame proves that this aircraft was processed.
if (armed && value === false && processedFrame == null) {
processedFrame = v.scene.frameState.frameNumber;
}
},
configurable: true,
});
const bb = window.__dfFindBB('aaa097');
if (!bb) return { error: 'aaa097 billboard missing' };
bb.show = true; // the handoff would not have hidden it: the model is not ready
@@ -3713,21 +3794,45 @@ async function main() {
fl._clearDisplayFloorStateForTest();
const seededFleet = d0.h + 45;
floorAround(d0, seededFleet);
// reportMeshFloorCell is a direct test seam and does not schedule a
// Cesium frame. Force the CallbackProperty to re-evaluate before the
// assertion, matching the request-render fix in the retained-model
// scenario above.
v.scene.requestRender();
await window.__dfSettle(900);
const bbAfter = window.__dfFindBB('aaa097');
out.fleetModelReady = fleetModel.ready;
out.fleetModelShow = fleetModel.show;
out.fleetBillboardVisible = !!bbAfter?.show;
out.fleetSeeded = seededFleet;
out.fleetH = bbAfter ? window.__dfCarto(bbAfter.position).h : null;
delete fleetModel.ready;
delete fleetModel.show;
fl.setParams({ models3d: false });
const tickDeadline = Date.now() + 5000;
removePostRender = v.scene.postRender.addEventListener(() => {
if (sample || processedFrame == null || v.scene.frameState.frameNumber < processedFrame) return;
if (Date.now() > tickDeadline) {
sample = { error: 'fleet loading fixture completed after its 5000 ms deadline' };
return;
}
const currentBb = window.__dfFindBB('aaa097');
const currentModel = findFleetModel('aaa097');
sample = {
fleetTickCompleted: true,
fleetFrame: v.scene.frameState.frameNumber,
fleetModelReady: fleetModel.ready,
fleetModelShow: fleetModel.show,
fleetBillboardVisible: !!currentBb?.show,
fleetSeeded: seededFleet,
fleetH: currentBb ? window.__dfCarto(currentBb.position).h : null,
};
if (currentBb !== bb || currentModel !== fleetModel || fleetModel.isDestroyed()
|| fl.getTrackedInfo()) sample.error = 'fleet loading fixture changed before its completed frame';
});
armed = true;
// This is a setup-completion deadline, not a visual latency budget.
// Take the FIRST completed fleet result, even when its height is wrong;
// never poll the height until the assertion happens to pass.
do {
await window.__dfSettle(50);
} while (!sample && Date.now() < tickDeadline);
if (!sample) return { error: 'fleet loading fixture did not complete an aircraft update within 5000 ms' };
Object.assign(out, sample);
} finally {
armed = false;
removePostRender?.();
if (priorReady) Object.defineProperty(fleetModel, 'ready', priorReady);
else delete fleetModel.ready;
if (priorShow) Object.defineProperty(fleetModel, 'show', priorShow);
else delete fleetModel.show;
fl.setParams({ models3d: false });
}
await window.__dfSettle(400);
// (b) TRACKED, regime ACTIVE but no model yet. Turning 3D on while
@@ -3771,7 +3876,8 @@ async function main() {
skip('display-floor/loading: fleet model loading window', dfLoading.skipped);
} else {
record('display-floor/loading: a fleet model that is shown-but-not-ready does not own the visual',
!dfLoading.error && dfLoading.fleetModelShow === true && dfLoading.fleetModelReady === false
!dfLoading.error && dfLoading.fleetTickCompleted === true
&& dfLoading.fleetModelShow === true && dfLoading.fleetModelReady === false
&& dfLoading.fleetBillboardVisible === true
&& Number.isFinite(dfLoading.fleetH)
&& dfLoading.fleetH >= dfLoading.fleetSeeded + DISPLAY_FLOOR_LIFT_M - 0.5,
@@ -3955,6 +4061,7 @@ async function main() {
delete window.__dfPriorTilesLoadedDescriptor;
delete window.__dfSkinSamples;
delete window.__dfSkinM;
delete window.__dfObserveTrackedExit;
await fl.update(v);
});
+7
View File
@@ -20,6 +20,8 @@ import { createApplicationEarthquakes } from './layers/earthquakes.js';
import { createApplicationCables } from './layers/submarineCables.js';
import { createInfrastructureLayers } from '../data/infrastructure.js';
import { localGeoJsonServices } from './localGeojsonServices.js';
import { createBhoteKoshiEventLayer } from '../data/bhoteKoshiEvent.js';
import { createBhoteKoshiLocatorLayer } from '../data/bhoteKoshiLocator.js';
const SOURCE_METHODS = Object.freeze({
flights: ['getSnapshot'],
@@ -55,6 +57,7 @@ export function createApplicationCatalog({
metadata = LAYER_STATE_REGISTRY,
vesselOptions,
resolveAsset,
nepalBoundaryResolver,
}) {
if (!signal?.addEventListener)
throw new TypeError('An application lifetime signal is required');
@@ -101,6 +104,10 @@ export function createApplicationCatalog({
});
const catalog = createLayerCatalog(
[
createBhoteKoshiEventLayer(),
createBhoteKoshiLocatorLayer({
boundaryResolver: nepalBoundaryResolver,
}),
flights,
military,
createApplicationEarthquakes({ source: sources.earthquakes }),
+22 -1
View File
@@ -4,6 +4,7 @@ import assert from 'node:assert/strict';
import { createApplicationCatalog } from './constructCatalog.js';
import { createStandaloneLayerSources } from '../standalone/layerSources.js';
import { catalogControlServices } from './catalog.js';
import { LayerLifecycle } from '../data/lifecycle.js';
function fixtureSources(ids, calls) {
const sources = createStandaloneLayerSources();
@@ -38,7 +39,27 @@ test('catalogs construct distinct layers and classification from their supplied
signal: b.signal,
surface: fixtureSurface(b.signal),
});
assert.equal(first.layers.length, 19);
assert.equal(first.layers.length, 21);
assert.ok(first.get('transit'));
assert.ok(first.get('bhote-koshi-2026'));
assert.ok(first.get('bhote-koshi-locator'));
const lifecycle = new LayerLifecycle({});
for (const layer of first.layers) lifecycle.register(layer);
const rows = lifecycle.getAll();
for (const id of ['bhote-koshi-2026', 'bhote-koshi-locator']) {
assert.equal(
rows.find((row) => row.id === id)?.showInTogglePanel,
false,
`${id} remains registered for Scenes but is absent from Data Layers`,
);
assert.equal(typeof first.get(id).enable, 'function');
assert.equal(typeof first.get(id).setParams, 'function');
}
assert.equal(
rows.find((row) => row.id === 'flights')?.showInTogglePanel,
true,
'ordinary data layer entries remain visible',
);
assert.deepEqual(
first.layers.map(({ id }) => id),
second.layers.map(({ id }) => id),
+3 -1
View File
@@ -2,7 +2,7 @@ import { LayerLifecycle } from '../data/lifecycle.js';
import { LayerPresentation } from './layerPresentation.js';
/** Register the application layer catalog before allowing state restoration. */
export function createApplicationData({
scene: { viewer },
scene: { viewer, mapStackController },
controls: { styleManager },
catalog,
allowQaRegistration,
@@ -27,6 +27,8 @@ export function createApplicationData({
throw new TypeError('An application layer catalog is required');
for (const layer of catalog.layers) dataManager.register(layer);
for (const layer of catalog.layers) layer.attachDataManager?.(dataManager);
for (const layer of catalog.layers)
layer.attachMapStackController?.(mapStackController);
// Restoration starts only after the caller's complete registry is sealed.
dataManager.finalizeRegistrations(catalog.metadata);
if (allowQaRegistration) {
+7 -1
View File
@@ -27,7 +27,13 @@ export function createApplicationTools({
const { viewer, tileset, mapStackController, operations } = scene;
const { styleManager, weatherEffects, cockpitCloudEffects } = controls;
const { dataManager } = data;
const sceneDirector = new SceneDirector(viewer, styleManager, dataManager);
const sceneDirector = new SceneDirector(viewer, styleManager, dataManager, {
isMapStackAvailable: (id) =>
mapStackController?.isStackAvailable(id) === true,
});
dataManager.layers
.get('bhote-koshi-2026')
?.module.attachSceneController(sceneDirector);
defer(() => sceneDirector.destroy());
onSceneDirector?.(sceneDirector);
const annotations = initAnnotations({
+1 -1
View File
@@ -255,7 +255,7 @@ test('Cockpit owns a focused shared Display portal and compact Radio controls',
css,
/body\.scene-playback-mode\s*:is\([\s\S]*?#clear-selected-layers,[\s\S]*?#tilt-map-view,[\s\S]*?#north-up-view,[\s\S]*?#reset-globe-view[\s\S]*?\)\s*\{\s*display:\s*none !important;/,
);
assert.match(sceneDirector, /this\._running = true;\s*this\._setPlaybackActive\(true\);/);
assert.match(sceneDirector, /this\._running = true;\s*this\._previewRun = preview;\s*if \(preview\) this\._setPlaybackActive\(true\);/);
assert.match(sceneDirector, /styleManager\.setRecordingMode\(false\);\s*this\._setPlaybackActive\(false\);/);
assert.match(SceneControls.prototype.setPlaybackActive.toString(), /document\.body\.classList\.toggle\('scene-playback-mode', active\)/);
assert.equal((html.match(/id="hud-toggle"/g) || []).length, 1, 'HUD control must have one stateful DOM owner');
+912
View File
@@ -0,0 +1,912 @@
import * as Cesium from 'cesium';
const YOUTUBE_HOSTS = new Set([
'youtube.com',
'www.youtube.com',
'm.youtube.com',
'youtube-nocookie.com',
'www.youtube-nocookie.com',
'youtu.be',
]);
const FACEBOOK_HOSTS = new Set([
'facebook.com',
'www.facebook.com',
'm.facebook.com',
'web.facebook.com',
'fb.watch',
'www.fb.watch',
]);
const X_HOSTS = new Set([
'x.com',
'www.x.com',
'mobile.x.com',
'twitter.com',
'www.twitter.com',
'mobile.twitter.com',
]);
const X_WIDGET_URL = 'https://platform.twitter.com/widgets.js';
const FACEBOOK_SDK_ID = 'facebook-jssdk';
const FACEBOOK_SDK_URL = 'https://connect.facebook.net/en_US/sdk.js#xfbml=0&version=v24.0';
const EMBED_TIMEOUT_MS = 15000;
const CARD_EXIT_DURATION_MS = 620;
const MEDIA_PRECONNECT_ORIGINS = Object.freeze({
youtube: Object.freeze([
'https://www.youtube-nocookie.com',
'https://www.youtube.com',
'https://i.ytimg.com',
]),
facebook: Object.freeze(['https://www.facebook.com']),
});
let _xWidgetsPromise = null;
let _facebookSdkPromise = null;
let _facebookEmbedSequence = 0;
const youtubeApis = new WeakMap();
function loadYouTubeApi(documentRef, globalRef) {
if (typeof globalRef.YT?.Player === 'function') return Promise.resolve(globalRef.YT);
if (youtubeApis.has(documentRef)) return youtubeApis.get(documentRef);
const pending = new Promise((resolve, reject) => {
const previous = globalRef.onYouTubeIframeAPIReady;
let timer;
let settled = false;
const script = documentRef.createElement('script');
const finish = (error) => {
if (settled) return;
settled = true;
globalRef.clearTimeout(timer);
if (globalRef.onYouTubeIframeAPIReady === ready) globalRef.onYouTubeIframeAPIReady = previous;
if (error) { script.remove(); reject(error); }
else resolve(globalRef.YT);
};
const ready = () => {
try { previous?.(); } finally {
finish(typeof globalRef.YT?.Player === 'function' ? null : new Error('YouTube API unavailable'));
}
};
globalRef.onYouTubeIframeAPIReady = ready;
script.src = 'https://www.youtube.com/iframe_api';
script.async = true;
script.addEventListener('error', () => finish(new Error('YouTube API unavailable')), { once: true });
timer = globalRef.setTimeout(() => finish(new Error('YouTube API timed out')), 5000);
documentRef.head.append(script);
}).catch((error) => { youtubeApis.delete(documentRef); throw error; });
youtubeApis.set(documentRef, pending);
return pending;
}
/** A trimmed clip follows provider time, never time spent loading its iframe. */
export function createTrimmedYouTubePlayback({
iframe, endAtSec, startAtSec = 0, loadApi, globalRef = globalThis,
isCurrent = () => true, onStatus = () => {},
}) {
let player = null;
let phase = 'starting';
let active = true;
let started = false;
let currentTime = startAtSec;
let poll = null;
let deadline = null;
const live = () => active && isCurrent() && ['starting', 'playing'].includes(phase);
const clear = () => {
globalRef.clearTimeout(deadline);
globalRef.clearTimeout(poll);
deadline = poll = null;
};
const finish = (outcome) => {
if (!live()) return;
phase = outcome;
clear();
try { player?.pauseVideo?.(); } catch { /* Removed provider frame. */ }
onStatus(outcome);
};
const sample = (stateHint) => {
if (!live()) return;
globalRef.clearTimeout(poll);
poll = null;
try {
const previousTime = currentTime;
const time = player?.getCurrentTime?.();
if (Number.isFinite(time)) currentTime = time;
const state = stateHint ?? player?.getPlayerState?.();
if (currentTime >= endAtSec || (state === 0 && currentTime >= endAtSec - 0.1)) {
finish('completed'); return;
}
if (state === 0) { finish('unavailable'); return; }
// Autoplay can begin before the API attaches, without a new PLAYING edge.
if (!started && (state === 1 || currentTime > previousTime)) {
started = true;
phase = 'playing';
globalRef.clearTimeout(deadline);
deadline = globalRef.setTimeout(() => finish('timeout'), (endAtSec - startAtSec) * 1000 + 5000);
}
} catch { finish('unavailable'); return; }
poll = globalRef.setTimeout(() => sample(), 100);
};
deadline = globalRef.setTimeout(() => finish('timeout'), 5000);
Promise.resolve().then(loadApi).then((api) => {
if (!live()) return;
player = new api.Player(iframe, { events: {
onReady(event) {
if (!live()) return;
player = event.target;
sample();
if (!live()) return;
try { event.target.mute(); event.target.playVideo(); } catch { finish('unavailable'); }
},
onStateChange(event) {
if (!live()) return;
if (event.target) player = event.target;
sample(event.data);
},
onError: () => finish('unavailable'),
onAutoplayBlocked: () => finish('blocked'),
} });
}).catch(() => finish('unavailable'));
return {
getState: () => ({ phase, currentTime }),
destroy() {
active = false;
phase = 'cancelled';
clear();
try { player?.destroy?.(); } catch { /* Detached frame already released. */ }
player = null;
},
};
}
function facebookSdkReady(api) {
return typeof api?.XFBML?.parse === 'function'
&& typeof api?.Event?.subscribe === 'function';
}
function loadFacebookSdk(documentRef, globalRef = globalThis) {
if (facebookSdkReady(globalRef.FB)) return Promise.resolve(globalRef.FB);
if (_facebookSdkPromise) return _facebookSdkPromise;
_facebookSdkPromise = new Promise((resolve, reject) => {
let settled = false;
let timer = null;
const prior = globalRef.fbAsyncInit;
const finish = (error) => {
if (settled) return;
settled = true;
globalRef.clearTimeout?.(timer);
if (globalRef.fbAsyncInit === ready) globalRef.fbAsyncInit = prior;
if (error) reject(error);
else resolve(globalRef.FB);
};
const ready = () => {
try {
prior?.();
} finally {
if (facebookSdkReady(globalRef.FB)) finish();
else finish(new Error('Facebook player API did not initialize.'));
}
};
globalRef.fbAsyncInit = ready;
let script = documentRef.getElementById?.(FACEBOOK_SDK_ID);
if (!script) {
script = documentRef.createElement('script');
script.id = FACEBOOK_SDK_ID;
script.async = true;
script.defer = true;
script.crossOrigin = 'anonymous';
script.src = FACEBOOK_SDK_URL;
documentRef.head?.append(script);
}
script.addEventListener?.(
'error',
() => finish(new Error('Facebook player API could not load.')),
{ once: true },
);
timer = globalRef.setTimeout?.(
() => finish(new Error('Facebook player API timed out.')),
EMBED_TIMEOUT_MS,
) ?? null;
}).catch((error) => {
_facebookSdkPromise = null;
throw error;
});
return _facebookSdkPromise;
}
/** Accept a public HTTPS media URL without allowing iframe markup or credentials. */
export function publicEmbeddedMediaUrl(value) {
if (typeof value !== 'string' || value.length > 4096) return null;
let url;
try {
url = new URL(value.trim());
} catch {
return null;
}
const host = url.hostname.toLowerCase();
if (
url.protocol !== 'https:'
|| url.username
|| url.password
|| (url.port && url.port !== '443')
|| !host.includes('.')
|| host.includes(':')
|| /^\d+(\.\d+){3}$/.test(host)
|| /(^|\.)(localhost|local|internal|test|invalid)$/.test(host)
) return null;
return url;
}
function youtubeVideoId(url) {
const host = url.hostname.toLowerCase();
if (!YOUTUBE_HOSTS.has(host)) return null;
if (host === 'youtu.be') return /^\/([\w-]{11})\/?$/.exec(url.pathname)?.[1] || null;
if (url.pathname === '/watch') return url.searchParams.get('v');
return /^\/(?:embed|shorts)\/([\w-]{11})\/?$/.exec(url.pathname)?.[1] || null;
}
function isFacebookVideoUrl(url) {
const host = url.hostname.toLowerCase();
if (!FACEBOOK_HOSTS.has(host)) return false;
if (host === 'fb.watch' || host === 'www.fb.watch') return /^\/[\w.-]+\/?$/.test(url.pathname);
return /^\/reel\/[\w.-]+\/?$/.test(url.pathname)
|| (url.pathname === '/watch/' && Boolean(url.searchParams.get('v')))
|| /\/videos\/[\w.-]+\/?$/.test(url.pathname)
|| /^\/share\/(?:v|r)\/[\w.-]+\/?$/.test(url.pathname);
}
function xPostId(url) {
if (!X_HOSTS.has(url.hostname.toLowerCase())) return null;
return /^\/[A-Za-z0-9_]{1,15}\/status\/(\d+)\/?$/.exec(url.pathname)?.[1] || null;
}
/** Resolve only providers that can render inside a Bhote Koshi callout. */
export function resolveEmbeddedMediaSource(value) {
const url = publicEmbeddedMediaUrl(value);
if (!url) return null;
const youtubeId = youtubeVideoId(url);
if (youtubeId && /^[\w-]{11}$/.test(youtubeId)) {
return {
provider: 'youtube',
id: youtubeId,
url: `https://www.youtube.com/watch?v=${youtubeId}`,
};
}
if (isFacebookVideoUrl(url)) {
return { provider: 'facebook', url: url.href };
}
const postId = xPostId(url);
if (postId) {
return { provider: 'x', id: postId, url: url.href };
}
return null;
}
/** Provider-owned iframe URL for YouTube or Facebook callouts. */
export function embeddedMediaFrameUrl(source, {
autoplay = false,
startAtSec = 0,
endAtSec = null,
} = {}) {
if (source?.provider === 'youtube') {
const url = new URL(`https://www.youtube-nocookie.com/embed/${source.id}`);
url.searchParams.set('playsinline', '1');
url.searchParams.set('rel', '0');
const startSeconds = Math.max(0, Math.floor(Number(startAtSec) || 0));
if (startSeconds > 0) url.searchParams.set('start', String(startSeconds));
const endSeconds = Math.floor(Number(endAtSec));
if (Number.isFinite(endSeconds) && endSeconds > startSeconds) {
url.searchParams.set('end', String(endSeconds));
}
if (autoplay) {
url.searchParams.set('autoplay', '1');
url.searchParams.set('mute', '1');
}
return url.href;
}
if (source?.provider === 'facebook') {
const url = new URL('https://www.facebook.com/plugins/video.php');
url.searchParams.set('href', source.url);
url.searchParams.set('show_text', 'false');
url.searchParams.set('width', '500');
url.searchParams.set('autoplay', autoplay ? 'true' : 'false');
url.searchParams.set('mute', autoplay ? 'true' : 'false');
return url.href;
}
return null;
}
function xWidgetsReady(api) {
return typeof api?.widgets?.createTweet === 'function';
}
function loadXWidgets(documentRef, globalRef = globalThis) {
if (xWidgetsReady(globalRef.twttr)) return Promise.resolve(globalRef.twttr);
if (_xWidgetsPromise) return _xWidgetsPromise;
_xWidgetsPromise = new Promise((resolve, reject) => {
let settled = false;
let timer = null;
const finish = (error) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (error) reject(error);
else resolve(globalRef.twttr);
};
const ready = () => {
if (xWidgetsReady(globalRef.twttr)) finish();
else finish(new Error('X embed API did not initialize.'));
};
let script = documentRef.querySelector?.(`script[src="${X_WIDGET_URL}"]`);
if (!script) {
script = documentRef.createElement('script');
script.src = X_WIDGET_URL;
script.async = true;
script.charset = 'utf-8';
script.dataset.gevXWidgets = 'true';
documentRef.head?.append(script);
}
script.addEventListener?.('error', () => finish(new Error('X embed could not load.')), {
once: true,
});
const waitForApi = () => {
if (xWidgetsReady(globalRef.twttr)) {
finish();
return;
}
if (typeof globalRef.twttr?.ready === 'function') globalRef.twttr.ready(ready);
else ready();
};
script.addEventListener?.('load', waitForApi, { once: true });
if (typeof globalRef.twttr?.ready === 'function') globalRef.twttr.ready(ready);
if (script.dataset?.gevXLoaded === 'true') waitForApi();
else script.addEventListener?.('load', () => { script.dataset.gevXLoaded = 'true'; }, {
once: true,
});
timer = setTimeout(() => finish(new Error('X embed timed out.')), EMBED_TIMEOUT_MS);
}).catch((error) => {
_xWidgetsPromise = null;
throw error;
});
return _xWidgetsPromise;
}
function providerLabel(provider) {
return ({ youtube: 'YOUTUBE', facebook: 'FACEBOOK', x: 'X' })[provider] || 'SOURCE';
}
function isPortraitMedia(media = {}) {
const width = Number(media.width);
const height = Number(media.height);
return String(media.orientation || '').toLowerCase() === 'portrait'
|| (Number.isFinite(width) && Number.isFinite(height) && height > width);
}
function ensureProviderConnections(documentRef, provider) {
for (const origin of MEDIA_PRECONNECT_ORIGINS[provider] || []) {
if (documentRef.querySelector?.(`link[data-gev-media-preconnect="${origin}"]`)) continue;
const link = documentRef.createElement('link');
link.rel = 'preconnect';
link.href = origin;
link.crossOrigin = 'anonymous';
link.dataset.gevMediaPreconnect = origin;
documentRef.head?.append(link);
}
}
function anchorOnScreen(viewer, anchor, root) {
if (!viewer?.scene || !anchor || !root) return null;
const camera = viewer.camera;
if (camera?.positionWC && camera?.directionWC) {
const delta = Cesium.Cartesian3.subtract(anchor, camera.positionWC, new Cesium.Cartesian3());
if (Cesium.Cartesian3.dot(delta, camera.directionWC) <= 0) return null;
}
const pixel = Cesium.SceneTransforms.worldToWindowCoordinates(viewer.scene, anchor);
if (!pixel) return null;
const canvasBounds = viewer.canvas?.getBoundingClientRect?.();
const rootBounds = root.getBoundingClientRect?.();
if (!canvasBounds || !rootBounds) return null;
const x = pixel.x + canvasBounds.left - rootBounds.left;
const y = pixel.y + canvasBounds.top - rootBounds.top;
if (x < -40 || y < -40 || x > rootBounds.width + 40 || y > rootBounds.height + 40) return null;
return { x, y, width: rootBounds.width, height: rootBounds.height };
}
function placeCard(card, leader, anchor) {
const margin = 18;
const gap = 42;
const width = card.offsetWidth || 392;
const height = card.offsetHeight || 360;
const rootBounds = card.parentElement?.getBoundingClientRect?.();
const documentRef = card.ownerDocument;
let laneLeft = margin;
let laneRight = anchor.width - margin;
let laneBottom = anchor.height - margin;
if (rootBounds && documentRef?.querySelector) {
const leftPanel = documentRef.querySelector('#scene-panel')?.getBoundingClientRect?.();
const rightRail = documentRef.querySelector('#right-context-rail')?.getBoundingClientRect?.();
const commandDock = documentRef.querySelector('#command-dock')?.getBoundingClientRect?.();
if (leftPanel?.width > 0) laneLeft = Math.max(laneLeft, leftPanel.right - rootBounds.left + margin);
if (rightRail?.width > 0) laneRight = Math.min(laneRight, rightRail.left - rootBounds.left - margin);
if (commandDock?.height > 0) laneBottom = Math.min(
laneBottom,
commandDock.top - rootBounds.top - margin,
);
if (laneRight - laneLeft < width) {
laneLeft = margin;
laneRight = anchor.width - margin;
}
}
let left = anchor.x + gap;
if (left + width > laneRight) left = anchor.x - width - gap;
left = Math.max(laneLeft, Math.min(laneRight - width, left));
const belowAnchor = anchor.y + gap;
const prefersBelow = card.classList.contains('is-youtube')
|| card.classList.contains('is-portrait');
const desiredTop = prefersBelow && belowAnchor + height <= laneBottom
? belowAnchor
: anchor.y - height * 0.42;
const top = Math.max(margin, Math.min(laneBottom - height, desiredTop));
card.style.left = `${left}px`;
card.style.top = `${top}px`;
const localX = anchor.x - left;
const localY = anchor.y - top;
const edgeX = localX < 0 ? 0 : width;
const edgeY = Math.max(16, Math.min(height - 16, localY));
leader.querySelector('path')?.setAttribute('d', `M ${localX} ${localY} L ${edgeX} ${edgeY}`);
const dot = leader.querySelector('circle');
dot?.setAttribute('cx', String(localX));
dot?.setAttribute('cy', String(localY));
}
function createRoot(documentRef, viewer) {
const root = documentRef.createElement('div');
root.id = 'bhote-koshi-embedded-media-root';
root.setAttribute('aria-live', 'polite');
(viewer?.container || documentRef.body)?.append(root);
return root;
}
/**
* Render one provider-owned player/post in a geographically anchored DOM card.
* YouTube and Facebook sources use their provider player; approved local clips
* remain available to bhoteKoshiEvent as a fallback when no embed is supported.
*/
export function createBhoteKoshiEmbeddedMedia({
viewer,
documentRef = globalThis.document,
globalRef = globalThis,
xLoader = loadXWidgets,
facebookLoader = loadFacebookSdk,
youtubeLoader = loadYouTubeApi,
} = {}) {
if (!documentRef?.createElement || !viewer) {
return {
warm: () => false,
show: () => false,
play: () => false,
pause: () => false,
hide: () => {},
destroy: () => {},
};
}
const root = createRoot(documentRef, viewer);
let record = null;
let warmRecord = null;
let generation = 0;
const leavingRecords = new Set();
const prefersReducedMotion = () => (
globalRef.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches === true
);
const scheduleFrame = (callback) => {
if (typeof globalRef.requestAnimationFrame === 'function') {
return { kind: 'frame', id: globalRef.requestAnimationFrame(callback) };
}
if (typeof globalRef.setTimeout === 'function') {
return { kind: 'timer', id: globalRef.setTimeout(callback, 0) };
}
callback();
return null;
};
const cancelScheduledFrame = (scheduled) => {
if (!scheduled) return;
if (scheduled.kind === 'frame') globalRef.cancelAnimationFrame?.(scheduled.id);
else globalRef.clearTimeout?.(scheduled.id);
};
function unsubscribeFacebook(session) {
if (!session?.readyListener) return;
try {
session.api?.Event?.unsubscribe?.('xfbml.ready', session.readyListener);
} catch {
// Provider teardown is best effort.
}
session.readyListener = null;
}
function setFacebookSessionPlayback(session, playing) {
if (!session?.active) return false;
session.shouldPlay = playing === true;
if (!session.player) return false;
try {
if (session.shouldPlay) {
session.player.mute?.();
const result = session.player.play?.();
result?.catch?.(() => {
if (!session.active || !session.shouldPlay) return;
session.onPlaybackBlocked?.();
});
} else {
session.player.pause?.();
}
return true;
} catch {
if (session.shouldPlay) session.onPlaybackBlocked?.();
return false;
}
}
function destroyFacebookSession(session) {
if (!session) return;
session.shouldPlay = false;
globalRef.clearTimeout?.(session.timer);
session.timer = null;
try {
session.player?.pause?.();
} catch {
// Provider teardown is best effort.
}
session.active = false;
unsubscribeFacebook(session);
session.host?.remove?.();
session.player = null;
}
function createFacebookSession(source, host) {
const session = {
active: true,
api: null,
embed: null,
host,
player: null,
ready: false,
error: null,
readyListener: null,
shouldPlay: false,
timer: null,
onReady: null,
onError: null,
onPlaybackBlocked: null,
};
const embed = documentRef.createElement('div');
embed.id = `bhote-koshi-facebook-${++_facebookEmbedSequence}`;
embed.className = 'fb-video';
embed.dataset.href = source.url;
embed.dataset.width = '500';
embed.dataset.showText = 'false';
embed.dataset.allowfullscreen = 'true';
embed.dataset.autoplay = 'false';
session.embed = embed;
host.append(embed);
void facebookLoader(documentRef, globalRef).then((api) => {
if (!session.active) return;
session.api = api;
session.readyListener = (message) => {
if (!session.active || message?.type !== 'video'
|| (message.id && message.id !== embed.id)) return;
globalRef.clearTimeout?.(session.timer);
session.timer = null;
session.player = message.instance;
session.ready = true;
session.error = null;
session.onReady?.();
setFacebookSessionPlayback(session, session.shouldPlay);
};
api.Event.subscribe('xfbml.ready', session.readyListener);
api.XFBML.parse(session.host);
session.timer = globalRef.setTimeout?.(() => {
if (!session.active || session.ready) return;
session.error = new Error(
'Facebook embed unavailable. The post may be private, removed, region-restricted, or blocked.',
);
session.onError?.(session.error);
}, EMBED_TIMEOUT_MS) ?? null;
}).catch((error) => {
if (!session.active) return;
session.error = error;
session.onError?.(error);
});
return session;
}
function removeRecord(current) {
if (!current) return;
cancelScheduledFrame(current.enterFrame);
if (current.exitTimer != null) globalRef.clearTimeout?.(current.exitTimer);
destroyFacebookSession(current.facebookSession);
current.youtubeSession?.destroy();
current.card?.remove?.();
leavingRecords.delete(current);
}
const position = () => {
if (!record) return;
const projected = anchorOnScreen(viewer, record.anchor, root);
record.card.hidden = !projected;
if (projected) {
placeCard(record.card, record.leader, projected);
if (!record.entered) {
record.entered = true;
const current = record;
const token = generation;
current.enterFrame = scheduleFrame(() => {
current.enterFrame = null;
if (token !== generation || record !== current) return;
current.card.classList.add('is-entering');
});
}
}
};
const removePostRender = viewer.scene?.postRender?.addEventListener?.(position);
function hide({ immediate = false } = {}) {
generation += 1;
const current = record;
record = null;
if (!current) return;
current.youtubeSession?.destroy();
setFacebookSessionPlayback(current.facebookSession, false);
cancelScheduledFrame(current.enterFrame);
current.enterFrame = null;
if (immediate || current.card.hidden || prefersReducedMotion()) {
removeRecord(current);
return;
}
current.card.classList.remove('is-entering');
current.card.classList.add('is-leaving');
leavingRecords.add(current);
current.exitTimer = globalRef.setTimeout?.(
() => removeRecord(current),
CARD_EXIT_DURATION_MS,
) ?? null;
}
function clearWarm(key = null) {
if (!warmRecord || (key && warmRecord.key !== key)) return;
if (warmRecord.facebookSession) destroyFacebookSession(warmRecord.facebookSession);
else warmRecord.iframe?.remove?.();
warmRecord = null;
}
function takeWarmFacebook(key, host) {
if (warmRecord?.key !== key || !warmRecord.facebookSession) return null;
const session = warmRecord.facebookSession;
warmRecord = null;
const warmHost = session.host;
warmHost.classList.remove('bhote-embedded-media-warmup');
warmHost.classList.add('bhote-facebook-player-host');
warmHost.removeAttribute?.('aria-hidden');
host.append(warmHost);
return session;
}
function warm({ observation, sourceUrl } = {}) {
const media = observation?.media || {};
const source = resolveEmbeddedMediaSource(sourceUrl || media.sourceUrl);
if (!source || source.provider === 'x') {
clearWarm();
return false;
}
const startAtSec = source.provider === 'youtube'
? Math.max(0, Number(media.embedStartAtSec ?? media.clipInSec) || 0)
: 0;
ensureProviderConnections(documentRef, source.provider);
const endAtSec = media.embedEndAtSec;
const key = `${source.provider}:${source.id || source.url}:${startAtSec}:${endAtSec ?? ''}`;
if (warmRecord?.key === key) return true;
clearWarm();
if (source.provider === 'facebook') {
const host = documentRef.createElement('div');
host.className = 'bhote-embedded-media-warmup';
host.setAttribute('aria-hidden', 'true');
root.append(host);
const facebookSession = createFacebookSession(source, host);
warmRecord = { key, facebookSession };
return true;
}
const iframe = documentRef.createElement('iframe');
iframe.className = 'bhote-embedded-media-warmup';
iframe.src = embeddedMediaFrameUrl(source, { autoplay: false, startAtSec, endAtSec });
iframe.title = `Preloading ${providerLabel(source.provider)} media`;
iframe.tabIndex = -1;
iframe.setAttribute('aria-hidden', 'true');
iframe.loading = 'eager';
iframe.referrerPolicy = 'strict-origin-when-cross-origin';
root.append(iframe);
warmRecord = { key, iframe };
return true;
}
async function mountX(current, token) {
current.status.textContent = 'LOADING X POST…';
try {
const api = await xLoader(documentRef, globalRef);
if (token !== generation || record !== current) return;
const widget = await api.widgets.createTweet(current.source.id, current.player, {
align: 'center',
conversation: 'none',
dnt: true,
theme: 'dark',
width: 390,
});
if (token !== generation || record !== current) return;
if (!widget) throw new Error('The X post is unavailable or cannot be embedded.');
current.status.textContent = 'X POST · PROVIDER CONTROLS';
position();
} catch (error) {
if (token !== generation || record !== current) return;
current.card.classList.add('is-unavailable');
current.status.textContent = `${String(error?.message || 'X embed unavailable')} OPEN ORIGINAL.`;
position();
}
}
function bindFacebook(current, token, warmKey, autoplay) {
const session = takeWarmFacebook(warmKey, current.player)
|| createFacebookSession(current.source, current.player);
current.facebookSession = session;
session.shouldPlay = autoplay === true;
session.onReady = () => {
if (token !== generation || record !== current) return;
current.status.textContent = 'FACEBOOK · PROVIDER CONTROLS';
current.card.classList.remove('is-unavailable');
position();
};
session.onError = (error) => {
if (token !== generation || record !== current) return;
current.card.classList.add('is-unavailable');
current.status.textContent = `${String(error?.message || 'Facebook embed unavailable')} OPEN ORIGINAL.`;
position();
};
session.onPlaybackBlocked = () => {
if (token !== generation || record !== current) return;
current.status.textContent = 'AUTOPLAY BLOCKED · USE PROVIDER CONTROLS';
};
if (session.ready) session.onReady();
else if (session.error) session.onError(session.error);
setFacebookSessionPlayback(session, autoplay);
}
function setPlayback(playing) {
// Removing an uncontrolled provider frame also cancels delayed autoplay.
// Provider commands alone cannot stop a frame that has not loaded yet.
if (!playing && record && !record.facebookSession) {
hide({ immediate: true });
return true;
}
if (!record?.facebookSession) return false;
return setFacebookSessionPlayback(record.facebookSession, playing);
}
function show({ observation, anchor, autoplay = false, sourceUrl } = {}) {
const media = observation?.media || {};
const source = resolveEmbeddedMediaSource(sourceUrl || media.sourceUrl);
if (!source || !anchor) {
hide();
return false;
}
const startAtSec = source.provider === 'youtube'
? Math.max(0, Number(media.embedStartAtSec ?? media.clipInSec) || 0)
: 0;
ensureProviderConnections(documentRef, source.provider);
const endAtSec = media.embedEndAtSec;
const warmKey = `${source.provider}:${source.id || source.url}:${startAtSec}:${endAtSec ?? ''}`;
const key = source.provider === 'facebook'
? warmKey
: `${warmKey}:${autoplay ? 1 : 0}`;
if (record?.key === key) {
record.anchor = anchor;
if (source.provider === 'facebook') setPlayback(autoplay);
position();
return true;
}
hide();
const token = ++generation;
const card = documentRef.createElement('section');
const portraitClass = isPortraitMedia(media) ? ' is-portrait' : '';
card.className = `bhote-embedded-callout is-${source.provider}${portraitClass}`;
card.dataset.provider = source.provider;
card.setAttribute('aria-label', `Embedded ${providerLabel(source.provider)} media for ${observation?.title || 'witness source'}`);
const leader = documentRef.createElementNS('http://www.w3.org/2000/svg', 'svg');
leader.classList.add('bhote-embedded-callout-leader');
leader.setAttribute('aria-hidden', 'true');
leader.innerHTML = '<path pathLength="1" fill="none" stroke="rgba(147,213,228,.72)" stroke-width="1.5"/><circle r="4" fill="#6be8ff" stroke="#ffffff" stroke-width="1.5"/>';
const player = documentRef.createElement('div');
player.className = 'bhote-embedded-callout-player';
const footer = documentRef.createElement('footer');
const title = documentRef.createElement('strong');
title.textContent = String(observation?.shortTitle || observation?.title || 'Witness source').toUpperCase();
const status = documentRef.createElement('small');
status.setAttribute('role', 'status');
status.textContent = `${providerLabel(source.provider)} · EMBEDDED SOURCE`;
const link = documentRef.createElement('a');
link.href = source.url;
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.textContent = 'OPEN ORIGINAL ↗';
footer.append(title, status, link);
const body = documentRef.createElement('div');
body.className = 'bhote-embedded-callout-body';
body.append(player, footer);
card.append(leader, body);
root.append(card);
record = {
key,
source,
anchor,
card,
leader,
player,
status,
entered: false,
};
if (source.provider === 'x') {
void mountX(record, token);
} else if (source.provider === 'facebook') {
bindFacebook(record, token, warmKey, autoplay);
} else {
const iframe = documentRef.createElement('iframe');
iframe.src = embeddedMediaFrameUrl(source, { autoplay, startAtSec, endAtSec });
const controlled = autoplay && Number.isFinite(Number(endAtSec)) && Number(endAtSec) > startAtSec;
if (controlled) {
const url = new URL(iframe.src);
url.searchParams.set('enablejsapi', '1');
if (globalRef.location?.origin) url.searchParams.set('origin', globalRef.location.origin);
iframe.src = url.href;
}
iframe.title = `${providerLabel(source.provider)} media: ${observation?.title || 'witness source'}`;
iframe.allow = 'autoplay; encrypted-media; picture-in-picture; fullscreen';
iframe.allowFullscreen = true;
iframe.loading = 'eager';
iframe.referrerPolicy = 'strict-origin-when-cross-origin';
iframe.addEventListener?.('load', () => {
if (token !== generation || record?.key !== key) return;
clearWarm(warmKey);
if (!record.youtubeSession || ['starting', 'playing'].includes(record.youtubeSession.getState().phase)) {
status.textContent = `${providerLabel(source.provider)} · PROVIDER CONTROLS`;
}
position();
}, { once: true });
player.append(iframe);
if (controlled) {
const current = record;
current.youtubeSession = createTrimmedYouTubePlayback({
iframe, endAtSec: Number(endAtSec), startAtSec, globalRef,
loadApi: () => youtubeLoader(documentRef, globalRef),
isCurrent: () => token === generation && record === current,
onStatus: (phase) => {
if (phase !== 'completed') status.textContent = `YOUTUBE ${phase.toUpperCase()} · OPEN ORIGINAL`;
},
});
}
}
position();
return true;
}
function destroy() {
hide({ immediate: true });
for (const current of [...leavingRecords]) removeRecord(current);
clearWarm();
removePostRender?.();
root.remove();
}
return {
warm,
show,
getPlaybackState: () => record?.youtubeSession?.getState() || null,
play: () => setPlayback(true),
pause: () => setPlayback(false),
hide,
destroy,
};
}
+250
View File
@@ -0,0 +1,250 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import {
createBhoteKoshiEmbeddedMedia,
createTrimmedYouTubePlayback,
embeddedMediaFrameUrl,
publicEmbeddedMediaUrl,
resolveEmbeddedMediaSource,
} from './bhoteKoshiEmbeddedMedia.js';
function trimmedPlaybackFixture() {
let now = 0;
let nextId = 0;
const timers = new Map();
const globalRef = {
setTimeout(fn, delay) { const id = ++nextId; timers.set(id, { at: now + delay, fn }); return id; },
clearTimeout(id) { timers.delete(id); },
};
const advance = (ms) => {
const until = now + ms;
while (true) {
const entry = [...timers].filter(([, item]) => item.at <= until).sort((a, b) => a[1].at - b[1].at)[0];
if (!entry) break;
const [id, item] = entry; timers.delete(id); now = item.at; item.fn();
}
now = until;
};
let events;
const calls = [];
let time = 0;
let state = -1;
const player = {
mute: () => calls.push('mute'), playVideo: () => calls.push('play'),
pauseVideo: () => calls.push('pause'), destroy: () => calls.push('destroy'),
getCurrentTime: () => time,
getPlayerState: () => state,
};
const api = { Player: class { constructor(_iframe, options) { events = options.events; return player; } } };
const statuses = [];
const session = createTrimmedYouTubePlayback({ iframe: {}, endAtSec: 7, globalRef,
loadApi: () => api, onStatus: (phase) => statuses.push(phase) });
return { session, advance, calls, timers, statuses, player, get events() { return events; }, setTime(value) { time = value; }, setState(value) { state = value; } };
}
test('trimmed YouTube reconciles already-playing READY and missed PLAYING callbacks', async () => {
for (const alreadyPlaying of [true, false]) {
const f = trimmedPlaybackFixture();
await Promise.resolve(); await Promise.resolve();
if (alreadyPlaying) { f.setState(1); f.setTime(1); }
f.events.onReady({ target: f.player });
if (!alreadyPlaying) { f.setState(1); f.setTime(1); f.advance(100); }
f.setTime(6); f.advance(5000);
assert.deepEqual(f.session.getState(), { phase: 'playing', currentTime: 6 });
f.setTime(7); f.advance(100);
assert.deepEqual(f.session.getState(), { phase: 'completed', currentTime: 7 });
assert.deepEqual(f.statuses, ['completed']);
assert.equal(f.timers.size, 0);
f.session.destroy();
}
});
test('trimmed YouTube accepts ENDED before PLAYING and never replays a completed READY', async () => {
for (const endedCallback of [true, false]) {
const f = trimmedPlaybackFixture();
await Promise.resolve(); await Promise.resolve();
f.setState(0); f.setTime(7);
if (endedCallback) f.events.onStateChange({ data: 0, target: f.player });
f.events.onReady({ target: f.player });
assert.deepEqual(f.session.getState(), { phase: 'completed', currentTime: 7 });
assert.deepEqual(f.calls, ['pause']);
assert.deepEqual(f.statuses, ['completed']);
assert.equal(f.timers.size, 0);
f.session.destroy();
}
});
test('trimmed YouTube waits through delayed startup, stops at source 0:07, and releases its timers', async () => {
const f = trimmedPlaybackFixture();
await Promise.resolve(); await Promise.resolve();
f.advance(3000);
assert.equal(f.session.getState().phase, 'starting');
f.events.onReady({ target: f.player });
f.events.onStateChange({ data: 1 });
f.setTime(4); f.advance(4000);
assert.equal(f.session.getState().phase, 'playing', 'seven wall-clock seconds do not truncate a delayed clip');
f.setTime(7); f.advance(100);
assert.deepEqual(f.session.getState(), { phase: 'completed', currentTime: 7 });
assert.deepEqual(f.statuses, ['completed']);
assert.equal(f.timers.size, 0);
assert.equal(f.calls.at(-1), 'pause');
f.session.destroy();
});
test('trimmed YouTube bounds blocked, failed, stalled and never-started playback', async () => {
for (const outcome of ['blocked', 'unavailable', 'timeout', 'stalled']) {
const f = trimmedPlaybackFixture();
await Promise.resolve(); await Promise.resolve();
if (outcome === 'blocked') f.events.onAutoplayBlocked();
else if (outcome === 'unavailable') f.events.onError();
else if (outcome === 'stalled') { f.events.onStateChange({ data: 1 }); f.advance(12000); }
else f.advance(5000);
assert.equal(f.session.getState().phase, outcome === 'stalled' ? 'timeout' : outcome);
assert.equal(f.timers.size, 0);
const calls = [...f.calls];
f.events.onReady({ target: f.player });
f.events.onStateChange({ data: 1 });
assert.deepEqual(f.calls, calls, 'late provider callbacks cannot restart a terminal attempt');
f.session.destroy();
}
});
test('trimmed YouTube teardown revokes pending SDK and player callbacks', async () => {
const f = trimmedPlaybackFixture();
await Promise.resolve(); await Promise.resolve();
f.session.destroy();
f.events.onReady({ target: f.player });
f.events.onStateChange({ data: 1 });
f.events.onError(); f.advance(20000);
assert.deepEqual(f.calls, ['destroy']);
assert.deepEqual(f.statuses, []);
assert.equal(f.timers.size, 0);
let constructCount = 0;
let resolveApi;
const session = createTrimmedYouTubePlayback({ iframe: {}, endAtSec: 7,
loadApi: () => new Promise((resolve) => { resolveApi = resolve; }) });
await Promise.resolve();
session.destroy();
resolveApi({ Player: class { constructor() { constructCount++; } } });
await Promise.resolve(); await Promise.resolve();
assert.equal(constructCount, 0);
});
test('pausing YouTube removes its frame and rejects a late load from the stopped generation', () => {
const nodes = [];
class Element extends EventTarget {
constructor(tag) { super(); this.tag = tag; this.children = []; this.dataset = {}; this.classList = { add() {}, remove() {} }; nodes.push(this); }
setAttribute() {}
append(...children) { for (const child of children) { child.parent = this; this.children.push(child); } }
remove() { if (this.parent) this.parent.children = this.parent.children.filter((child) => child !== this); this.parent = null; }
}
const documentRef = {
body: new Element('body'),
createElement: (tag) => new Element(tag),
createElementNS: (_, tag) => new Element(tag),
};
const media = createBhoteKoshiEmbeddedMedia({ viewer: {}, documentRef, globalRef: {} });
const options = { observation: { title: 'Clip', media: { sourceUrl: 'https://youtu.be/DbqRexFxv3k' } }, anchor: {}, autoplay: true };
assert.equal(media.show(options), true);
const frame = nodes.find((node) => node.tag === 'iframe');
const card = nodes.find((node) => node.tag === 'section');
assert.ok(card.parent);
assert.equal(media.pause(), true);
assert.equal(card.parent, null);
frame.dispatchEvent(new Event('load'));
assert.equal(card.parent, null);
assert.equal(media.show(options), true, 'an explicit replay creates a fresh player');
media.destroy();
assert.equal(documentRef.body.children.length, 0);
});
test('resolves supported embedded callout providers from canonical public URLs', () => {
assert.deepEqual(
resolveEmbeddedMediaSource('https://www.youtube.com/watch?v=DbqRexFxv3k'),
{
provider: 'youtube',
id: 'DbqRexFxv3k',
url: 'https://www.youtube.com/watch?v=DbqRexFxv3k',
},
);
assert.equal(
resolveEmbeddedMediaSource('https://www.facebook.com/reel/1571491657757829').provider,
'facebook',
);
assert.deepEqual(
resolveEmbeddedMediaSource('https://x.com/Indowatchosint/status/2092640554568007958'),
{
provider: 'x',
id: '2092640554568007958',
url: 'https://x.com/Indowatchosint/status/2092640554568007958',
},
);
assert.equal(
resolveEmbeddedMediaSource('https://twitter.com/Indowatchosint/status/2092640554568007958').provider,
'x',
);
assert.equal(
resolveEmbeddedMediaSource('https://www.youtube.com/shorts/DbqRexFxv3k').id,
'DbqRexFxv3k',
);
});
test('rejects deceptive hosts, non-post X links, private targets, and iframe markup', () => {
for (const value of [
'https://x.com.evil.example/user/status/2092640554568007958',
'https://x.com/Indowatchosint',
'https://youtube.com.evil.example/watch?v=DbqRexFxv3k',
'http://www.youtube.com/watch?v=DbqRexFxv3k',
'https://localhost/video.mp4',
'<iframe src="https://x.com/user/status/2092640554568007958"></iframe>',
]) {
assert.equal(resolveEmbeddedMediaSource(value), null, value);
}
assert.equal(publicEmbeddedMediaUrl('https://user:[email protected]/name/status/123'), null);
});
test('builds privacy-aware provider frames and keeps X on its widget runtime', () => {
const youtube = resolveEmbeddedMediaSource('https://youtu.be/DbqRexFxv3k');
const youtubeFrame = new URL(embeddedMediaFrameUrl(youtube, { autoplay: true }));
assert.equal(youtubeFrame.hostname, 'www.youtube-nocookie.com');
assert.equal(youtubeFrame.pathname, '/embed/DbqRexFxv3k');
assert.equal(youtubeFrame.searchParams.get('autoplay'), '1');
assert.equal(youtubeFrame.searchParams.get('mute'), '1');
const timestampedFrame = new URL(embeddedMediaFrameUrl(youtube, {
autoplay: true,
startAtSec: 26,
}));
assert.equal(timestampedFrame.searchParams.get('start'), '26');
const facebook = resolveEmbeddedMediaSource('https://www.facebook.com/watch/?v=456');
const facebookFrame = new URL(embeddedMediaFrameUrl(facebook, { autoplay: false }));
assert.equal(facebookFrame.hostname, 'www.facebook.com');
assert.equal(facebookFrame.pathname, '/plugins/video.php');
assert.equal(facebookFrame.searchParams.get('href'), facebook.url);
assert.equal(facebookFrame.searchParams.get('autoplay'), 'false');
const x = resolveEmbeddedMediaSource('https://x.com/Indowatchosint/status/2092640554568007958');
assert.equal(embeddedMediaFrameUrl(x), null);
});
test('Mailung playback stops at source timestamp 0:07 without changing other clips', async () => {
const event = JSON.parse(await readFile(new URL('../../public/events/bhote-koshi-2026/event.json', import.meta.url), 'utf8'));
const bounded = event.evidenceSpine.filter(point => point.media?.embedEndAtSec != null);
assert.deepEqual(bounded.map(point => point.id), ['mailung-bazzar']);
const media = bounded[0].media;
assert.equal(media.embedEndAtSec, 7);
const source = resolveEmbeddedMediaSource(media.sourceUrl);
for (const autoplay of [false, true]) {
const url = new URL(embeddedMediaFrameUrl(source, {
autoplay, startAtSec: media.clipInSec, endAtSec: media.embedEndAtSec,
}));
assert.equal(url.searchParams.get('end'), '7');
}
for (const endAtSec of [undefined, null, NaN, Infinity, -1, 0, 3]) {
const url = new URL(embeddedMediaFrameUrl(source, {startAtSec: 3, endAtSec}));
assert.equal(url.searchParams.has('end'), false);
}
});
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+499
View File
@@ -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]),
]);
+22
View File
@@ -0,0 +1,22 @@
// 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;
File diff suppressed because it is too large. Load diff
+671
View File
@@ -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);
});
+65
View File
@@ -0,0 +1,65 @@
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;
}
+58
View File
@@ -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');
});
+22
View File
@@ -312,6 +312,28 @@ export function transitFeedCredit(feed) {
};
}
/** Registered when the Bhote Koshi event reconstruction activates. */
export const BHOTE_KOSHI_CREDIT = {
key: 'bhote-koshi-2026',
html:
'Bhote Koshi 2026 event imagery and derived reconstruction: ' +
'<a href="https://vantor.com/company/open-data-program" target="_blank" rel="noopener">Vantor Open Data</a> ' +
'and <a href="https://github.com/geo-pera/bhotekoshi-2026-reconstruction" target="_blank" rel="noopener">GeoPera</a> ' +
'(CC BY-NC 4.0); terrain © Re:Earth / Mapterhorn (CC BY 4.0)',
};
/** Registered when the scene-friendly Nepal incident locator activates. */
export const BHOTE_KOSHI_LOCATOR_CREDIT = {
key: 'bhote-koshi-locator',
html:
'Nepal administrative boundary and nearby-city locations: ' +
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
'(ODbL 1.0); incident-place anchors use an owner-curated, source-verified geolocation union; ' +
'flood corridor derived from the ' +
'<a href="https://github.com/geo-pera/bhotekoshi-2026-reconstruction/blob/main/vectors/river_centerline.geojson" target="_blank" rel="noopener">GeoPera river centerline</a> ' +
'(CC BY-NC 4.0)',
};
/** @type {Set<string>} Keys of dynamic credits already registered this session. */
const _dynamicCreditKeys = new Set();
+2
View File
@@ -277,6 +277,8 @@ export const SHARE_TRACKING_RESTORE_POLICIES = Object.freeze({
export const LAYER_STATE_REGISTRY = Object.freeze([
Object.freeze({ id: 'ais-live-vessels', token: 'a', disposition: 'enabled-only' }),
Object.freeze({ id: 'alpr-cameras', token: 'p', disposition: 'enabled-only' }),
Object.freeze({ id: 'bhote-koshi-2026', token: 'h', disposition: 'enabled-only' }),
Object.freeze({ id: 'bhote-koshi-locator', token: 'z', disposition: 'enabled-only' }),
Object.freeze({ id: 'bikeshare', token: 'b', disposition: 'enabled-only' }),
Object.freeze({ id: 'cctv', token: 'c', disposition: 'enabled+options', optionOwner: 'cctv' }),
Object.freeze({ id: 'directions', token: 'n', disposition: 'enabled-only' }),
+11 -4
View File
@@ -158,8 +158,9 @@ function encode(state) {
test('production registry is exact, canonical, and rejects incomplete contracts', async () => {
assert.equal(validateLayerStateRegistry(), true);
assert.equal(REGISTERED_LAYER_IDS.length, 19);
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 19);
assert.equal(REGISTERED_LAYER_IDS.length, 21);
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 21);
assert.ok(REGISTERED_LAYER_IDS.includes('transit'));
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
assert.throws(
() => validateLayerStateRegistry([...LAYER_STATE_REGISTRY, LAYER_STATE_REGISTRY[0]]),
@@ -226,8 +227,14 @@ test('v2 codec distinguishes absent from empty and keeps canonical deterministic
});
test('unknown enabled-layer tokens reject the payload instead of becoming an empty set', () => {
assert.equal(decodeLayerStateParams(new URLSearchParams('v=2&l=z')), null);
assert.equal(decodeLayerStateParams(new URLSearchParams('v=2&l=c.z')), null);
assert.equal(decodeLayerStateParams(new URLSearchParams('v=2&l=unknown')), null);
assert.equal(decodeLayerStateParams(new URLSearchParams('v=2&l=c.unknown')), null);
});
test('Nepal event and locator have distinct enabled-only share tokens', () => {
const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=h.z'));
assert.deepEqual(decoded.enabledLayerIds, ['bhote-koshi-2026', 'bhote-koshi-locator']);
assert.ok(encode(decoded).includes('l=h.z'));
});
test('unknown and forbidden option fields are ignored while missing options use codec defaults', () => {
+16 -4
View File
@@ -180,12 +180,24 @@ export class MapSourceController {
async _activateGlobeStack(stack, gen) {
const resolution = await this._getImageryProvider(stack);
if (gen !== this._switchGen) return;
this._removeImageryLayer();
this._imageryLayer = this._createImageryLayer(resolution.provider);
this._activeImageryProvider = resolution.provider;
this.viewer.imageryLayers.add(this._imageryLayer, 0);
// Scene shots reapply their map stack at every handoff. Keep the live
// layer (and its loaded tiles) when the resolved provider is unchanged;
// rebuilding it exposes the bare globe while imagery loads again.
if (
!this._imageryLayer ||
this._activeImageryProvider !== resolution.provider
) {
this._removeImageryLayer();
this._imageryLayer = this._createImageryLayer(resolution.provider);
this._activeImageryProvider = resolution.provider;
this.viewer.imageryLayers.add(this._imageryLayer, 0);
}
const source = this._sources.get(resolution.effectiveStackId);
this._credits.show(source?.credit || null);
// A repeated request still owns a new switch generation. Rebind its
// failure listener so fallback remains live without accumulating listeners.
this._removeImageryErrorListener?.();
this._removeImageryErrorListener = null;
this._watchProvider(resolution, gen);
this._showTileset(null);
this.viewer.scene.globe.show = true;
+55 -1
View File
@@ -124,14 +124,68 @@ test('an additional imagery source needs no controller branch and owns its cache
assert.equal(reads, 1);
assert.equal(terrainReads, 1);
assert.equal(env.imagery.length, 1);
assert.equal(env.removed[0].destroy, true);
assert.equal(
env.removed.length,
0,
'same provider keeps its loaded imagery layer',
);
env.controller.destroy();
env.controller.destroy();
await settle();
assert.equal(env.imagery.length, 0);
assert.equal(env.removed[0].destroy, true);
assert.equal(destroys, 1);
});
test('repeated Esri shot handoffs retain imagery and keep tile fallback live', async () => {
const env = publicFixture();
await env.controller.setStack('esri-imagery');
const layer = env.imagery[0];
const generation = env.controller.getSwitchGeneration();
const errors = env.providers.get('esri-imagery').errorEvent;
for (let i = 0; i < 3; i++) await env.controller.setStack('esri-imagery');
assert.equal(env.imagery[0], layer);
assert.equal(env.removed.length, 0);
assert.equal(errors.size, 1);
assert.equal(env.controller.getSwitchGeneration(), generation + 3);
errors.raise();
errors.raise();
await settle();
assert.equal(env.controller.getActiveId(), 'osm');
assert.equal(env.removed.length, 1);
assert.equal(errors.size, 0);
env.controller.destroy();
});
test('returning to the live provider supersedes a pending switch without rebuilding imagery', async () => {
const env = publicFixture();
await env.controller.setStack('esri-imagery');
const layer = env.imagery[0];
let resolve;
env.registry.sources.find(
(source) => source.descriptor.id === 'osm',
).imagery = () =>
new Promise((done) => {
resolve = done;
});
const pending = env.controller.setStack('osm');
await settle();
await env.controller.setStack('esri-imagery');
resolve(env.providers.get('osm'));
await pending;
assert.equal(env.controller.getActiveId(), 'esri-imagery');
assert.equal(env.imagery[0], layer);
assert.equal(env.removed.length, 0);
await env.controller.setStack('photoreal');
assert.equal(env.imagery.length, 0);
assert.equal(env.tileset.show, true);
await env.controller.setStack('esri-imagery');
assert.notEqual(env.imagery[0], layer, 'a removed layer must be recreated');
assert.equal(env.viewer.scene.globe.show, true);
assert.equal(env.tileset.show, false);
env.controller.destroy();
});
test('a destroyed controller aborts creation and disposes a late provider without touching the scene', async () => {
let resolve,
signal,
+61 -1
View File
@@ -2,7 +2,67 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createIonImagery } from './imagery.js';
import { createWorldTerrain } from './terrain.js';
import { createWorldTerrain, createKeylessTerrain } from './terrain.js';
import { createDefaultMapSources } from './defaultSources.js';
test('Esri uses Re:Earth without keys and preserves ion terrain when configured', async () => {
const originalTerrain = Cesium.CesiumTerrainProvider.fromUrl;
const originalResource = Cesium.IonResource.fromAssetId;
const calls = [];
try {
Cesium.CesiumTerrainProvider.fromUrl = async (resource) => {
calls.push(resource);
return { resource };
};
Cesium.IonResource.fromAssetId = async () =>
assert.fail('keyless mode must not acquire ion terrain');
const keyless = createDefaultMapSources();
const esri = keyless.sources.find(
({ descriptor }) => descriptor.id === 'esri-imagery',
);
assert.equal(esri.available, true);
assert.equal(esri.terrain.id, 'keyless');
assert.equal(esri.terrain.create, createKeylessTerrain);
await esri.terrain.create();
assert.deepEqual(calls, [
'https://terrain.reearth.land/cesium-mesh/ellipsoid',
]);
assert.equal(
keyless.sources.find(({ descriptor }) => descriptor.id === 'photoreal')
.available,
false,
);
for (const credentials of [
{ googleApiKey: 'test-key' },
{ cesiumToken: 'test-token' },
]) {
const keyed = createDefaultMapSources({
...credentials,
googleTileset: { show: true },
});
assert.equal(
keyed.sources.find(({ descriptor }) => descriptor.id === 'photoreal')
.available,
true,
);
assert.equal(
keyed.sources.find(({ descriptor }) => descriptor.id === 'esri-imagery')
.terrain.id,
credentials.cesiumToken ? 'world' : 'keyless',
);
const failedTileset = createDefaultMapSources(credentials);
assert.equal(
failedTileset.sources.find(
({ descriptor }) => descriptor.id === 'photoreal',
).available,
false,
);
}
} finally {
Cesium.CesiumTerrainProvider.fromUrl = originalTerrain;
Cesium.IonResource.fromAssetId = originalResource;
}
});
test('imagery and terrain pass their own ion token without relying on SDK defaults', async () => {
const originalImagery = Cesium.IonImageryProvider.fromAssetId;
+65 -4
View File
@@ -103,6 +103,7 @@ export const WORLD_OVERLAY_OCCLUDER_SELECTORS = Object.freeze([
'#space-mission-panel',
'#space-mission-panel-host',
'#military-awareness-panel',
'#bhote-koshi-event-panel',
// Cockpit: solid backdrop-filled windows only (both bounded to
// `min(340px, 28vw)` wide and `min(42vh, 410px)` tall, both `hidden` until
// toggled). Every other cockpit selector was removed — see the block comment.
@@ -460,7 +461,20 @@ export function normalizeOverlayEntry(sourceId, entry) {
edgeFade: entry.edgeFade === false || entry.edgeFade === 'none' ? 'none' : 'keyhole',
horizonCull: entry.horizonCull !== false,
terrainOcclusion: entry.terrainOcclusion === true,
sourceAlpha: clamp01(entry.sourceAlpha ?? entry.alpha, 1),
sourceAlpha: typeof (entry.sourceAlpha ?? entry.alpha) === 'function'
? (entry.sourceAlpha ?? entry.alpha)
: clamp01(entry.sourceAlpha ?? entry.alpha, 1),
presentationScale: typeof entry.presentationScale === 'function'
? entry.presentationScale
: Math.max(0, Number(entry.presentationScale) || 1),
leaderProgress: typeof entry.leaderProgress === 'function'
? entry.leaderProgress
: clamp01(entry.leaderProgress, 1),
contentAlpha: typeof entry.contentAlpha === 'function'
? entry.contentAlpha
: clamp01(entry.contentAlpha, 1),
anchorDot: entry.anchorDot === true,
leaderStyle: entry.leaderStyle === 'elbow' ? 'elbow' : 'straight',
temporalAlpha: clamp01(entry.temporalAlpha, 1),
gapPx: Number.isFinite(Number(entry.gapPx)) ? Math.max(0, Number(entry.gapPx)) : 12,
leaderOffsetPx: Number.isFinite(Number(entry.leaderOffsetPx))
@@ -1513,6 +1527,8 @@ function getProjectionRecord(entry) {
candidate: null,
distanceAlpha: 1,
paintScale: 1,
leaderProgress: 1,
contentAlpha: 1,
altitudeAlpha: 1,
sourceAlpha: 1,
protectedPlacement: null,
@@ -1575,6 +1591,30 @@ function resetFrameDomains() {
}
}
// Evaluate optional scene presentation outside the shared projection hot path.
// Passing records (rather than intermediate doubles) also avoids boxing the
// ordinary moving-source workload at a non-inlined call boundary.
function applyPresentation(entry, source, record) {
try {
const scale = Number(typeof entry.presentationScale === 'function'
? entry.presentationScale() : entry.presentationScale);
record.paintScale *= Number.isFinite(scale) ? Math.max(0, scale) : 1;
const alpha = Number(typeof entry.sourceAlpha === 'function'
? entry.sourceAlpha() : entry.sourceAlpha);
record.sourceAlpha = source.options.alpha
* (Number.isFinite(alpha) ? Math.max(0, Math.min(1, alpha)) : 1);
const leader = Number(typeof entry.leaderProgress === 'function'
? entry.leaderProgress() : entry.leaderProgress);
record.leaderProgress = Number.isFinite(leader) ? Math.max(0, Math.min(1, leader)) : 1;
const content = Number(typeof entry.contentAlpha === 'function'
? entry.contentAlpha() : entry.contentAlpha);
record.contentAlpha = Number.isFinite(content) ? Math.max(0, Math.min(1, content)) : 1;
return true;
} catch {
return false;
}
}
function snapshotAndProject(entry, source, viewProjection, keyhole) {
const record = getProjectionRecord(entry);
let position;
@@ -1651,7 +1691,14 @@ function snapshotAndProject(entry, source, viewProjection, keyhole) {
record.altitudeAlpha = entry.altitudeFadeEnd === Number.POSITIVE_INFINITY
? (Number.isFinite(cameraAltitude) && cameraAltitude < entry.minAltitude ? 0 : 1)
: altitudeFade(cameraAltitude, record.altitudeOptions);
record.sourceAlpha = source.options.alpha * entry.sourceAlpha;
record.leaderProgress = 1;
record.contentAlpha = 1;
if (entry.presentationScale !== 1 || entry.leaderProgress !== 1
|| entry.contentAlpha !== 1 || typeof entry.sourceAlpha === 'function') {
if (!applyPresentation(entry, source, record)) return null;
} else {
record.sourceAlpha = source.options.alpha * entry.sourceAlpha;
}
if (record.distanceAlpha <= 0 || record.paintScale <= 0
|| record.altitudeAlpha <= 0 || record.sourceAlpha <= 0) return null;
@@ -2075,7 +2122,14 @@ function paintEntryItem(item, keyhole) {
* record.distanceAlpha * record.altitudeAlpha * keyholeAlpha;
if (finalAlpha <= 0.001) return;
if (record.paintScale === 1) {
paintOverlayEntry(_ctx, entry, placement, finalAlpha);
paintOverlayEntry(
_ctx,
entry,
placement,
finalAlpha,
record.leaderProgress,
record.contentAlpha,
);
} else {
const scaled = localizeScaledPlacement(
placement,
@@ -2085,7 +2139,14 @@ function paintEntryItem(item, keyhole) {
_ctx.save();
_ctx.translate(placement.rect.x, placement.rect.y);
_ctx.scale(record.paintScale, record.paintScale);
paintOverlayEntry(_ctx, entry, scaled, finalAlpha);
paintOverlayEntry(
_ctx,
entry,
scaled,
finalAlpha,
record.leaderProgress,
record.contentAlpha,
);
_ctx.restore();
}
publishPaintRect(item);
+79
View File
@@ -1821,6 +1821,85 @@ test('CCTV host binding preserves the smoothstep scale and 0.45/0.35 fade curve'
}
});
test('scene presentation samples once per frame and recovers after a callback failure', () => {
const env = installMockEnvironment({ width: 500, height: 400, dpr: 1 });
const alphas = [];
Object.defineProperty(env.ctx, 'globalAlpha', {
configurable: true,
get() { return alphas.at(-1) ?? 1; },
set(value) { alphas.push(value); },
});
initWorldOverlay(env.viewer);
const entry = createCctvThumbnailOverlayEntry({
id: 'scene-presentation', position: position(), title: 'SCENE',
frameSlot: { frame: { ready: true }, stamp: 1 }, active: true,
});
Object.assign(entry, {
horizonCull: false, maxDistance: Number.POSITIVE_INFINITY, edgeFade: 'none',
altitudeScale: null, altitudeFadeEnd: Number.POSITIVE_INFINITY,
});
let scale = 0.5;
let alpha = 0.8;
let leader = 0.5;
let content = 0.25;
let fail = false;
const sampled = { scale: 0, alpha: 0, leader: 0, content: 0 };
entry.presentationScale = () => { sampled.scale++; return scale; };
entry.sourceAlpha = () => {
sampled.alpha++;
if (fail) throw new Error('presentation unavailable');
return alpha;
};
entry.leaderProgress = () => { sampled.leader++; return leader; };
entry.contentAlpha = () => { sampled.content++; return content; };
const publish = () => setOverlayEntries('scene-presentation', [entry], {
cohortLimit: 1, collisionCapacity: 0,
});
const frame = () => {
env.ctx.calls.length = 0;
alphas.length = 0;
env.postRender.raise();
};
try {
publish();
frame();
assert.deepEqual(sampled, { scale: 1, alpha: 1, leader: 1, content: 1 });
assert.equal(env.ctx.calls.find(([name]) => name === 'scale')?.[1], 0.5);
assert.ok(Math.abs(alphas.at(-1) - 0.2) < 1e-12);
assert.ok(env.ctx.calls.some(([name]) => name === 'drawImage'));
fail = true;
assert.doesNotThrow(frame);
assert.equal(env.ctx.calls.some(([name]) => name === 'drawImage'), false);
assert.deepEqual(sampled, { scale: 2, alpha: 2, leader: 1, content: 1 });
fail = false;
scale = Number.NaN;
alpha = 2;
leader = Number.NaN;
content = 2;
frame();
assert.equal(env.ctx.calls.some(([name]) => name === 'scale'), false);
assert.equal(alphas.at(-1), 1);
assert.ok(env.ctx.calls.some(([name]) => name === 'drawImage'));
scale = 0;
frame();
assert.equal(env.ctx.calls.some(([name]) => name === 'drawImage'), false);
// Reusing the record for an ordinary card must not retain animated values.
Object.assign(entry, {
presentationScale: 1, sourceAlpha: 1, leaderProgress: 1, contentAlpha: 1,
});
publish();
frame();
assert.equal(alphas.at(-1), 1);
assert.ok(env.ctx.calls.some(([name]) => name === 'drawImage'));
} finally {
env.cleanup();
}
});
test('CCTV leader remains one CSS pixel at the 0.35 altitude scale point', () => {
const env = installMockEnvironment({ width: 500, height: 400, dpr: 1 });
env.viewer.camera.positionCartographic.height = 9500;
+134 -27
View File
@@ -311,7 +311,10 @@ export function measureOverlayEntry(ctx, entry, out = {}) {
out.padY = Math.max(0, Number(entry?.thumbnailPadTop) || 0);
out.padBottom = Math.max(0, Number(entry?.thumbnailPadBottom) || 0);
out.titleGap = Math.max(0, Number(entry?.thumbnailTitleGap) || 0);
out.titleH = Math.max(0, Number(entry?.thumbnailTitleHeight) || out.titleH);
const thumbnailTitleHeight = Number(entry?.thumbnailTitleHeight);
out.titleH = Number.isFinite(thumbnailTitleHeight)
? Math.max(0, thumbnailTitleHeight)
: out.titleH;
out.thumbW = Math.max(1, Number(entry?.thumbnailWidth) || 96);
out.thumbH = Math.max(1, Number(entry?.thumbnailHeight) || 54);
out.w = out.thumbW + out.padX * 2;
@@ -444,28 +447,82 @@ export function placementVariants({
return out;
}
function drawLeader(ctx, placement, accent) {
function drawLeader(
ctx,
placement,
accent,
progress = 1,
width = 1,
style = 'straight',
) {
const reveal = Math.max(0, Math.min(1, Number(progress) || 0));
if (reveal <= 0) return;
ctx.strokeStyle = accent || WORLD_OVERLAY_STYLE.leader;
// Leaders are screen-space strokes. Card content may be painted inside an
// altitude/distance scale transform, so counter-scale the canvas width to
// retain the shipped one-CSS-pixel leader at every card size.
ctx.lineWidth = 1 / (placement.paintScale || 1);
ctx.beginPath();
if (placement.leaderOffset === 0) {
ctx.moveTo(placement.leadFromX, placement.leadFromY);
} else if (placement.corner === 'above' || placement.corner === 'below') {
ctx.moveTo(placement.leadFromX, placement.leadFromY + placement.leaderOffset);
} else {
ctx.moveTo(placement.leadFromX + placement.leaderOffset, placement.leadFromY);
ctx.lineWidth = width / (placement.paintScale || 1);
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
let startX = placement.leadFromX;
let startY = placement.leadFromY;
if (placement.leaderOffset !== 0) {
if (placement.corner === 'above' || placement.corner === 'below') {
startY += placement.leaderOffset;
} else {
startX += placement.leaderOffset;
}
}
ctx.lineTo(placement.leadToX, placement.leadToY);
ctx.beginPath();
ctx.moveTo(startX, startY);
if (
style === 'elbow'
&& (placement.corner === 'left' || placement.corner === 'right')
) {
const elbowY = placement.rect.y + Math.min(
Math.max(12, placement.rect.h * 0.12),
26,
);
const verticalLength = Math.abs(elbowY - startY);
const horizontalLength = Math.abs(placement.leadToX - startX);
const totalLength = verticalLength + horizontalLength;
const visibleLength = totalLength * reveal;
if (visibleLength <= verticalLength && verticalLength > 0) {
const direction = Math.sign(elbowY - startY);
ctx.lineTo(startX, startY + visibleLength * direction);
} else {
ctx.lineTo(startX, elbowY);
const remaining = Math.max(0, visibleLength - verticalLength);
const direction = Math.sign(placement.leadToX - startX);
ctx.lineTo(startX + Math.min(horizontalLength, remaining) * direction, elbowY);
}
ctx.stroke();
return;
}
ctx.lineTo(
startX + (placement.leadToX - startX) * reveal,
startY + (placement.leadToY - startY) * reveal,
);
ctx.stroke();
}
function drawCardChrome(ctx, entry, placement, selected = false) {
function drawAnchorDot(ctx, entry, placement) {
if (!entry.anchorDot) return;
const paintScale = placement.paintScale || 1;
const radius = WORLD_OVERLAY_STYLE.anchorDotRadius / paintScale;
ctx.beginPath();
ctx.arc(placement.anchorX, placement.anchorY, radius, 0, Math.PI * 2);
ctx.fillStyle = entry.accent || WORLD_OVERLAY_STYLE.accent;
ctx.fill();
ctx.strokeStyle = WORLD_OVERLAY_STYLE.anchorDotStroke;
ctx.lineWidth = WORLD_OVERLAY_STYLE.anchorDotStrokeWidth / paintScale;
ctx.stroke();
}
function drawCardChrome(ctx, entry, placement, selected = false, drawLeaderLine = true) {
const { x, y, w, h } = placement.rect;
const accent = entry.accent || WORLD_OVERLAY_STYLE.accent;
drawLeader(ctx, placement, accent);
if (drawLeaderLine) drawLeader(ctx, placement, accent, 1, 1, entry.leaderStyle);
ctx.beginPath();
roundedRectPath(ctx, x, y, w, h);
ctx.fillStyle = selected ? WORLD_OVERLAY_STYLE.selectedBackground : WORLD_OVERLAY_STYLE.background;
@@ -667,18 +724,46 @@ export function paintTrack(ctx, entry, placement, alpha = 1) {
return placement.rect;
}
/** Paint a standard detail card. */
export function paintCard(ctx, entry, placement, alpha = 1) {
/** Paint a standard detail card, with an opt-in staged anchor/leader reveal. */
export function paintCard(
ctx,
entry,
placement,
alpha = 1,
leaderProgress = 1,
contentAlpha = 1,
) {
ctx.save();
ctx.globalAlpha = alpha;
drawCardChrome(ctx, entry, placement, false);
if (entry.anchorDot) {
drawLeader(
ctx,
placement,
entry.accent || WORLD_OVERLAY_STYLE.accent,
leaderProgress,
WORLD_OVERLAY_STYLE.leaderWidth,
entry.leaderStyle,
);
drawAnchorDot(ctx, entry, placement);
ctx.globalAlpha = alpha * Math.max(0, Math.min(1, Number(contentAlpha) || 0));
drawCardChrome(ctx, entry, placement, false, false);
} else {
drawCardChrome(ctx, entry, placement, false);
}
drawCardText(ctx, entry, placement, false);
ctx.restore();
return placement.rect;
}
/** Paint a thumbnail card. The image slot remains source-owned. */
export function paintThumbnail(ctx, entry, placement, alpha = 1) {
export function paintThumbnail(
ctx,
entry,
placement,
alpha = 1,
leaderProgress = 1,
contentAlpha = 1,
) {
const layout = entry._overlayLayout || {};
const { x, y, w, h } = placement.rect;
const padX = Number.isFinite(layout.padX) ? layout.padX : 4;
@@ -690,7 +775,16 @@ export function paintThumbnail(ctx, entry, placement, alpha = 1) {
ctx.save();
ctx.globalAlpha = alpha;
drawLeader(ctx, placement, entry.thumbnailLeaderColor || WORLD_OVERLAY_STYLE.leader);
drawLeader(
ctx,
placement,
entry.thumbnailLeaderColor || WORLD_OVERLAY_STYLE.leader,
leaderProgress,
entry.anchorDot ? WORLD_OVERLAY_STYLE.leaderWidth : 1,
entry.leaderStyle,
);
drawAnchorDot(ctx, entry, placement);
ctx.globalAlpha = alpha * Math.max(0, Math.min(1, Number(contentAlpha) || 0));
ctx.beginPath();
roundedRectPath(ctx, x, y, w, h, Number(entry.thumbnailRadius) || 4);
ctx.fillStyle = entry.thumbnailBackground || WORLD_OVERLAY_STYLE.background;
@@ -722,11 +816,13 @@ export function paintThumbnail(ctx, entry, placement, alpha = 1) {
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = entry.thumbnailTitleColor || WORLD_OVERLAY_STYLE.title;
ctx.font = entry.thumbnailTitleFont || WORLD_OVERLAY_STYLE.fontTitle;
ctx.fillText(
titleChars > 0 ? title.slice(0, titleChars) : title,
imageX,
imageY + thumbH + titleH - 3,
);
if (title && titleH > 0) {
ctx.fillText(
titleChars > 0 ? title.slice(0, titleChars) : title,
imageX,
imageY + thumbH + titleH - 3,
);
}
ctx.restore();
return placement.rect;
}
@@ -749,7 +845,7 @@ export function paintTracked(ctx, entry, placement, alpha = 1) {
const accent = entry.accent || WORLD_OVERLAY_STYLE.accent;
ctx.save();
ctx.globalAlpha = alpha;
drawLeader(ctx, placement, accent);
drawLeader(ctx, placement, accent, 1, 1, entry.leaderStyle);
ctx.beginPath();
roundedRectPath(ctx, x, y, w, h, 5);
ctx.fillStyle = WORLD_OVERLAY_STYLE.background;
@@ -851,12 +947,23 @@ export function paintDetectionCallout(ctx, callout, alpha = 1) {
}
/** Dispatch a normalized entry to its pure variant painter. */
export function paintOverlayEntry(ctx, entry, placement, alpha = 1) {
export function paintOverlayEntry(
ctx,
entry,
placement,
alpha = 1,
leaderProgress = 1,
contentAlpha = 1,
) {
if (entry.cardStyle === 'tactical') return paintTacticalCard(ctx, entry, placement, alpha);
if (entry.variant === 'tracked') return paintTracked(ctx, entry, placement, alpha);
if (entry.selected || entry.variant === 'selected') return paintSelected(ctx, entry, placement, alpha);
if (entry.variant === 'thumbnail') return paintThumbnail(ctx, entry, placement, alpha);
if (entry.variant === 'card') return paintCard(ctx, entry, placement, alpha);
if (entry.variant === 'thumbnail') {
return paintThumbnail(ctx, entry, placement, alpha, leaderProgress, contentAlpha);
}
if (entry.variant === 'card') {
return paintCard(ctx, entry, placement, alpha, leaderProgress, contentAlpha);
}
if (entry.variant === 'track') return paintTrack(ctx, entry, placement, alpha);
return paintLabel(ctx, entry, placement, alpha);
}
+4
View File
@@ -24,6 +24,10 @@ export const WORLD_OVERLAY_STYLE = Object.freeze({
fontTrackedTitle: '600 13px "JetBrains Mono", monospace',
fontTrackedDetail: '500 11px "JetBrains Mono", monospace',
radius: 4,
anchorDotRadius: 3.2,
anchorDotStrokeWidth: 1,
anchorDotStroke: 'rgba(4, 12, 16, 0.96)',
leaderWidth: 1.35,
});
/** CCTV's field-tested thumbnail-card overrides on top of shared card chrome. */
+1246 -76
View File
File diff suppressed because it is too large. Load diff
+701 -2
View File
@@ -17,7 +17,550 @@ import test from 'node:test';
import { SceneDirector } from './director.js';
import { SCENE_TRACKING_PARAM_KEYS } from './scenePolicy.js';
import { SCENE_RECIPES } from './recipes.js';
import { SCENE_RECIPES, getSceneAppendRecipeById } from './recipes.js';
const NEPAL_ORIGINAL_SHOT_TITLES = [
'Global Incident Context',
'Nepal-Focused Globe Rotation',
'Bhote Koshi Regional Approach',
'Bhote Koshi Nearby Cities',
'Bhote Koshi Incident Corridor',
'Bhote Koshi Flood Path',
'Bhote Koshi Corridor Overview',
'Bhote Koshi Upper Valley',
];
function nepalProjectFixture() {
const project = structuredClone(PROJECT_FIXTURE);
project.scenes[0].title = 'Nepal Flood Incident';
project.scenes[0].shots = NEPAL_ORIGINAL_SHOT_TITLES.map((title, index) => ({
...structuredClone(PROJECT_FIXTURE.scenes[0].shots[index % 2]),
id: `nepal-original-${index + 1}`,
title,
camera: {
lat: 27 + index / 10,
lon: 85 + index / 10,
alt: 1000 + index * 100,
heading: index * 10,
pitch: -35 - index,
roll: 0,
},
visual: {
...structuredClone(PROJECT_FIXTURE.scenes[0].shots[index % 2].visual),
mapStack: 'photoreal',
},
layers: {
...structuredClone(PROJECT_FIXTURE.scenes[0].shots[index % 2].layers),
'bhote-koshi-locator': {
enabled: index > 0,
params: { presentation: `fixture-${index + 1}` },
},
},
}));
project.scenes[0].shots[7].layers['bhote-koshi-locator'] = {
enabled: true,
params: { presentation: 'bhote-koshi-trigger-record' },
};
project.scenes[0].shots[7].holdSec = 8;
return project;
}
function legacyThreeShotNepalProjectFixture() {
const project = structuredClone(PROJECT_FIXTURE);
project.scenes[0].title = 'Nepal Flood Incident';
project.scenes[0].shots = [0, 1, 2].map((index) => ({
...structuredClone(PROJECT_FIXTURE.scenes[0].shots[index % 2]),
id: `legacy-nepal-${index + 1}`,
title: `Shot ${index + 1}`,
camera: {
lat: 20 + index,
lon: 80 + index,
alt: 1000000 + index * 100000,
heading: index * 15,
pitch: -50 - index,
roll: 0,
},
}));
return project;
}
function legacyDefaultProjectWithoutNepalFixture() {
const project = structuredClone(PROJECT_FIXTURE);
project.scenes[0].id = 'bhote-koshi-flood';
project.scenes[0].title = 'Bhote Koshi Flood Reconstruction';
project.scenes[0].shots = [{
...structuredClone(PROJECT_FIXTURE.scenes[0].shots[0]),
id: 'standalone-shot',
title: 'Shot 1',
}];
return project;
}
test('Mailung clip trim estimates seven seconds and its exit, including older saved holds', () => {
const { director, restore } = makeDirector();
try {
const scene = director._project.scenes[0];
const shot = scene.shots[0];
shot.sourcePackId = 'bhote-koshi-nepal-evidence-pack';
shot.durationSec = 4.2;
shot.holdSec = 15;
shot.layers = { 'bhote-koshi-2026': { enabled: true, params: {
presentation: 'scene-beat', beatId: 'mailung-bazzar',
} } };
assert.equal(director._effectiveShotHoldSec(scene, shot), 7.65);
assert.equal(director._shotRuntimeDurationSec(scene, shot), 4.2 + 7.65);
const state = director._layerStatesForShot(scene, shot)['bhote-koshi-2026'];
assert.equal(state.params.sceneContext.holdSec, 7.65);
assert.equal(state.params.sceneControls.mediaPlaybackHoldSec, 7);
assert.equal(state.params.sceneControls.minimumHoldSec, 7.65);
assert.equal(shot.holdSec, 15, 'runtime correction does not rewrite saved shots');
shot.layers['bhote-koshi-2026'].params.beatId = 'dandagaun';
assert.equal(director._effectiveShotHoldSec(scene, shot), 15, 'other shots keep their authored hold');
} finally { restore(); }
});
test('Incident Corridor gives all overview pins time to reveal without rewriting saved shots', () => {
const { director, restore } = makeDirector();
try {
const scene = director._project.scenes[0];
const shot = scene.shots[0];
shot.holdSec = 0.9;
shot.layers = { 'bhote-koshi-locator': {
enabled: true, params: { presentation: 'bhote-koshi-incident-places' },
} };
assert.equal(director._effectiveShotHoldSec(scene, shot), 11);
assert.equal(shot.holdSec, 0.9);
shot.holdSec = 15;
assert.equal(director._effectiveShotHoldSec(scene, shot), 15);
shot.holdSec = 0.9;
shot.layers['bhote-koshi-locator'].params.presentation = 'bhote-koshi-flood-path';
assert.equal(director._effectiveShotHoldSec(scene, shot), 0.9);
shot.layers['bhote-koshi-locator'].params.presentation = 'bhote-koshi-incident-places';
shot.layers['bhote-koshi-locator'].enabled = false;
assert.equal(director._effectiveShotHoldSec(scene, shot), 0.9);
} finally {
restore();
}
});
test('scene clock seek resolves the exact shot phase and camera in both directions', async () => {
const { director, restore } = makeDirector();
const loads = [];
director._loadShot = async (sceneId, shotId, options) => {
loads.push({ sceneId, shotId, options });
return { started: true, shotId };
};
try {
assert.equal(await director.seekScene('scene-1', 0), true);
assert.equal(await director.seekScene('scene-1', 0.99), true);
assert.equal(await director.seekScene('scene-1', 0.25), true);
assert.equal(await director.seekScene('missing', 0.5), false);
assert.deepEqual(loads.map(({ sceneId, shotId }) => ({ sceneId, shotId })), [
{ sceneId: 'scene-1', shotId: 'shot-a' },
{ sceneId: 'scene-1', shotId: 'shot-b' },
{ sceneId: 'scene-1', shotId: 'shot-a' },
]);
assert.equal(loads[0].options.sceneSeek.sceneProgress, 0);
assert.equal(loads[0].options.sceneSeek.cameraProgress, 0);
assert.ok(loads[1].options.sceneSeek.cameraProgress > 0.9);
assert.equal(loads[1].options.sceneSeek.shotIndex, 1);
assert.equal(loads[2].options.sceneSeek.sceneProgress, 0.25);
assert.equal(loads[2].options.sceneSeek.cameraProgress, 0.5);
assert.equal(loads[2].options.sceneSeek.camera.lat, 10);
assert.equal(loads[2].options.sceneSeek.camera.lon, 20);
} finally {
restore();
}
});
test('scene clock subscribers receive authoritative forward playback snapshots', () => {
const { director, restore } = makeDirector();
const snapshots = [];
const unsubscribe = director.subscribeSceneClock((snapshot) => snapshots.push(snapshot));
try {
const scene = director._project.scenes[0];
director._publishSceneClock(scene, scene.shots[0], 0.1, { running: true });
director._publishSceneClock(scene, scene.shots[1], 0.3, { running: true });
unsubscribe();
director._publishSceneClock(scene, scene.shots[1], 0.4, { running: false });
assert.deepEqual(snapshots.map(({ shotId, sceneElapsedSec, running }) => ({
shotId, sceneElapsedSec, running,
})), [
{ shotId: 'shot-a', sceneElapsedSec: 0.1, running: true },
{ shotId: 'shot-b', sceneElapsedSec: 0.3, running: true },
]);
} finally {
restore();
}
});
test('the Nepal evidence pack appends once and applies the approved corridor framing', () => {
const project = nepalProjectFixture();
const originalShots = structuredClone(project.scenes[0].shots);
const { director, restore } = makeDirector({ project });
try {
const scene = director._project.scenes[0];
const originalShotIds = scene.shots.map(({ id }) => id);
const first = director.appendShotPack('scene-1', 'bhote-koshi-nepal-evidence-pack');
assert.deepEqual(first, {
appended: true,
updated: false,
shotCount: 17,
patchedShotCount: 14,
firstShotId: scene.shots[8].id,
});
assert.deepEqual(scene.shots.slice(0, 8).map(({ id }) => id), originalShotIds);
assert.equal(scene.shots.length, 25);
assert.ok(scene.releaseLayerIds.includes('bhote-koshi-2026'));
assert.ok(scene.releaseLayerIds.includes('bhote-koshi-locator'));
assert.equal(scene.appliedShotPacks[0].id, 'bhote-koshi-nepal-evidence-pack');
assert.equal(scene.appliedShotPacks[0].version, 18);
assert.deepEqual(
Object.keys(scene.appliedShotPacks[0].shotBindings),
[...NEPAL_ORIGINAL_SHOT_TITLES, ...scene.shots.slice(8).map(({ title }) => title)],
);
assert.deepEqual(
scene.shots.map((shot) => shot.visual.mapStack),
[
...Array(25).fill('photoreal'),
],
);
assert.deepEqual(
scene.shots.map((shot) => shot.layers['bhote-koshi-2026'].params.beatId),
[
'immediate-collapse-viewpoint',
'debris-dammed-lake',
'gyirong-border-gate',
'timure-cluster',
'syabru-besi',
'bidur-trishuli-bridge',
'gyirong-border-gate',
'immediate-collapse-viewpoint',
'immediate-collapse-viewpoint', 'debris-dammed-lake', 'second-landslide',
'gyirong-border-gate', 'timure-cluster', 'syabru-besi', 'dhunche',
'mailung-upper-trishuli', 'mailung-bazzar', 'dandagaun', 'dandagaun-viewpoint',
'betrawati-bazaar', 'bhainse', 'bidur-trishuli-bridge',
'devighat-taadi-khola-bridge', 'charaudi', 'final-view',
],
);
assert.deepEqual(
scene.shots[8].layers['bhote-koshi-2026'].params.sceneControls,
{ evidenceMediaAutoplay: true },
);
assert.deepEqual(
scene.shots[9].layers['bhote-koshi-2026'].params.sceneControls,
{ evidenceMediaAutoplay: true },
);
assert.deepEqual(
scene.shots[11].layers['bhote-koshi-2026'].params.sceneControls,
{
imageryComparison: true,
evidenceSequence: true,
evidenceSequenceDurationSec: 2.5,
deferEvidenceUntilCameraSettled: true,
evidenceRevealDurationSec: 2.5,
},
);
assert.deepEqual(
scene.shots.slice(12, 14).map(
(shot) => shot.layers['bhote-koshi-2026'].params.sceneControls,
),
[
{
imageryComparison: true,
evidenceSequence: true,
evidenceSequenceDurationSec: 4,
},
{
imageryComparison: true,
evidenceSequence: true,
evidenceSequenceDurationSec: 4,
},
],
);
assert.deepEqual(
scene.shots[21].layers['bhote-koshi-2026'].params.sceneControls,
{
evidenceSequence: true,
evidenceSequenceDurationSec: 4,
},
);
for (const shot of scene.shots) {
assert.equal(shot.layers['bhote-koshi-2026'].enabled, true);
assert.equal(shot.layers['bhote-koshi-2026'].params.presentation, 'scene-beat');
}
assert.deepEqual(
scene.shots.slice(0, 8).filter((_, index) => index !== 4).map(({ camera }) => camera),
originalShots.filter((_, index) => index !== 4).map(({ camera }) => camera),
);
assert.deepEqual(scene.shots[4].camera, {
lat: 28.0529,
lon: 85.2189,
alt: 126931,
heading: 0,
pitch: -90,
roll: 0,
});
assert.equal(scene.shots[4].holdSec, 11);
assert.deepEqual(
scene.shots.slice(0, 8).map((shot) => shot.layers['bhote-koshi-locator']),
originalShots.map((shot) => shot.layers['bhote-koshi-locator']),
);
assert.equal(new Set(scene.shots.map(({ id }) => id)).size, 25);
assert.deepEqual(
director.appendShotPack('scene-1', 'bhote-koshi-nepal-evidence-pack'),
{ appended: false, reason: 'already-appended' },
);
assert.equal(scene.shots.length, 25);
} finally {
restore();
}
});
test('installed v12 Nepal pack inserts ten points without replacing renamed cameras', () => {
const first = makeDirector({ project: nepalProjectFixture() });
let legacy;
const recipe = getSceneAppendRecipeById('bhote-koshi-nepal-evidence-pack');
try {
first.director.appendShotPack('scene-1', recipe.id);
legacy = structuredClone(first.director._project);
const scene = legacy.scenes[0];
scene.shots = scene.shots.filter((shot) => !shot.sourcePackId
|| recipe.previousRequiredSourcePackBeatIds.includes(shot.layers['bhote-koshi-2026'].params.beatId));
scene.appliedShotPacks[0].version = 12;
const ids = new Set(scene.shots.map(({ id }) => id));
scene.appliedShotPacks[0].shotBindings = Object.fromEntries(
Object.entries(scene.appliedShotPacks[0].shotBindings).filter(([, id]) => ids.has(id)));
scene.shots[8].title = 'My collapse camera';
scene.shots[8].camera.heading = 149;
} finally { first.restore(); }
const originalShots = structuredClone(legacy.scenes[0].shots);
const upgraded = makeDirector({ project: legacy });
try {
const scene = upgraded.director._project.scenes[0];
assert.equal(scene.shots.length, 25);
for (const old of originalShots) {
const current = scene.shots.find(({ id }) => id === old.id);
assert.ok(current);
assert.deepEqual(current.camera, old.camera);
assert.equal(current.title, old.title);
}
assert.deepEqual(scene.shots.filter((shot) => shot.sourcePackId).map((shot) =>
shot.layers['bhote-koshi-2026'].params.beatId), recipe.requiredSourcePackBeatIds);
assert.equal(scene.appliedShotPacks[0].version, 18);
assert.equal(upgraded.director.appendShotPack(scene.id, recipe.id).reason, 'already-appended');
} finally { upgraded.restore(); }
});
test('a legacy three-shot Nepal browser project bootstraps to the current 25-shot sequence', () => {
const project = legacyThreeShotNepalProjectFixture();
const originalIds = project.scenes[0].shots.map(({ id }) => id);
const originalCameras = project.scenes[0].shots.map(({ camera }) => structuredClone(camera));
const { director, restore } = makeDirector({ project });
try {
const scene = director._project.scenes[0];
assert.equal(scene.shots.length, 25);
assert.deepEqual(scene.shots.map(({ title }) => title),
getSceneAppendRecipeById('bhote-koshi-nepal-evidence-pack').requiredShotTitles);
assert.deepEqual(scene.shots.slice(0, 3).map(({ id }) => id), originalIds);
assert.deepEqual(scene.shots.slice(0, 3).map(({ camera }) => camera), originalCameras);
assert.equal(scene.appliedShotPacks[0].id, 'bhote-koshi-nepal-evidence-pack');
assert.equal(scene.appliedShotPacks[0].version, 18);
assert.equal(director._selectedSceneId, scene.id);
assert.equal(director._selectedShotId, scene.shots[0].id);
} finally {
restore();
}
});
test('the Nepal evidence pack refuses a partial inventory without mutating the scene', () => {
const project = nepalProjectFixture();
project.scenes[0].shots[3].title = 'Renamed Nearby Cities';
const { director, restore } = makeDirector({ project });
try {
const sceneBefore = structuredClone(director._project.scenes[0]);
assert.deepEqual(
director.appendShotPack('scene-1', 'bhote-koshi-nepal-evidence-pack'),
{
appended: false,
updated: false,
reason: 'shot-inventory-mismatch',
},
);
assert.deepEqual(director._project.scenes[0], sceneBefore);
} finally {
restore();
}
});
test('the Nepal pack upgrades the upper-valley shots without duplicating evidence beats', () => {
const initial = makeDirector({ project: nepalProjectFixture() });
initial.director.appendShotPack('scene-1', 'bhote-koshi-nepal-evidence-pack');
const project = structuredClone(initial.director._project);
initial.restore();
project.scenes[0].appliedShotPacks = [{
id: 'bhote-koshi-nepal-evidence-pack',
version: 4,
}];
for (const shot of project.scenes[0].shots.slice(0, 8)) {
delete shot.layers['bhote-koshi-2026'];
}
const originalLength = project.scenes[0].shots.length;
const originalIds = project.scenes[0].shots.map(({ id }) => id);
const originalCameras = project.scenes[0].shots.map(({ camera }) => structuredClone(camera));
const originalLocators = project.scenes[0].shots.map(
(shot) => structuredClone(shot.layers['bhote-koshi-locator']),
);
const { director, restore } = makeDirector({ project });
try {
const scene = director._project.scenes[0];
assert.deepEqual(
director.appendShotPack('scene-1', 'bhote-koshi-nepal-evidence-pack'),
{ appended: false, reason: 'already-appended' },
);
assert.equal(scene.shots.length, originalLength);
assert.deepEqual(scene.shots.map(({ id }) => id), originalIds);
assert.deepEqual(scene.shots.map(({ camera }) => camera), originalCameras);
assert.deepEqual(
scene.shots.map((shot) => shot.layers['bhote-koshi-locator']),
originalLocators,
);
assert.deepEqual(scene.shots[8].camera, {
lat: 28.417,
lon: 85.3937,
alt: 9850,
heading: 146,
pitch: -34,
roll: 0,
});
assert.equal(scene.appliedShotPacks[0].version, 18);
assert.equal(Object.keys(scene.appliedShotPacks[0].shotBindings).length, 25);
assert.ok(scene.shots.every((shot) => (
shot.layers['bhote-koshi-2026']?.enabled === true
&& shot.layers['bhote-koshi-2026']?.params?.presentation === 'scene-beat'
)));
const afterUpgrade = structuredClone(scene);
assert.deepEqual(
director.appendShotPack('scene-1', 'bhote-koshi-nepal-evidence-pack'),
{ appended: false, reason: 'already-appended' },
);
assert.deepEqual(scene, afterUpgrade);
} finally {
restore();
}
});
test('an older default project gains the complete selectable Nepal scene once', () => {
const project = legacyDefaultProjectWithoutNepalFixture();
const { director, restore } = makeDirector({
project,
data: { registered: [...REGISTERED, 'bhote-koshi-2026', 'bhote-koshi-locator'] },
});
try {
const nepal = director._project.scenes.find(({ title }) => title === 'Nepal Flood Incident');
assert.ok(nepal);
assert.equal(nepal.shots.length, 25);
assert.equal(nepal.shots[0].title, 'Global Incident Context');
assert.equal(nepal.shots.at(-1).title, 'Final view');
assert.deepEqual(director._project.installedBuiltInSceneIds, ['bhote-koshi-nepal-scene']);
} finally {
restore();
}
});
test('a previously installed Nepal scene stays deleted when its marker remains', () => {
const project = legacyDefaultProjectWithoutNepalFixture();
project.installedBuiltInSceneIds = ['bhote-koshi-nepal-scene'];
const { director, restore } = makeDirector({ project });
try {
assert.equal(
director._project.scenes.some(({ title }) => title === 'Nepal Flood Incident'),
false,
);
} finally {
restore();
}
});
test('public defaults include Nepal without an extra standalone flood recipe', () => {
assert.equal(SCENE_RECIPES.some((item) => item.id === 'bhote-koshi-flood'), false);
assert.equal(SCENE_RECIPES.filter((item) => item.id === 'bhote-koshi-nepal-scene').length, 1);
});
test('an existing public default project gains Nepal without replacing authored shots', () => {
const project = structuredClone(PROJECT_FIXTURE);
project.scenes[0].id = 'flights-radar';
const original = structuredClone(project.scenes[0].shots);
const { director, restore } = makeDirector({ project });
try {
assert.deepEqual(director._project.scenes[0].shots.map(({ id, camera }) => ({ id, camera })),
original.map(({ id, camera }) => ({ id, camera })));
assert.equal(director._project.scenes[1].title, 'Nepal Flood Incident');
assert.equal(director._project.scenes[1].shots.length, 25);
assert.deepEqual(director._project.installedBuiltInSceneIds, ['bhote-koshi-nepal-scene']);
} finally { restore(); }
});
test('Nepal comparison shots load Esri beneath Vantor even from a saved OSM or photoreal shot', async () => {
const { director, styleManager, restore } = makeDirector({
project: nepalProjectFixture(),
data: { registered: [...REGISTERED, 'bhote-koshi-2026', 'bhote-koshi-locator'] },
});
try {
director.appendShotPack('scene-1', 'bhote-koshi-nepal-evidence-pack');
const scene = director._project.scenes[0];
const comparisonShots = scene.shots.filter((shot) =>
director._layerStatesForShot(scene, shot)['bhote-koshi-2026']?.params?.sceneControls?.imageryComparison);
assert.ok(comparisonShots.length >= 2, 'the real comparison beats must be exercised');
for (const shot of comparisonShots) {
for (const prior of ['osm', 'photoreal']) {
shot.visual.mapStack = prior;
await director.loadShot(scene.id, shot.id, { flyDuration: 0.2 });
assert.equal(styleManager.visualStates.at(-1).mapStack, 'esri-imagery', shot.title);
}
}
} finally {
await director.destroy();
restore();
}
});
test('all saved Nepal shots choose a usable map in keyed and keyless runtimes without rewriting the project', async () => {
let photorealAvailable = false;
const { director, styleManager, restore } = makeDirector({
project: nepalProjectFixture(),
isMapStackAvailable: (id) => id === 'photoreal' && photorealAvailable,
data: { registered: [...REGISTERED, 'bhote-koshi-2026', 'bhote-koshi-locator'] },
});
try {
director.appendShotPack('scene-1', 'bhote-koshi-nepal-evidence-pack');
const scene = director._project.scenes[0];
const savedShots = structuredClone(scene.shots);
for (const available of [false, true, false]) {
photorealAvailable = available;
for (const shot of scene.shots) {
const comparison = director._layerStatesForShot(scene, shot)
['bhote-koshi-2026']?.params?.sceneControls?.imageryComparison;
const visual = director._visualStateForShot(shot);
assert.equal(visual.mapStack, available && !comparison ? 'photoreal' : 'esri-imagery', shot.title);
}
await director.loadShot(scene.id, scene.shots[0].id, { flyDuration: 0.2 });
assert.equal(styleManager.visualStates.at(-1).mapStack, available ? 'photoreal' : 'esri-imagery');
}
assert.deepEqual(scene.shots, savedShots);
const unrelated = { visual: { mapStack: 'photoreal' }, layers: { flights: { enabled: true } } };
assert.equal(director._visualStateForShot(unrelated), unrelated.visual, 'other scenes keep their provider policy');
} finally {
await director.destroy();
restore();
}
});
/** The layer registry as main.js builds it (src/main.js dataManager.register calls). */
const REGISTERED = [
@@ -98,6 +641,10 @@ function fakeDataManager({ registered = REGISTERED, refuse = () => false } = {})
const committed = [];
return {
setEnabledCalls,
subscribeVisibilityRequests(listener) {
this.visibilityListener = listener;
return () => { this.visibilityListener = null; };
},
setParamsCalls,
committed,
getAll: () => registered.map((id) => ({ id, enabled: !!enabled.get(id) })),
@@ -174,13 +721,67 @@ function makeDirector(options = {}) {
const viewer = fakeViewer();
const styleManager = fakeStyleManager(options.style);
const dataManager = fakeDataManager(options.data);
const director = new SceneDirector(viewer, styleManager, dataManager);
const director = new SceneDirector(viewer, styleManager, dataManager, {
isMapStackAvailable: options.isMapStackAvailable,
});
// Telemetry is only accumulated during a run; observable-failure assertions
// need the accumulator without driving a whole run.
director._activeRun = { events: [] };
return { director, viewer, styleManager, dataManager, restore };
}
test('scene camera waits for provider completion and fade rather than the saved media hold', async () => {
const { director, viewer, dataManager, restore } = makeDirector();
let release;
let pending = true;
const gate = new Promise((resolve) => { release = resolve; });
try {
const scene = director._project.scenes[0];
scene.shots[0].layers.flights.params = { beatId: 'trimmed' };
dataManager.layers = new Map([['flights', { module: {
getSceneShotMediaHold: (id) => id === 'trimmed' ? { pending, maxWaitMs: 20000 } : null,
} }]]);
const waits = [];
director._sleep = async (ms) => { waits.push(ms); if (ms === 70) await gate; };
const run = director.startScene('scene-1', { single: true, preview: false });
await settle(40);
assert.equal(viewer.flights.length, 1, 'no next flight while source is starting, playing or fading');
assert.deepEqual(waits, [70]);
pending = false; release();
await run;
assert.equal(viewer.flights.length, 2, 'the next shot starts immediately after owned media completion');
} finally { release(); await director.destroy(); restore(); }
});
test('Stop cancels a provider-owned shot hold and late completion cannot fly the next camera', async () => {
const { director, viewer, dataManager, restore } = makeDirector();
let release;
const gate = new Promise((resolve) => { release = resolve; });
try {
dataManager.layers = new Map([['flights', { module: {
getSceneShotMediaHold: () => ({ pending: true, maxWaitMs: 20000 }),
} }]]);
director._sleep = () => gate;
const run = director.startScene('scene-1', { single: true, preview: false });
await settle(40);
assert.equal(viewer.flights.length, 1);
director.stopScene('test stop'); release(); await run;
assert.equal(viewer.flights.length, 1);
assert.equal(director.running, false);
} finally { release(); await director.destroy(); restore(); }
});
test('a provider-owned hold fails boundedly if its owner never settles', async () => {
const { director, restore } = makeDirector();
try {
director.dataManager.layers = new Map([['flights', { module: {
getSceneShotMediaHold: () => ({ pending: true, maxWaitMs: 0 }),
} }]]);
const scene = director._project.scenes[0];
await assert.rejects(director._holdShot(scene, scene.shots[0], { cancelled: false }), /bounded playback window/);
} finally { await director.destroy(); restore(); }
});
/** The layer map a shipped recipe declares, in normalized form. */
function recipeLayers(recipeId) {
const recipe = SCENE_RECIPES.find((item) => item.id === recipeId);
@@ -189,6 +790,104 @@ function recipeLayers(recipeId) {
);
}
test('destroyed directors refuse seek, replay, adjacent and continuation without writes', async () => {
const { director, viewer, dataManager, restore } = makeDirector();
try {
await director.destroy();
director._loadedSceneId = 'scene-1';
assert.equal(await director.seekScene('scene-1', 0), false);
assert.equal((await director.replayShot('scene-1', 'shot-a')).started, false);
assert.equal(await director.loadAdjacentShot('scene-1', 'shot-a', 1), false);
assert.equal((await director.continueScene('scene-1', 'shot-a')).started, false);
assert.equal(viewer.flights.length, 0);
assert.equal(dataManager.setParamsCalls.length, 0);
} finally { restore(); }
});
test('replay, adjacent and seek report layer refusal rather than success', async () => {
const { director, restore } = makeDirector({ data: { refuse: () => true } });
try {
assert.equal((await director.replayShot('scene-1', 'shot-a')).started, false);
assert.equal(await director.loadAdjacentShot('scene-1', 'shot-a', 1), false);
assert.equal(await director.seekScene('scene-1', 0), false);
} finally { await director.destroy(); restore(); }
});
test('a seek waiting for run teardown is revoked by newer Stop, Load, Start or destroy', async () => {
for (const action of ['stop', 'load', 'start', 'destroy']) {
const { director, restore } = makeDirector();
try {
let release;
director._waitForRunIdle = () => new Promise((resolve) => { release = resolve; });
director._running = true;
director._runToken = { cancelled: false };
const seek = director.seekScene('scene-1', 0);
assert.equal(director._pendingWork.has(seek), true);
let newer;
if (action === 'stop') director.stopScene();
if (action === 'load') newer = director.loadShot('scene-1', 'shot-b');
if (action === 'start') newer = director.startScene('scene-1');
if (action === 'destroy') newer = director.destroy();
director._running = false;
release();
assert.equal(await seek, false, action);
await newer;
} finally { await director.destroy(); restore(); }
}
});
test('explicit scene-layer OFF revokes continuation during enable, flight and hold; internal OFF does not', async () => {
for (const phase of ['enable', 'flight', 'hold']) {
const { director, dataManager, restore } = makeDirector();
try {
director._project.scenes[0].releaseLayerIds = ['flights'];
let release;
const gate = new Promise((resolve) => { release = resolve; });
if (phase === 'enable') {
const enable = dataManager.setEnabled.bind(dataManager);
dataManager.setEnabled = async (...args) => { await gate; return enable(...args); };
} else if (phase === 'flight') director._flyCamera = () => gate;
else director._sleep = () => gate;
director._project.scenes[0].shots[0].holdSec = 1;
const run = director.startScene('scene-1', { single: true, preview: false });
await settle(40);
const token = director._runToken;
dataManager.visibilityListener({ layerId: 'flights', enabled: false, origin: 'scene' });
assert.equal(token.cancelled, false);
dataManager.visibilityListener({ layerId: 'flights', enabled: false, origin: 'user' });
assert.equal(token.cancelled, true, phase);
dataManager.visibilityListener({ layerId: 'flights', enabled: false, origin: 'tool' });
release();
await run;
assert.equal(dataManager.committed.some(({ id, enabled }) => id === 'traffic' && enabled), false);
} finally { await director.destroy(); restore(); }
}
});
test('scene preview owns recording chrome while panel playback leaves it available', async () => {
for (const preview of [true, false]) {
const { director, styleManager, restore } = makeDirector();
const recording = [];
const playback = [];
let release;
const flight = new Promise((resolve) => { release = resolve; });
director._flyCamera = () => flight;
styleManager.setRecordingMode = (active) => recording.push(active);
director._setPlaybackActive = (active) => playback.push(active);
try {
const run = director.startScene('scene-1', { single: true, preview });
await settle(40);
assert.deepEqual(recording, preview ? [true] : []);
assert.deepEqual(playback, preview ? [true] : []);
director.stopScene();
release();
await run;
assert.deepEqual(recording, preview ? [true, false] : []);
assert.deepEqual(playback, preview ? [true, false] : []);
} finally { release(); await director.destroy(); restore(); }
}
});
test('the director reconciles only the layers a shot declares', async () => {
// Regression: _applyLayerStates walked the LIVE registry and forced every
// undeclared layer off, tearing down CCTV/vessels/fires with no restore pass.
+323
View File
@@ -0,0 +1,323 @@
import { SOURCE_PATH_BEAT_IDS } from '../data/bhoteKoshiShotPaths.js';
/**
* Nepal evidence expansion. Coordinates and camera framing come from
* the source event pack; existing authored cameras remain owned by the Scene.
*/
const ADDITIONAL_SHOTS = [
{
id: 'second-landslide',
title: 'Second landslide',
lat: 28.399234,
lon: 85.535806,
alt: 10145,
heading: 224,
pitch: -38,
hold: 11
},
{
id: 'dhunche',
title: 'Dhunche',
lat: 28.161431,
lon: 85.324278,
alt: 6212,
heading: 208,
pitch: -39,
hold: 16
},
{
id: 'mailung-upper-trishuli',
title: 'Mailung / Upper Trishuli bridge',
lat: 28.140642,
lon: 85.265996,
alt: 6353,
heading: 211,
pitch: -40,
hold: 13
},
{
id: 'mailung-bazzar',
title: 'Mailung Bazzar, Dandaguan',
lat: 28.126526,
lon: 85.248027,
alt: 6331,
heading: 212,
pitch: -40,
hold: 15
},
{
id: 'dandagaun',
title: 'Dandagaun, Rasuwa',
lat: 28.124424,
lon: 85.245154,
alt: 6311,
heading: 214,
pitch: -40,
hold: 15
},
{
id: 'dandagaun-viewpoint',
title: 'Dandagaun viewpoint restaurant',
lat: 28.086663,
lon: 85.229914,
alt: 6740,
heading: 215,
pitch: -41,
hold: 13
},
{
id: 'betrawati-bazaar',
title: 'Betrawati Bazaar',
lat: 28.029827,
lon: 85.227694,
alt: 7034,
heading: 216,
pitch: -41,
hold: 12
},
{
id: 'bhainse',
title: 'Bhainse',
lat: 28.023087,
lon: 85.229449,
alt: 8051,
heading: 219,
pitch: -41,
hold: 13
},
{
id: 'devighat-taadi-khola-bridge',
title: 'Devighat / Taadi Khola Bridge',
lat: 27.921132,
lon: 85.177113,
alt: 9056,
heading: 224,
pitch: -42,
hold: 13
},
{
id: 'charaudi',
title: 'Charaudi',
lat: 27.865095,
lon: 84.838424,
alt: 10347,
heading: 232,
pitch: -42,
hold: 11
},
{
id: 'final-view',
title: 'Final view',
lat: 28.0834,
lon: 85.206,
alt: 113520,
heading: 64,
pitch: -83,
roll: 174,
duration: 4,
hold: 11,
locatorPresentation: 'bhote-koshi-incident-places'
}
];
const EVIDENCE_ORDER = [
'immediate-collapse-viewpoint',
'debris-dammed-lake',
'second-landslide',
'gyirong-border-gate',
'timure-cluster',
'syabru-besi',
'dhunche',
'mailung-upper-trishuli',
'mailung-bazzar',
'dandagaun',
'dandagaun-viewpoint',
'betrawati-bazaar',
'bhainse',
'bidur-trishuli-bridge',
'devighat-taadi-khola-bridge',
'charaudi',
'final-view'
];
// Per-reach motion timing keeps the schematic front ahead of the fixed 4.2s
// camera flights without pretending short urban reaches and long valley runs
// should advance at one global speed.
const PATH_DURATION_BY_BEAT = Object.freeze({
'debris-dammed-lake': 4.8,
'second-landslide': 6.5,
'timure-cluster': 2.1,
'syabru-besi': 2.8,
'dhunche': 2.5,
'mailung-upper-trishuli': 2.9,
'mailung-bazzar': 2.1,
'dandagaun': 1.2,
'dandagaun-viewpoint': 2.5,
'betrawati-bazaar': 2.9,
'bhainse': 1.5,
'bidur-trishuli-bridge': 2.6,
});
/** Extend the six-beat Nepal pack without replacing existing authored shots. */
export function expandNepalEvidencePack(base) {
const layerId = base.requiredSourcePackLayerId;
const extraShots = ADDITIONAL_SHOTS.map(({
id,
hold,
duration = 4.2,
locatorPresentation,
...camera
}) => ({
...camera,
roll: Number.isFinite(Number(camera.roll)) ? Number(camera.roll) : 0,
duration,
hold,
mapStack: 'photoreal',
layers: {
[layerId]: { enabled: true, params: {
presentation: 'scene-beat',
beatId: id,
beatReveal: 0.36,
split: 0.5,
} },
...(locatorPresentation ? {
'bhote-koshi-locator': {
enabled: true,
params: { presentation: locatorPresentation },
},
} : {}),
},
}));
const byBeat = new Map([...base.cameraPath, ...extraShots].map((shot) => [
shot.layers[layerId].params.beatId, shot,
]));
const cameraPath = EVIDENCE_ORDER.map((id) => byBeat.get(id));
const runtimeControlsByBeat = { ...base.runtimeControlsByBeat };
const cameraLedPathBeats = new Set([
'timure-cluster', 'syabru-besi', ...SOURCE_PATH_BEAT_IDS.slice(2),
]);
const mediaLedPathBeats = new Set([
'debris-dammed-lake', 'second-landslide',
]);
const finalSourcedPathBeatId = SOURCE_PATH_BEAT_IDS.at(-1);
const postSourceHistoryBeats = new Set([
'devighat-taadi-khola-bridge', 'charaudi', 'final-view',
]);
for (const shot of cameraPath) {
const id = shot.layers[layerId].params.beatId;
const isFinalView = id === 'final-view';
const authoredPathDurationSec = PATH_DURATION_BY_BEAT[id];
runtimeControlsByBeat[id] = {
...runtimeControlsByBeat[id],
evidenceMediaAutoplay: !isFinalView,
...(isFinalView ? {} : {
mediaPlaybackHoldSec: id === 'mailung-bazzar' ? 7 : 6,
mediaExitDurationSec: 0.65,
}),
deferEvidenceUntilCameraSettled: !isFinalView,
evidenceRevealDurationSec: 0.9,
evidencePath: SOURCE_PATH_BEAT_IDS.includes(id) ? 'source'
: ['gyirong-border-gate', 'timure-cluster', 'syabru-besi'].includes(id) ? 'comparison' : 'none',
...(cameraLedPathBeats.has(id) ? {
evidencePathDuringCamera: true,
evidencePathPersistent: true,
...(authoredPathDurationSec
? { evidencePathTravelDurationSec: authoredPathDurationSec }
: {}),
} : {}),
...(mediaLedPathBeats.has(id) ? {
evidencePathDuringMedia: true,
evidencePathPersistent: true,
evidencePathStartDelaySec: 0,
} : {}),
};
if (SOURCE_PATH_BEAT_IDS.includes(id)) {
runtimeControlsByBeat[id] = {
...runtimeControlsByBeat[id],
evidenceHoldCard: true,
evidencePathHistory: true,
evidencePathDurationSec: authoredPathDurationSec || Math.max(4, shot.hold),
minimumHoldSec: Math.max(4, shot.hold),
};
}
if (SOURCE_PATH_BEAT_IDS.slice(2).includes(id)) {
runtimeControlsByBeat[id] = {
...runtimeControlsByBeat[id],
evidencePathHistory: true,
evidencePathElevation: 'source',
};
}
if (postSourceHistoryBeats.has(id)) {
runtimeControlsByBeat[id] = {
...runtimeControlsByBeat[id],
evidencePath: 'history',
evidencePathHistory: true,
evidencePathHistoryBeatId: finalSourcedPathBeatId,
evidencePathPersistent: true,
evidencePathElevation: 'source',
};
}
const pathDurationSec = Number(runtimeControlsByBeat[id].evidencePathDurationSec)
|| (!cameraLedPathBeats.has(id) && runtimeControlsByBeat[id].evidenceSequence === true
? Number(runtimeControlsByBeat[id].evidenceSequenceDurationSec) || 0
: 0);
const mediaPresentationSec = isFinalView
? 0
: (id === 'mailung-bazzar' ? 7 : 6) + 0.65;
const mediaAndPathHoldSec = pathDurationSec > 0
&& !cameraLedPathBeats.has(id)
? mediaLedPathBeats.has(id)
? Math.max(mediaPresentationSec, pathDurationSec)
: mediaPresentationSec + pathDurationSec
: mediaPresentationSec;
runtimeControlsByBeat[id] = {
...runtimeControlsByBeat[id],
minimumHoldSec: id === 'mailung-bazzar' ? mediaAndPathHoldSec : Math.max(
Number(runtimeControlsByBeat[id].minimumHoldSec) || 0,
mediaAndPathHoldSec,
),
};
}
// Keep the downstream sequence on its sourced river profile. Coarse Google
// mesh samples can contain cliff-height spikes that reverse apparent motion.
// Preserve each authored readable hold after the media-first timing floor.
for (const [id, seconds, sequenceSeconds = seconds] of [
['immediate-collapse-viewpoint', 10], ['gyirong-border-gate', 8, 2.5],
['timure-cluster', 12], ['syabru-besi', 11],
]) {
runtimeControlsByBeat[id] = {
...runtimeControlsByBeat[id],
evidenceHoldCard: true,
evidenceSequenceDurationSec: sequenceSeconds,
minimumHoldSec: Math.max(
Number(runtimeControlsByBeat[id].minimumHoldSec) || 0,
seconds,
),
};
}
const finalView = byBeat.get('final-view');
return Object.freeze({
...base, version: 18, cameraPath, runtimeControlsByBeat,
// Clip trims must also replace older saved holds, not just lower a floor.
runtimeHoldSecByBeat: { 'mailung-bazzar': 7 + 0.65 },
expansionFromVersion: 17,
previousRequiredSourcePackBeatIds: EVIDENCE_ORDER.slice(0, -1),
previousRequiredSourcePackBeatIdVariants: [
base.requiredSourcePackBeatIds,
EVIDENCE_ORDER.slice(0, -1),
],
requiredSourcePackBeatIds: EVIDENCE_ORDER,
requiredShotTitles: [
...base.requiredShotTitles.slice(0, -base.cameraPath.length),
...cameraPath.map(({ title }) => title),
],
adoptExistingShotTitles: ['Final view'],
shotPatches: [
...(base.shotPatches || []),
{
title: 'Final view',
layers: finalView.layers,
},
],
photorealSurfaceBeatIds: EVIDENCE_ORDER,
});
}
+603 -1
View File
@@ -3,7 +3,592 @@
* Each recipe is deterministic so repeated runs produce similar footage.
*/
export const SCENE_RECIPES = [
import { expandNepalEvidencePack } from './nepalEvidencePack.js';
const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
id: 'bhote-koshi-nepal-evidence-pack',
version: 12,
title: 'Bhote Koshi Evidence Sequence',
style: 'normal',
ui: { hidePanels: false, hudMode: 'full', safeFrame: '16:9' },
layers: {
'bhote-koshi-locator': false,
},
releaseLayerIds: ['bhote-koshi-2026', 'bhote-koshi-locator'],
post: {
bloom: 0,
sharpen: true,
detectionMode: 'OFF',
mapStack: 'photoreal',
},
legacySceneBootstrap: {
targetSceneTitle: 'Nepal Flood Incident',
fromShotTitles: ['Shot 1', 'Shot 2', 'Shot 3'],
cameraPath: [
{
title: 'Global Incident Context',
lat: 27.5962,
lon: 78.5718,
alt: 15628002,
heading: 0,
pitch: -90,
roll: 0,
duration: 4,
hold: 0.9,
layers: {
'bhote-koshi-locator': {
enabled: true,
params: { presentation: 'nepal-context' },
},
},
},
{
title: 'Nepal-Focused Globe Rotation',
lat: 28.7248,
lon: 81.3226,
alt: 2068966,
heading: 0,
pitch: -90,
roll: 0,
duration: 4,
hold: 0.9,
layers: {
'bhote-koshi-locator': {
enabled: true,
params: { presentation: 'nepal-context' },
},
},
},
{
title: 'Bhote Koshi Regional Approach',
lat: 27.0354,
lon: 84.6287,
alt: 809543,
heading: 0,
pitch: -90,
roll: 0,
duration: 4,
hold: 0.9,
layers: {
'bhote-koshi-locator': {
enabled: true,
params: { presentation: 'bhote-koshi-regional' },
},
},
},
{
title: 'Bhote Koshi Nearby Cities',
lat: 27.891,
lon: 85.428,
alt: 225734,
heading: 0,
pitch: -90,
roll: 0,
duration: 4,
hold: 0.9,
layers: {
'bhote-koshi-locator': {
enabled: true,
params: { presentation: 'bhote-koshi-city-context' },
},
},
},
{
title: 'Bhote Koshi Incident Corridor',
lat: 28.0529,
lon: 85.2189,
alt: 126931,
heading: 0,
pitch: -90,
roll: 0,
duration: 4,
hold: 11,
layers: {
'bhote-koshi-locator': {
enabled: true,
params: { presentation: 'bhote-koshi-incident-places' },
},
},
},
{
title: 'Bhote Koshi Flood Path',
lat: 28.1691,
lon: 85.352,
alt: 60328,
heading: 0,
pitch: -90,
roll: 0,
duration: 4,
hold: 0.9,
layers: {
'bhote-koshi-locator': {
enabled: true,
params: { presentation: 'bhote-koshi-flood-path' },
},
},
},
{
title: 'Bhote Koshi Corridor Overview',
lat: 27.8986,
lon: 84.8723,
alt: 53247,
heading: 60,
pitch: -48,
roll: 0,
duration: 4,
hold: 0.9,
layers: {
'bhote-koshi-locator': {
enabled: true,
params: { presentation: 'bhote-koshi-path-overview' },
},
},
},
{
title: 'Bhote Koshi Upper Valley',
lat: 28.4187,
lon: 85.4341,
alt: 7977,
heading: 155,
pitch: -28,
roll: 0,
duration: 4,
hold: 8,
layers: {
'bhote-koshi-locator': {
enabled: true,
params: { presentation: 'bhote-koshi-trigger-record' },
},
},
},
],
},
requiredShotTitles: [
'Global Incident Context',
'Nepal-Focused Globe Rotation',
'Bhote Koshi Regional Approach',
'Bhote Koshi Nearby Cities',
'Bhote Koshi Incident Corridor',
'Bhote Koshi Flood Path',
'Bhote Koshi Corridor Overview',
'Bhote Koshi Upper Valley',
'Upper Valley Collapse',
'Debris-Dammed Lake',
'Nepal-China Border Gate',
'Timure Evidence Cluster',
'Syabru Besi Passage',
'Bidur / Trishuli Consequence',
],
requiredSourcePackBeatIds: [
'immediate-collapse-viewpoint',
'debris-dammed-lake',
'gyirong-border-gate',
'timure-cluster',
'syabru-besi',
'bidur-trishuli-bridge',
],
requiredSourcePackLayerId: 'bhote-koshi-2026',
photorealSurfaceBeatIds: [
'gyirong-border-gate',
'timure-cluster',
'syabru-besi',
'bidur-trishuli-bridge',
],
runtimeControlsByBeat: {
'immediate-collapse-viewpoint': { evidenceMediaAutoplay: true },
'debris-dammed-lake': { evidenceMediaAutoplay: true },
'gyirong-border-gate': {
imageryComparison: true,
evidenceSequence: true,
evidenceSequenceDurationSec: 2.5,
deferEvidenceUntilCameraSettled: true,
evidenceRevealDurationSec: 2.5,
},
'timure-cluster': {
imageryComparison: true,
evidenceSequence: true,
evidenceSequenceDurationSec: 4,
},
'syabru-besi': {
imageryComparison: true,
evidenceSequence: true,
evidenceSequenceDurationSec: 4,
},
'bidur-trishuli-bridge': {
evidenceSequence: true,
evidenceSequenceDurationSec: 4,
},
},
shotPatches: [
{
title: 'Global Incident Context',
visual: { mapStack: 'photoreal' },
layers: {
'bhote-koshi-2026': {
enabled: true,
params: {
presentation: 'scene-beat',
beatId: 'immediate-collapse-viewpoint',
beatReveal: 0.36,
split: 0.5,
},
},
},
},
{
title: 'Nepal-Focused Globe Rotation',
visual: { mapStack: 'photoreal' },
layers: {
'bhote-koshi-2026': {
enabled: true,
params: {
presentation: 'scene-beat',
beatId: 'debris-dammed-lake',
beatReveal: 0.36,
split: 0.5,
},
},
},
},
{
title: 'Bhote Koshi Regional Approach',
visual: { mapStack: 'photoreal' },
layers: {
'bhote-koshi-2026': {
enabled: true,
params: {
presentation: 'scene-beat',
beatId: 'gyirong-border-gate',
beatReveal: 0.36,
split: 0.5,
},
},
},
},
{
title: 'Bhote Koshi Nearby Cities',
visual: { mapStack: 'photoreal' },
layers: {
'bhote-koshi-2026': {
enabled: true,
params: {
presentation: 'scene-beat',
beatId: 'timure-cluster',
beatReveal: 0.36,
split: 0.5,
},
},
},
},
{
title: 'Bhote Koshi Incident Corridor',
camera: {
lat: 28.0529,
lon: 85.2189,
alt: 126931,
heading: 0,
pitch: -90,
roll: 0,
},
holdSec: 11,
visual: { mapStack: 'photoreal' },
layers: {
'bhote-koshi-2026': {
enabled: true,
params: {
presentation: 'scene-beat',
beatId: 'syabru-besi',
beatReveal: 0.36,
split: 0.5,
},
},
},
},
{
title: 'Bhote Koshi Flood Path',
visual: { mapStack: 'photoreal' },
layers: {
'bhote-koshi-2026': {
enabled: true,
params: {
presentation: 'scene-beat',
beatId: 'bidur-trishuli-bridge',
beatReveal: 0.36,
split: 0.5,
},
},
},
},
{
title: 'Bhote Koshi Corridor Overview',
visual: { mapStack: 'photoreal' },
layers: {
'bhote-koshi-2026': {
enabled: true,
params: {
presentation: 'scene-beat',
beatId: 'gyirong-border-gate',
beatReveal: 0.36,
split: 0.5,
},
},
},
},
{
title: 'Bhote Koshi Upper Valley',
holdSec: 8,
visual: { mapStack: 'photoreal' },
layers: {
'bhote-koshi-2026': {
enabled: true,
params: {
presentation: 'scene-beat',
beatId: 'immediate-collapse-viewpoint',
beatReveal: 0.36,
split: 0.5,
},
},
'bhote-koshi-locator': {
enabled: true,
params: {
presentation: 'bhote-koshi-trigger-record',
},
},
},
},
{
title: 'Upper Valley Collapse',
camera: {
lat: 28.417,
lon: 85.3937,
alt: 9850,
heading: 146,
pitch: -34,
roll: 0,
},
layers: {
'bhote-koshi-2026': {
enabled: true,
params: {
presentation: 'scene-beat',
beatId: 'immediate-collapse-viewpoint',
beatReveal: 0.36,
split: 0.5,
sceneControls: { evidenceMediaAutoplay: true },
},
},
},
},
{
title: 'Nepal-China Border Gate',
visual: { mapStack: 'photoreal' },
},
{
title: 'Timure Evidence Cluster',
visual: { mapStack: 'photoreal' },
},
{
title: 'Syabru Besi Passage',
visual: { mapStack: 'photoreal' },
},
{
title: 'Bidur / Trishuli Consequence',
visual: { mapStack: 'photoreal' },
},
],
cameraPath: [
{
title: 'Upper Valley Collapse',
lat: 28.417,
lon: 85.3937,
alt: 9850,
heading: 146,
pitch: -34,
roll: 0,
duration: 4.2,
hold: 1.4,
layers: {
'bhote-koshi-2026': {
enabled: true,
params: {
presentation: 'scene-beat',
beatId: 'immediate-collapse-viewpoint',
beatReveal: 0.36,
split: 0.5,
sceneControls: { evidenceMediaAutoplay: true },
},
},
},
},
{
title: 'Debris-Dammed Lake',
lat: 28.392914,
lon: 85.570921,
alt: 10744,
heading: 232,
pitch: -37,
roll: 0,
duration: 4.2,
hold: 1.4,
layers: {
'bhote-koshi-2026': {
enabled: true,
params: {
presentation: 'scene-beat',
beatId: 'debris-dammed-lake',
beatReveal: 0.36,
split: 0.5,
sceneControls: { evidenceMediaAutoplay: true },
},
},
},
},
{
title: 'Nepal-China Border Gate',
mapStack: 'photoreal',
lat: 28.316789,
lon: 85.399734,
alt: 5900,
heading: 208,
pitch: -42,
roll: 0,
duration: 4.2,
hold: 2.5,
layers: {
'bhote-koshi-2026': {
enabled: true,
params: {
presentation: 'scene-beat',
beatId: 'gyirong-border-gate',
beatReveal: 0.36,
split: 0.5,
sceneControls: {
imageryComparison: true,
evidenceSequence: true,
evidenceSequenceDurationSec: 2.5,
deferEvidenceUntilCameraSettled: true,
evidenceRevealDurationSec: 2.5,
},
},
},
},
},
{
title: 'Timure Evidence Cluster',
mapStack: 'photoreal',
lat: 28.285968,
lon: 85.379136,
alt: 5032,
heading: 198,
pitch: -40,
roll: 0,
duration: 4.2,
hold: 4,
layers: {
'bhote-koshi-2026': {
enabled: true,
params: {
presentation: 'scene-beat',
beatId: 'timure-cluster',
beatReveal: 0.36,
split: 0.5,
sceneControls: {
imageryComparison: true,
evidenceSequence: true,
evidenceSequenceDurationSec: 4,
},
},
},
},
},
{
title: 'Syabru Besi Passage',
mapStack: 'photoreal',
lat: 28.210306,
lon: 85.361527,
alt: 5893,
heading: 205,
pitch: -38,
roll: 0,
duration: 4.2,
hold: 4,
layers: {
'bhote-koshi-2026': {
enabled: true,
params: {
presentation: 'scene-beat',
beatId: 'syabru-besi',
beatReveal: 0.36,
split: 0.5,
sceneControls: {
imageryComparison: true,
evidenceSequence: true,
evidenceSequenceDurationSec: 4,
},
},
},
},
},
{
title: 'Bidur / Trishuli Consequence',
mapStack: 'photoreal',
lat: 27.981737,
lon: 85.196273,
alt: 7422,
heading: 215,
pitch: -40,
roll: 0,
duration: 4.2,
hold: 4,
layers: {
'bhote-koshi-2026': {
enabled: true,
params: {
presentation: 'scene-beat',
beatId: 'bidur-trishuli-bridge',
beatReveal: 0.36,
split: 0.5,
sceneControls: {
evidenceSequence: true,
evidenceSequenceDurationSec: 4,
},
},
},
},
},
],
});
const BHOTE_KOSHI_NEPAL_APPEND_RECIPE = BHOTE_KOSHI_NEPAL_BASE_RECIPE
? expandNepalEvidencePack(BHOTE_KOSHI_NEPAL_BASE_RECIPE)
: null;
// Fresh browser origins have no legacy three-shot Nepal project for the
// append recipe to upgrade. Seed that exact legacy shape so SceneDirector can
// run the same idempotent bootstrap used by existing browser-saved projects.
const BHOTE_KOSHI_NEPAL_BOOTSTRAP_RECIPE = BHOTE_KOSHI_NEPAL_APPEND_RECIPE
? Object.freeze({
id: 'bhote-koshi-nepal-scene',
title: 'Nepal Flood Incident',
durationSec: 15,
style: BHOTE_KOSHI_NEPAL_APPEND_RECIPE.style,
ui: BHOTE_KOSHI_NEPAL_APPEND_RECIPE.ui,
layers: BHOTE_KOSHI_NEPAL_APPEND_RECIPE.layers,
releaseLayerIds: BHOTE_KOSHI_NEPAL_APPEND_RECIPE.releaseLayerIds,
post: BHOTE_KOSHI_NEPAL_APPEND_RECIPE.post,
installAlongsideSceneId: 'bhote-koshi-flood',
installAlongsideFallbackSceneId: 'flights-radar',
cameraPath: BHOTE_KOSHI_NEPAL_APPEND_RECIPE.legacySceneBootstrap.cameraPath
.slice(0, 3)
.map((keyframe, index) => ({ ...keyframe, title: `Shot ${index + 1}` })),
})
: null;
const EVENT_RECIPES = [
BHOTE_KOSHI_NEPAL_BOOTSTRAP_RECIPE,
].filter(Boolean);
const PUBLIC_SCENE_RECIPES = [
{
id: 'flights-radar',
title: 'Global Flights Radar',
@@ -161,6 +746,23 @@ export const SCENE_RECIPES = [
},
];
/** Build the recipe list without mutating stored user-authored projects. */
export function createSceneRecipes({
localDemoRecipes = EVENT_RECIPES,
} = {}) {
return [...localDemoRecipes, ...PUBLIC_SCENE_RECIPES];
}
export const SCENE_RECIPES = createSceneRecipes();
export const SCENE_APPEND_RECIPES = BHOTE_KOSHI_NEPAL_APPEND_RECIPE
? [BHOTE_KOSHI_NEPAL_APPEND_RECIPE]
: [];
export function getSceneRecipeById(id) {
return SCENE_RECIPES.find((recipe) => recipe.id === id) || null;
}
export function getSceneAppendRecipeById(id) {
return SCENE_APPEND_RECIPES.find((recipe) => recipe.id === id) || null;
}
+1
View File
@@ -41,6 +41,7 @@ function sweepLayerParamKeys() {
/** The layer registry as main.js builds it (src/main.js dataManager.register calls). */
const REGISTERED = new Set([
'bhote-koshi-2026', 'bhote-koshi-locator',
'flights', 'military', 'earthquakes', 'satellites', 'rocket-launches', 'traffic',
'cctv', 'radio', 'bikeshare', 'ais-live-vessels', 'military-installations',
'military-awareness', 'local-datacenters', 'local-dams',
+7 -1
View File
@@ -66,7 +66,7 @@ test('share links parse explicit celestial on and off states', () => {
});
test('unknown-only v2 layer tokens are invalid, while historical l fields stay inert', () => {
const invalid = makeManager('#v=2&lat=10&lon=20&l=z').parseInitialHash();
const invalid = makeManager('#v=2&lat=10&lon=20&l=unknown').parseInitialHash();
assert.equal(invalid.layerState, null);
assert.equal(invalid.layerStateInvalid, true);
for (const hash of ['#lat=10&lon=20&l=z', '#v=1&lat=10&lon=20&l=z']) {
@@ -76,6 +76,12 @@ test('unknown-only v2 layer tokens are invalid, while historical l fields stay i
}
});
test('Nepal locator token is valid in v2 share links', () => {
const parsed = makeManager('#v=2&lat=10&lon=20&l=z').parseInitialHash();
assert.deepEqual(parsed.layerState.enabledLayerIds, ['bhote-koshi-locator']);
assert.equal(parsed.layerStateInvalid, false);
});
test('share-link serialization emits the current celestial state', () => {
const manager = makeManager();
manager.onToggleChange(false, false, { celestialRingEnabled: false });
+6
View File
@@ -43,6 +43,12 @@ export function createStandaloneApplication({
loaderStatus,
});
catalog = createStandaloneCatalog({
nepalBoundaryResolver: (signal) =>
scene.operations.annotationResolver.resolveRegionRingForQuery(
'Nepal',
signal,
placeSearch,
),
signal: context.signal,
surface: scene.operations.surface,
});
+2
View File
@@ -6,6 +6,7 @@ export { createStandaloneReferenceSources } from './layerSources.js';
/** Create fresh layer instances using the existing standalone source choices. */
export function createStandaloneCatalog({
nepalBoundaryResolver,
signal = new AbortController().signal,
surface = createSurfaceServices({
terrainSource: createApplicationRequestServices().terrain,
@@ -13,6 +14,7 @@ export function createStandaloneCatalog({
}),
} = {}) {
return createApplicationCatalog({
nepalBoundaryResolver,
surface,
sources: createStandaloneLayerSources(),
signal,
+38
View File
@@ -25,6 +25,11 @@ function fixture() {
appendChild(element) {
this.children.push(element);
}
setAttribute(name, value) {
this[name] = value;
}
focus() {}
select() {}
set textContent(value) {
this._text = value;
this.children = [];
@@ -164,6 +169,8 @@ test('Scene controls dispatch explicit actions and revoke replaced shot-row list
f.elements.start.click();
label.click();
label.dispatchEvent(new Event('dblclick'));
label.children[0].value = 'Renamed';
label.children[0].dispatchEvent(new Event('blur'));
load.click();
assert.deepEqual(f.calls, [
['capture'],
@@ -343,6 +350,8 @@ test('the real director preserves a selected shot label for the following double
label.click();
assert.equal(f.elements.shots.children[0].children[0].children[0], label);
label.dispatchEvent(new Event('dblclick'));
label.children[0].value = 'Renamed';
label.children[0].dispatchEvent(new Event('blur'));
assert.equal(director._getSelectedScene().shots[0].title, 'Renamed');
} finally {
await director?.destroy();
@@ -351,6 +360,35 @@ test('the real director preserves a selected shot label for the following double
}
});
test('inline shot names save on Enter, cancel on Escape, and reject blank names', () => {
const f = fixture();
const key = (name) => Object.assign(new Event('keydown'), { key: name });
try {
const label = f.elements.shots.children[0].children[0].children[0];
for (const [value, commitKey] of [
['Cancelled', 'Escape'],
[' ', 'Enter'],
[' New name ', 'Enter'],
]) {
label.dispatchEvent(new Event('dblclick'));
const input = label.children[0];
input.value = value;
input.dispatchEvent(key(commitKey));
input.dispatchEvent(new Event('blur'));
}
assert.deepEqual(f.calls, [
['renameShot', 'scene-a', 'shot-a', 'New name'],
]);
assert.equal(
label.textContent,
'<Shot A>',
'the model owns the next render',
);
} finally {
f.restore();
}
});
test('Scene controls consume current state, preserve rows on progress, and unsubscribe on destruction', () => {
const f = fixture();
try {
+28 -2
View File
@@ -61,10 +61,36 @@ export function renderSceneShots(
const label = document.createElement('div');
label.className = 'scene-shot-label';
label.textContent = shot.title;
label.title = 'Double-click to rename';
listen(label, 'click', () => select(shot.id));
listen(label, 'dblclick', () => {
const title = window.prompt('Shot title', shot.title);
if (title) rename(scene.id, shot.id, title);
if (label.children.length) return;
const input = document.createElement('input');
input.type = 'text';
input.className = 'scene-shot-rename';
input.setAttribute('aria-label', 'Shot name');
input.value = shot.title;
let finished = false;
const finish = (save) => {
if (finished) return;
finished = true;
const title = input.value.trim();
label.textContent = shot.title;
if (save && title && title !== shot.title)
rename(scene.id, shot.id, title);
};
listen(input, 'keydown', (event) => {
event.stopPropagation();
if (event.key === 'Enter' || event.key === 'Escape') {
event.preventDefault();
finish(event.key === 'Enter');
}
});
listen(input, 'blur', () => finish(true));
label.textContent = '';
label.appendChild(input);
input.focus();
input.select();
});
const actions = document.createElement('div');
actions.className = 'scene-shot-actions';
+613
View File
@@ -0,0 +1,613 @@
/* Bhote Koshi 2026 event reconstruction */
#bhote-koshi-event-panel {
position: fixed;
top: var(--right-stack-safe-top, 26vh);
right: var(--right-rail-x);
bottom: auto;
z-index: 142;
width: min(330px, calc(100vw - 40px));
max-height: calc(100vh - var(--right-stack-safe-top, 26vh) - 88px);
overflow-y: auto;
color: var(--text-primary);
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: 6px;
box-shadow: 0 18px 52px rgba(0, 0, 0, 0.48), inset 0 0 28px rgba(14, 78, 92, 0.12);
backdrop-filter: blur(24px) saturate(1.4);
font-family: var(--font-mono);
letter-spacing: 0;
pointer-events: auto;
scrollbar-width: none;
}
#bhote-koshi-event-panel::-webkit-scrollbar {
display: none;
}
#bhote-koshi-event-panel [hidden],
#bhote-koshi-split-line[hidden] {
display: none !important;
}
#bhote-koshi-embedded-media-root {
position: absolute;
inset: 0;
z-index: 7;
overflow: hidden;
pointer-events: none;
}
.bhote-embedded-media-warmup {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
clip-path: inset(50%);
}
.bhote-embedded-callout {
position: absolute;
box-sizing: border-box;
width: min(356px, calc(100% - 36px));
max-height: min(270px, calc(100% - 36px));
overflow: visible;
color: var(--text-primary);
font-family: var(--font-mono);
pointer-events: none;
}
.bhote-embedded-callout[hidden] {
display: none !important;
}
.bhote-embedded-callout-leader {
position: absolute;
top: 0;
left: 0;
z-index: 0;
width: 1px;
height: 1px;
overflow: visible;
pointer-events: none;
}
.bhote-embedded-callout-leader circle {
opacity: 0;
transform: scale(0.2);
transform-box: fill-box;
transform-origin: center;
}
.bhote-embedded-callout-leader path {
opacity: 0;
stroke-dasharray: 1;
stroke-dashoffset: 1;
}
.bhote-embedded-callout-body {
position: relative;
z-index: 1;
overflow: hidden;
background: rgba(5, 13, 19, 0.96);
border: 1px solid rgba(107, 232, 255, 0.32);
box-shadow: 0 18px 46px rgba(0, 0, 0, 0.56);
opacity: 0;
transform: translateY(8px) scale(0.98);
pointer-events: auto;
}
.bhote-embedded-callout-player {
width: 100%;
height: min(200px, 34vh);
aspect-ratio: 16 / 9;
min-height: 0;
overflow: hidden;
background: #03080d;
scrollbar-width: thin;
scrollbar-color: rgba(107, 232, 255, 0.36) transparent;
}
.bhote-embedded-callout.is-youtube {
width: min(280px, calc(100% - 36px));
max-height: min(228px, calc(100% - 36px));
}
.bhote-embedded-callout.is-youtube .bhote-embedded-callout-player {
height: min(158px, 27vh);
}
.bhote-embedded-callout.is-portrait {
width: min(214px, calc(100% - 36px));
max-height: min(390px, calc(100% - 36px));
}
.bhote-embedded-callout.is-portrait .bhote-embedded-callout-player {
width: 100%;
height: min(320px, 48vh);
aspect-ratio: 9 / 16;
}
.bhote-embedded-callout.is-entering .bhote-embedded-callout-leader circle {
animation: bhote-embedded-anchor-enter 240ms ease-out both;
}
.bhote-embedded-callout.is-entering .bhote-embedded-callout-leader path {
animation: bhote-embedded-leader-enter 420ms 150ms ease-out both;
}
.bhote-embedded-callout.is-entering .bhote-embedded-callout-body {
animation: bhote-embedded-body-enter 420ms 510ms ease-out both;
}
.bhote-embedded-callout.is-leaving .bhote-embedded-callout-body {
animation: bhote-embedded-body-exit 240ms ease-in both;
}
.bhote-embedded-callout.is-leaving .bhote-embedded-callout-leader path {
animation: bhote-embedded-leader-exit 300ms 190ms ease-in both;
}
.bhote-embedded-callout.is-leaving .bhote-embedded-callout-leader circle {
animation: bhote-embedded-anchor-exit 160ms 460ms ease-in both;
}
@keyframes bhote-embedded-anchor-enter {
from { opacity: 0; transform: scale(0.2); }
to { opacity: 1; transform: scale(1); }
}
@keyframes bhote-embedded-leader-enter {
from { opacity: 0; stroke-dashoffset: 1; }
to { opacity: 1; stroke-dashoffset: 0; }
}
@keyframes bhote-embedded-body-enter {
from { opacity: 0; transform: translateY(8px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes bhote-embedded-body-exit {
from { opacity: 1; transform: translateY(0) scale(1); }
to { opacity: 0; transform: translateY(6px) scale(0.985); }
}
@keyframes bhote-embedded-leader-exit {
from { opacity: 1; stroke-dashoffset: 0; }
to { opacity: 0; stroke-dashoffset: 1; }
}
@keyframes bhote-embedded-anchor-exit {
from { opacity: 1; transform: scale(1); }
to { opacity: 0; transform: scale(0.2); }
}
@media (prefers-reduced-motion: reduce) {
.bhote-embedded-callout.is-entering .bhote-embedded-callout-leader circle,
.bhote-embedded-callout.is-entering .bhote-embedded-callout-leader path,
.bhote-embedded-callout.is-entering .bhote-embedded-callout-body {
animation: none;
opacity: 1;
transform: none;
stroke-dashoffset: 0;
}
}
.bhote-embedded-callout.is-x .bhote-embedded-callout-player {
max-height: min(200px, 34vh);
aspect-ratio: auto;
overflow: auto;
}
.bhote-embedded-callout:not(.is-x) .bhote-embedded-callout-player iframe {
display: block;
width: 100%;
height: 100%;
min-height: 0;
border: 0;
}
.bhote-facebook-player-host {
width: 100%;
height: 100%;
}
.bhote-embedded-callout.is-x .bhote-embedded-callout-player iframe {
display: block;
width: 100% !important;
max-width: 100% !important;
min-height: 0;
border: 0;
}
.bhote-embedded-callout.is-x .bhote-embedded-callout-player > * {
margin: 0 auto !important;
}
.bhote-embedded-callout.is-unavailable .bhote-embedded-callout-player {
min-height: 92px;
background: repeating-linear-gradient(
135deg,
rgba(107, 232, 255, 0.06),
rgba(107, 232, 255, 0.06) 12px,
rgba(3, 8, 13, 0.92) 12px,
rgba(3, 8, 13, 0.92) 24px
);
}
.bhote-embedded-callout footer {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 2px 8px;
padding: 7px 9px 8px;
border-top: 3px solid #6be8ff;
}
.bhote-embedded-callout footer strong {
overflow: hidden;
font-size: 11px;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
.bhote-embedded-callout footer small {
grid-column: 1;
color: rgba(151, 211, 224, 0.7);
font-size: 8px;
line-height: 1.35;
}
.bhote-embedded-callout footer a {
grid-column: 2;
grid-row: 1 / span 2;
align-self: center;
color: #6be8ff;
font-size: 8px;
text-decoration: none;
}
.bhote-embedded-callout footer a:hover,
.bhote-embedded-callout footer a:focus-visible {
text-decoration: underline;
}
#right-context-rail.bhote-event-active > :not(#bhote-koshi-event-panel) {
display: none !important;
}
.bhote-event-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
padding: 15px 16px 12px;
border-bottom: 1px solid rgba(95, 213, 236, 0.18);
}
.bhote-event-kicker,
.bhote-event-section-head,
.bhote-event-status,
.bhote-event-evidence summary,
.bhote-event-report small {
font-size: 9px;
line-height: 1.35;
text-transform: uppercase;
color: var(--accent);
opacity: 0.72;
}
.bhote-event-title {
margin-top: 5px;
font-size: 17px;
line-height: 1.2;
color: var(--text-primary);
}
.bhote-event-icon-btn {
flex: 0 0 30px;
width: 30px;
height: 30px;
padding: 0;
border: 1px solid rgba(151, 211, 224, 0.26);
border-radius: 4px;
color: var(--text-primary);
background: var(--accent-dim);
font: inherit;
font-size: 18px;
cursor: pointer;
}
.bhote-event-status-row {
display: flex;
gap: 8px;
padding: 11px 16px 0;
}
.bhote-event-status {
padding: 4px 7px;
border: 1px solid rgba(80, 206, 231, 0.24);
border-radius: 3px;
background: var(--accent-dim);
}
.bhote-event-status.reconstructed {
color: var(--accent);
border-color: rgba(80, 206, 231, 0.24);
background: var(--accent-dim);
}
.bhote-event-section {
padding: 13px 16px 0;
}
.bhote-event-section-head,
.bhote-event-dates,
.bhote-event-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.bhote-event-section-head span:last-child {
color: rgba(222, 240, 245, 0.88);
}
.bhote-event-range {
width: 100%;
height: 4px;
margin: 11px 0 8px;
accent-color: var(--accent);
cursor: ew-resize;
}
.bhote-event-range.flood {
accent-color: var(--accent);
cursor: pointer;
}
.bhote-event-dates {
font-size: 8px;
color: rgba(174, 208, 216, 0.62);
}
.bhote-event-story-nav {
display: grid;
grid-template-columns: 34px minmax(0, 1fr) 34px;
align-items: stretch;
gap: 6px;
margin-top: 10px;
}
.bhote-event-story-nav > button {
width: 34px;
min-height: 54px;
padding: 0;
border: 1px solid rgba(88, 196, 218, 0.3);
border-radius: 4px;
color: var(--accent);
background: rgba(7, 25, 35, 0.9);
font: 22px/1 'JetBrains Mono', 'SFMono-Regular', Consolas, monospace;
cursor: pointer;
}
.bhote-event-story-current {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-content: center;
column-gap: 9px;
min-width: 0;
padding: 7px 10px;
border: 1px solid rgba(88, 196, 218, 0.18);
background: rgba(6, 19, 27, 0.72);
}
.bhote-event-story-current span,
.bhote-event-story-current small {
color: rgba(116, 216, 235, 0.7);
font-size: 8px;
line-height: 1.25;
}
.bhote-event-story-current strong {
overflow: hidden;
color: var(--text-primary);
font-size: 11px;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
.bhote-event-story-current small {
grid-column: 1 / -1;
margin-top: 4px;
overflow: hidden;
color: rgba(116, 216, 235, 0.7);
text-overflow: ellipsis;
white-space: nowrap;
}
.bhote-event-actions {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
justify-content: flex-start;
margin-top: 9px;
}
.bhote-event-actions button,
.bhote-event-report {
border: 1px solid rgba(88, 196, 218, 0.24);
border-radius: 4px;
color: var(--text-primary);
background: rgba(7, 25, 35, 0.9);
font: inherit;
font-size: 9px;
letter-spacing: 0;
cursor: pointer;
}
.bhote-event-actions button {
min-width: 0;
min-height: 30px;
padding: 0 5px;
overflow: hidden;
font-size: 8px;
text-overflow: ellipsis;
white-space: nowrap;
}
.bhote-event-actions button:disabled,
.bhote-event-story-nav > button:disabled {
opacity: 0.3;
cursor: default;
}
.bhote-event-actions button:first-child {
color: var(--accent);
border-color: rgba(88, 196, 218, 0.36);
}
.bhote-event-actions button[data-action="cinematic"][aria-pressed="true"] {
color: #071017;
border-color: var(--accent);
background: var(--accent);
}
.bhote-event-evidence {
margin: 13px 16px 0;
border-top: 1px solid rgba(95, 213, 236, 0.16);
border-bottom: 1px solid rgba(95, 213, 236, 0.16);
}
.bhote-event-evidence summary {
padding: 10px 0;
cursor: pointer;
}
.bhote-event-report-list {
display: grid;
gap: 6px;
padding-bottom: 10px;
}
.bhote-event-report {
display: grid;
gap: 3px;
width: 100%;
padding: 8px 9px;
text-align: left;
}
.bhote-event-report span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bhote-event-report small {
color: rgba(115, 210, 228, 0.58);
}
.bhote-event-credit {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin: 10px 16px 0;
padding: 8px 0 0;
border-top: 1px solid rgba(95, 213, 236, 0.16);
color: rgba(116, 216, 235, 0.62);
font-size: 8px;
line-height: 1.35;
}
.bhote-event-credit button {
flex: 0 0 auto;
min-height: 26px;
padding: 0 8px;
border: 1px solid rgba(88, 196, 218, 0.24);
border-radius: 4px;
color: var(--text-primary);
background: rgba(7, 25, 35, 0.9);
font: inherit;
cursor: pointer;
}
.bhote-event-caveat {
margin: 11px 16px 14px;
font-size: 8px;
line-height: 1.5;
color: rgba(179, 204, 210, 0.62);
}
#bhote-koshi-split-line {
position: fixed;
top: 0;
bottom: 0;
left: var(--bhote-koshi-split, 50%);
z-index: 8;
width: 1px;
pointer-events: none;
background: rgba(218, 241, 247, 0.76);
box-shadow: 0 0 12px rgba(94, 216, 239, 0.72);
}
#bhote-koshi-split-line span {
display: none;
}
#bhote-koshi-split-line .bhote-koshi-split-handle {
position: absolute;
top: 50%;
left: 50%;
width: 36px;
height: 36px;
padding: 0;
border: 1px solid rgba(180, 215, 222, 0.72);
border-radius: 50%;
outline: 0;
color: rgba(239, 247, 249, 0.96);
background: rgba(4, 17, 27, 0.96);
box-shadow:
0 0 0 5px rgba(62, 112, 125, 0.3),
0 0 13px rgba(94, 216, 239, 0.3);
font: 700 14px/1 var(--font-mono);
transform: translate(-50%, -50%);
pointer-events: auto;
touch-action: none;
cursor: ew-resize;
}
#bhote-koshi-split-line .bhote-koshi-split-handle:focus-visible {
outline: 2px solid #fff;
outline-offset: 4px;
}
@media (max-width: 720px) {
#bhote-koshi-event-panel {
right: 12px;
bottom: 88px;
width: calc(100vw - 24px);
max-height: 48vh;
overflow-y: auto;
}
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
width: 100%;
height: 100%;
overflow: hidden;
background: var(--bg-dark);
font-family: var(--font-sans);
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
}
+12
View File
@@ -244,12 +244,24 @@
}
.scene-shot-label {
min-width: 0;
flex: 1;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 1px;
color: var(--text-primary);
}
.scene-shot-rename {
width: 100%;
min-width: 0;
box-sizing: border-box;
font: inherit;
color: var(--text-primary);
background: var(--bg-panel, #101820);
border: 1px solid var(--accent, #00d4ff);
}
.scene-shot-meta {
margin-top: 4px;
font-family: var(--font-mono);
+1
View File
@@ -9,6 +9,7 @@
@import './src/ui/styles/radio.css';
@import './src/ui/styles/cctv.css';
@import './src/ui/styles/scenes.css';
@import './src/ui/styles/bhote-koshi.css';
@import './src/ui/styles/recording.css';
@import './src/ui/styles/responsive.css';
@import './src/ui/styles/command-dock.css';