mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
feat: add a Bhote Koshi flood event tool
This commit is contained in:
1 parent
a747b8a4e0
commit
0953ebb026
10 files changed
+243
-5
No files matched your search
@@ -15,7 +15,7 @@ pass over a point, and those overhead now), public cameras (including a
|
||||
camera's current image), license plate reader cameras, radio stations, place search, routing, bike-share
|
||||
stations, transit vehicles, road traffic flow, weather, weather map images
|
||||
(radar, satellite, lightning), wind, the most recent satellite image of an
|
||||
area, submarine cables, datacenters and dams, regional briefs, tropical cyclones, fire
|
||||
area, submarine cables, datacenters and dams, the Bhote Koshi flood event pack, regional briefs, tropical cyclones, fire
|
||||
perimeters, terrain height, military installations and map features, plus a
|
||||
combined situation brief, military awareness around a point, the app's heads-up display caption, and a link that
|
||||
opens the app over an area. Tools reuse the layers' portable source factories, take a
|
||||
|
||||
@@ -133,6 +133,7 @@ public feeds the sources already use.
|
||||
| `get_recent_imagery` | `imagery` | The most recent clear Landsat/Sentinel-2 image of an area (VIIRS fallback) |
|
||||
| `find_submarine_cables` | `cables` | TeleGeography cables and landing points by area or name (CC BY-NC-SA 3.0) |
|
||||
| `find_infrastructure` | `infrastructure` | OpenStreetMap datacenters or dams in an area, nearest first (ODbL) |
|
||||
| `get_bhote_koshi_flood` | `events` | The 2026 Bhote Koshi flood: evidence trail in story order, flood path and imagery dates (CC BY-NC 4.0) |
|
||||
| `get_regional_brief` | `regional` | What and where a location is, its weather and recent headlines |
|
||||
| `get_cyclones` | `cyclones` | Active NHC/CPHC tropical cyclones, optionally in an area |
|
||||
| `get_fire_perimeters` | `perimeters` | Mapped WFIGS wildfire perimeters in an area, largest first |
|
||||
|
||||
@@ -18,7 +18,8 @@ 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.
|
||||
imports before building, including the `get_bhote_koshi_flood` tool in
|
||||
`src/tools/queries/events.js`.
|
||||
|
||||
Sources:
|
||||
|
||||
|
||||
@@ -884,5 +884,6 @@
|
||||
"src/tools/queries/imagery.test.mjs",
|
||||
"src/tools/queries/cables.test.mjs",
|
||||
"src/tools/queries/surveillance.test.mjs",
|
||||
"src/tools/queries/infrastructure.test.mjs"
|
||||
"src/tools/queries/infrastructure.test.mjs",
|
||||
"src/tools/queries/events.test.mjs"
|
||||
]
|
||||
@@ -3117,7 +3117,9 @@
|
||||
"src/tools/queries/imagery.js",
|
||||
"src/tools/queries/cables.js",
|
||||
"src/tools/queries/surveillance.js",
|
||||
"src/tools/queries/infrastructure.js"
|
||||
"src/tools/queries/infrastructure.js",
|
||||
"src/tools/queries/events.js",
|
||||
"src/data/bhoteKoshiFloodPath.js"
|
||||
],
|
||||
"external": ["satellite.js"]
|
||||
},
|
||||
@@ -3193,7 +3195,8 @@
|
||||
"src/layers/alpr/policy.js",
|
||||
"src/layers/alpr/records.js",
|
||||
"src/layers/alpr/tileRecords.js",
|
||||
"src/sources/infrastructureData.js"
|
||||
"src/sources/infrastructureData.js",
|
||||
"src/sources/eventPacks.js"
|
||||
],
|
||||
"external": ["@mapbox/vector-tile", "pbf"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/** Event packs the app serves under `/events/<id>/`, read once per id. */
|
||||
|
||||
const EVENT_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
|
||||
export function createEventPackSource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
} = {}) {
|
||||
const cache = new Map();
|
||||
return {
|
||||
async getEvent(id, { signal } = {}) {
|
||||
if (!EVENT_ID.test(id)) throw new TypeError(`Invalid event id: ${id}`);
|
||||
if (!cache.has(id)) {
|
||||
signal?.throwIfAborted();
|
||||
const response = await fetchImpl(`/events/${id}/event.json`, {
|
||||
signal,
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new Error(`Event pack ${id} unavailable (${response.status})`);
|
||||
cache.set(id, await response.json());
|
||||
}
|
||||
return cache.get(id);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
getTerrainHeight,
|
||||
getWeather,
|
||||
} from './queries/environment.js';
|
||||
import { getBhoteKoshiFlood } from './queries/events.js';
|
||||
import { getActiveFires, getEarthquakes } from './queries/hazards.js';
|
||||
import { getRecentImagery } from './queries/imagery.js';
|
||||
import { findInfrastructure } from './queries/infrastructure.js';
|
||||
@@ -103,6 +104,7 @@ export const coreTools = Object.freeze([
|
||||
getRecentImagery,
|
||||
findSubmarineCables,
|
||||
findInfrastructure,
|
||||
getBhoteKoshiFlood,
|
||||
getRegionalBrief,
|
||||
getCyclones,
|
||||
getFirePerimeters,
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
/** The bundled Bhote Koshi 2026 outburst flood event pack. */
|
||||
|
||||
import { BHOTE_KOSHI_FLOOD_PATH } from '../../data/bhoteKoshiFloodPath.js';
|
||||
import { defineTool, ToolError } from '../catalog.js';
|
||||
import { distanceKm } from '../area.js';
|
||||
|
||||
const EVENT_ID = 'bhote-koshi-2026';
|
||||
const ATTRIBUTION =
|
||||
'Imagery: Vantor Open Data; reconstruction: GeoPera (CC BY-NC 4.0). ' +
|
||||
'Linked witness posts and the GeoGeorgeShadrach geolocation map keep ' +
|
||||
"their owners' terms.";
|
||||
|
||||
const round = (value, digits) =>
|
||||
Number.isFinite(value) ? Number(value.toFixed(digits)) : null;
|
||||
|
||||
/** Length of a `[lon, lat]` line in kilometers. */
|
||||
function pathLengthKm(path) {
|
||||
let total = 0;
|
||||
for (let index = 1; index < path.length; index += 1)
|
||||
total += distanceKm(
|
||||
{ lat: path[index - 1][1], lon: path[index - 1][0] },
|
||||
{ lat: path[index][1], lon: path[index][0] },
|
||||
);
|
||||
return total;
|
||||
}
|
||||
|
||||
const pathPoint = ([lon, lat]) => ({ lat, lon });
|
||||
|
||||
function evidenceRow(record) {
|
||||
return {
|
||||
sequence: record.sequence ?? null,
|
||||
id: record.id,
|
||||
title: record.title ?? null,
|
||||
summary: record.summary ?? null,
|
||||
phase: record.phase ?? null,
|
||||
lat: round(record.lat, 5),
|
||||
lon: round(record.lon, 5),
|
||||
location_confidence: record.confidence?.location ?? null,
|
||||
time_confidence: record.confidence?.time ?? null,
|
||||
captured_at: record.timing?.capturedAt ?? null,
|
||||
in_imagery: record.imageryCoverage === 'within',
|
||||
source_url: record.media?.sourceUrl ?? null,
|
||||
platform: record.media?.platform ?? null,
|
||||
via: record.provenance?.via ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export const getBhoteKoshiFlood = defineTool({
|
||||
name: 'get_bhote_koshi_flood',
|
||||
title: 'Bhote Koshi flood',
|
||||
description:
|
||||
"The 26 August 2026 Bhote Koshi outburst flood in Nepal as God's Eye " +
|
||||
'View maps it: the evidence trail of geolocated public witness posts ' +
|
||||
'from the collapse to Devighat in story order, the mapped flood path, ' +
|
||||
'and the before and after satellite imagery. A visualization, not an ' +
|
||||
'official hazard model; capture times are unverified.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
phase: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
maxLength: 40,
|
||||
description: 'Only evidence in a phase, such as "downstream passage".',
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['events'],
|
||||
async run(args, { services, signal }) {
|
||||
const event = await services.events.getEvent(EVENT_ID, { signal });
|
||||
if (!Array.isArray(event?.evidenceSpine))
|
||||
throw new ToolError('malformed', 'The event pack has no evidence');
|
||||
const wanted = args.phase?.toLowerCase();
|
||||
const evidence = event.evidenceSpine
|
||||
.filter((record) => !wanted || record.phase?.toLowerCase() === wanted)
|
||||
.sort((a, b) => a.sequence - b.sequence)
|
||||
.map(evidenceRow);
|
||||
const phases = [
|
||||
...new Set(event.evidenceSpine.map((record) => record.phase)),
|
||||
];
|
||||
const lengthKm = round(pathLengthKm(BHOTE_KOSHI_FLOOD_PATH), 1);
|
||||
const { imagery = {} } = event;
|
||||
return {
|
||||
summary:
|
||||
`${event.title}, observed ${event.observedDate}: ${evidence.length} ` +
|
||||
`evidence record${evidence.length === 1 ? '' : 's'}` +
|
||||
(args.phase ? ` in the ${args.phase} phase` : '') +
|
||||
` along a ${lengthKm} km mapped flood path.`,
|
||||
data: {
|
||||
id: event.id,
|
||||
title: event.title,
|
||||
observed_date: event.observedDate,
|
||||
caveat: event.reconstruction?.caveat ?? null,
|
||||
phases,
|
||||
evidence,
|
||||
witnesses: (event.witnessAnchors || []).map((anchor) => ({
|
||||
title: anchor.title ?? null,
|
||||
lat: round(anchor.lat, 6),
|
||||
lon: round(anchor.lon, 6),
|
||||
source_url: anchor.sourceUrl ?? null,
|
||||
provenance: anchor.provenance ?? null,
|
||||
})),
|
||||
flood_path: {
|
||||
start: pathPoint(BHOTE_KOSHI_FLOOD_PATH[0]),
|
||||
end: pathPoint(BHOTE_KOSHI_FLOOD_PATH.at(-1)),
|
||||
length_km: lengthKm,
|
||||
description:
|
||||
'Simplified river centerline from the debris-dammed lake to Trishuli Bazaar.',
|
||||
},
|
||||
imagery: {
|
||||
box: imagery.rectangle ?? null,
|
||||
before: imagery.before
|
||||
? {
|
||||
observed_at: imagery.before.observedAt,
|
||||
source: imagery.before.source,
|
||||
}
|
||||
: null,
|
||||
after: imagery.after
|
||||
? {
|
||||
observed_at: imagery.after.observedAt,
|
||||
source: imagery.after.source,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
attribution: ATTRIBUTION,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
import { composeCatalog, coreTools } from '../index.js';
|
||||
import { createEventPackSource } from '../../sources/eventPacks.js';
|
||||
|
||||
const EVENT_URL = new URL(
|
||||
'../../../public/events/bhote-koshi-2026/event.json',
|
||||
import.meta.url,
|
||||
);
|
||||
const bundled = JSON.parse(await readFile(EVENT_URL, 'utf8'));
|
||||
|
||||
test('the event pack source reads each event once and checks the id', async () => {
|
||||
const requested = [];
|
||||
const source = createEventPackSource({
|
||||
fetchImpl: async (url) => {
|
||||
requested.push(url);
|
||||
return Response.json(bundled);
|
||||
},
|
||||
});
|
||||
const event = await source.getEvent('bhote-koshi-2026');
|
||||
assert.deepEqual(event, bundled);
|
||||
assert.equal(await source.getEvent('bhote-koshi-2026'), event);
|
||||
assert.deepEqual(requested, ['/events/bhote-koshi-2026/event.json']);
|
||||
await assert.rejects(source.getEvent('../secret'), /Invalid event id/);
|
||||
const missing = createEventPackSource({
|
||||
fetchImpl: async () => new Response(null, { status: 404 }),
|
||||
});
|
||||
await assert.rejects(missing.getEvent('other'), /unavailable \(404\)/);
|
||||
});
|
||||
|
||||
test('the flood tool lists the bundled evidence in story order', async () => {
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { events: { getEvent: async () => bundled } },
|
||||
});
|
||||
const all = await catalog.call('get_bhote_koshi_flood', {});
|
||||
assert.match(
|
||||
all.summary,
|
||||
/^Bhote Koshi Outburst Flood, observed 2026-08-26: 16 evidence records along a \d+(\.\d)? km mapped flood path\.$/,
|
||||
);
|
||||
assert.deepEqual(
|
||||
all.data.evidence.map((row) => row.sequence),
|
||||
Array.from({ length: 16 }, (_, index) => index + 1),
|
||||
);
|
||||
assert.deepEqual(all.data.evidence[3], {
|
||||
sequence: 4,
|
||||
id: 'gyirong-border-gate',
|
||||
title: 'Gyirong border gate',
|
||||
summary: 'Public footage geolocated at the Nepal-China border crossing.',
|
||||
phase: 'border arrival',
|
||||
lat: 28.28083,
|
||||
lon: 85.37802,
|
||||
location_confidence: 'geolocated',
|
||||
time_confidence: 'unverified',
|
||||
captured_at: null,
|
||||
in_imagery: true,
|
||||
source_url: 'https://www.youtube.com/watch?v=qlE51Eu0thk',
|
||||
platform: 'YouTube',
|
||||
via: 'GeoGeorgeShadrach public geolocation map; independently consistent with the linked GeoConfirmed witness',
|
||||
});
|
||||
assert.deepEqual(all.data.flood_path.start, { lat: 28.33255, lon: 85.48476 });
|
||||
assert.ok(all.data.flood_path.length_km > 50);
|
||||
assert.equal(all.data.imagery.after.observed_at, '2026-08-27');
|
||||
assert.match(all.data.caveat, /not an official hazard model/);
|
||||
assert.match(all.data.attribution, /CC BY-NC 4\.0/);
|
||||
const downstream = await catalog.call('get_bhote_koshi_flood', {
|
||||
phase: 'Downstream passage',
|
||||
});
|
||||
assert.ok(
|
||||
downstream.data.evidence.every((row) => row.phase === 'downstream passage'),
|
||||
);
|
||||
assert.match(downstream.summary, /in the Downstream passage phase/);
|
||||
});
|
||||
@@ -26,6 +26,7 @@ import { createTransitSource } from '../layers/transit/source.js';
|
||||
import { createWeatherSource } from '../layers/weather/source.js';
|
||||
import { createWindSource } from '../layers/wind/source.js';
|
||||
import { createApplicationRequestServices } from '../services/requests.js';
|
||||
import { createEventPackSource } from '../sources/eventPacks.js';
|
||||
import { createInfrastructureSource } from '../sources/infrastructureData.js';
|
||||
import {
|
||||
createAdsbLolSource,
|
||||
@@ -72,6 +73,7 @@ export function createToolServices({ fetchImpl, appUrl }) {
|
||||
cables: createBundledCableSource({ fetchImpl }),
|
||||
alpr: createAlprTileSource({ tileFetchImpl: fetchImpl }),
|
||||
infrastructure: createInfrastructureSource({ fetchImpl }),
|
||||
events: createEventPackSource({ fetchImpl }),
|
||||
regional: requests.regional,
|
||||
terrain: requests.terrain,
|
||||
summary: requests.summary,
|
||||
|
||||
Reference in new issue
Block a user