Compare commits

..
Author SHA1 Message Date
Bilawal Sidhu 9c2fce4254 fix(weather): queue RainViewer tiles briefly instead of failing a burst 2026-09-28 20:46:58 -05:00
Bilawal Sidhu 9f3cdf8203 fix(weather): wait out metered RainViewer tiles instead of caching holes 2026-09-28 20:46:58 -05:00
Bilawal SidhuandSandiv Dathunarachchi d496dd9f33 feat(weather): global rain radar from RainViewer
Add a Global (RainViewer) option to Rain radar beside the US MRMS
radar. The proxy pins RainViewer's tile host, validates frames and
Web Mercator tile coordinates, and limits upstream tile requests to
80 a minute. Globe basemaps draw the tiles; on 3D Tiles the shell
composes them into one equirectangular image per frame and detail
window.

Co-authored-by: Sandiv Dathunarachchi <[email protected]>
2026-09-28 20:46:58 -05:00
151 changed files with 2565 additions and 16377 deletions

No files matched your search

-5
View File
@@ -186,8 +186,3 @@ AISSTREAM_API_KEY=
# Optional: comma-separated Overpass URLs; use only instances you run or pay for.
OVERPASS_UPSTREAMS=
# Optional: who may frame the app in embed mode (?embed=1), as CSP
# frame-ancestors sources such as "https://example.com". Unset allows any
# page; every other page of the app can never be framed.
# GEV_EMBED_FRAME_ANCESTORS=
+32 -55
View File
@@ -1,32 +1,9 @@
# Changelog
## [Unreleased]
## [0.2.0] — 2026-10-02 — God's Eye View through Your Agent
- Show God's Eye View inside AI conversations. Answers that can be shown
include a view (camera, layers, style, map, marks, something to follow),
and `show_in_gods_eye_view` displays it as the live globe in clients that
support MCP Apps, such as Claude Desktop and the Codex and ChatGPT desktop
apps, and as a link everywhere else. The panel loads the app's panel build
(`npm run build:panel`) and its data through the MCP server, so a local
server needs no HTTPS or tunnel. `?embed=1` shows the globe alone for pages
that frame the app.
- Add tools for language-model clients and a local MCP server, served over
stdio by `npm run mcp` and over HTTP at `/mcp` on the development and preview
servers for local requests only. `npm run mcp`
serves earthquake, active-fire, launch, aircraft, ship, satellite, camera, radio,
place, routing, bike-share, transit, traffic, weather, cyclone, fire-perimeter,
terrain, installation and map-feature queries, plus a situation brief, over
stdio to clients such as Claude Code, reading
from a running app. Tools are defined
once in `gods-eye-view/tools`, reuse the layers' source factories, and are
exposed through the protocol adapter in `gods-eye-view/tools/mcp`. The
catalog also covers weather maps, wind, recent imagery, submarine cables,
license plate reader cameras, datacenters and dams, the Bhote Koshi flood,
military awareness and links that open the app over an area with layers on.
Voice offers the same queries next to its app actions.
- Add Global (RainViewer) beside US (NOAA) in Rain radar, with shared observed
history and share-link region selection. Validated, rate-limited Web Mercator
tiles draw on globe basemaps and compose into reprojected images on 3D Tiles
shells; coverage follows national radar networks, with 2 hours of history.
- Public Overpass instances are no longer used by default. Street Traffic
roads come from TomTom flow tiles, OpenFreeMap vector tiles, or both, chosen
@@ -210,7 +187,7 @@
(`src/ui/imagerySplit.js`, `src/maps/imageryComparison.js`), and
`MapSourceController.subscribe()` reports every settled map switch.
### Local receiver feeds
## Unreleased — local receiver feeds
- The Local ADS-B layer also reads local 1090 MHz and 978 MHz UAT decoder
feeds: the `aircraft.json` that dump1090-fa, readsb, tar1090 or skyaware978
@@ -239,7 +216,7 @@
- Records carry `band` (`1090`/`978`) and `source` (`webusb`/`feed`).
- See `docs/LOCAL-RECEIVERS.md`.
### Local RTL-SDR and Local ADS-B
## Unreleased — local RTL-SDR and Local ADS-B
- Add a Local RTL-SDR card to the Radio panel. It connects a USB RTL-SDR in
desktop Chrome or Edge through WebUSB and receives broadcast FM (tune, seek,
@@ -264,7 +241,7 @@
- Add `@jtarrio/webrtlsdr` and `@jtarrio/signals` (Apache-2.0); see
`THIRD_PARTY_NOTICES.md`.
### Weather review
## Unreleased — weather review
- On 3D Tiles, draw a 4096×2048 detail window around the view on each
observed-weather shell except global infrared, sampled by the shell's own
@@ -530,26 +507,26 @@ Analyst records for loaded satellites, datacenters and dams, with explicit bound
- Split application scene, controls, catalog, tools and HTML into reusable components; configure application request services and sources without changing global fetch. Preserve standalone markup and voice behavior. Explicit annotation navigation may resolve a distant named target.
### Satellite pass prediction
## Satellite pass prediction
- Bisect pass rise/set to ~0.2 s and fit peak elevation with a parabola.
- Mark passes visible from Earth-shadow and civil-twilight checks.
- Add `getNextSatellitePass(noradId, options)` for any loaded catalog satellite.
- `next_iss_pass` retains the next geometric pass and adds visibility metadata. `next_satellite_pass` adds bounded loaded-catalog name/NORAD lookup and optional visible-only filtering (Rehaan Delmotra, #451; maintainer adaptation).
### Voice component boundaries
## Voice component boundaries
- Separate voice controls, Realtime connection requests and the action runner.
- Allow compatible endpoints and server-selected models through construction options.
- Cancel pending token/SDP requests on Stop or teardown and reject expired secrets.
### Configurable geospatial services
## Configurable geospatial services
- Compose geocoding, place context and routes through independent providers.
- Allow compatible endpoint configuration without changing voice tools or annotation behavior.
- Isolate configured source caches and reject results after cancellation.
### ALPR camera locations
## ALPR camera locations
- Port Manjunath's (@manjunath22466) cyan camera badges, coral selection brackets,
gradient direction wedges and animated tactical labels into the reusable ALPR
@@ -569,32 +546,32 @@ Analyst records for loaded satellites, datacenters and dams, with explicit bound
- Separate the request adapter, camera model, presentation, and instance lifecycle.
Source cancellation also guards late response bodies and rejects invalid query bounds.
### Release disabled infrastructure rendering
## Release disabled infrastructure rendering
- Remove built Data Center, Dam and Submarine Cable entities when their layers
are disabled, avoiding retained visualizer work and entity memory.
- Keep parsed datasets cached for re-enable; rebuild entities without refetching.
### Camera layer components
## Camera layer components
- Separate camera source requests, placement, frames, projection, cards and calibration.
- Own visibility listeners and pending initialization within each layer lifetime.
- Preserve existing camera catalogs, URL families, geometry and playback behavior.
### Traffic and bikeshare components
## Traffic and bikeshare components
- Separate traffic loading, animation, styling and lifecycle into factory-owned components.
- Give each flow source its own bounded decode cache and cancellation checks.
- Separate bikeshare registry, station requests, rendering, selection and proximity handling.
### Installation and context components
## Installation and context components
- Separate mapped-site requests, records, placement, selection and viewport lifecycle.
- Separate proximity queries, subject tracking, navigation/history, panel and direction rendering.
- Retain source and ground-floor ownership in standalone composition; reject malformed
installation snapshots and ignore failures from cancelled requests.
### Satellite and mission layer components
## Satellite and mission layer components
- Separate catalog loading, orbit calculations, display, tracking and interaction
into instance-owned satellite components.
@@ -602,20 +579,20 @@ Analyst records for loaded satellites, datacenters and dams, with explicit bound
operations, retaining existing layer controls and satellite coordination.
- Cancel late mission source work and reject malformed launch snapshots.
### Fire layer components
## Fire layer components
- Split fire source loading, state, rendering, cards, selection and viewport work
into reusable components with application-owned scene services.
- Cancel late refreshes, retain good data after malformed responses, and preserve
selection identity without repeating a user-selection notification on refresh.
### Earthquake components
## Earthquake components
- Separate earthquake snapshot loading, record validation, and display ownership.
- Cancel pending earthquake refreshes on disable or destruction, retaining the
last good snapshot after malformed or failed refreshes.
### September 8, 2026
## September 8, 2026
Earthquake refreshes validate the complete feed and construct replacement entities before clearing the previous snapshot. Malformed rows and duplicate rendered IDs retain the last good entities, overlays, count and timestamp and report a malformed response; unknown magnitude is excluded from M2.5+ rendering.
@@ -626,7 +603,7 @@ Launch payloads with missing records now say PAYLOAD DATA UNAVAILABLE. Missing n
This changelog records public product changes. For the authoritative description
of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md).
### Other changes
## [Unreleased]
- Add ECMWF IFS model selection to Wind (#464, thanks @beneduzi), with model-scoped forecast-step caches, cancellation of replaced requests, and separate issue/valid timestamps.
@@ -647,7 +624,7 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
- Separate submarine cable sources and rendering components, and export bundled geography lookup modules.
#### Added
### Added
- DISPLAY ▸ Draw: draw on the world by hand. Pick Area, Line or Pin, click the
vertices, double-click or press Enter to finish, label and colour it; Backspace
@@ -657,7 +634,7 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
together. While you are drawing, the draw tool owns the pointer and no layer
selects what you click through (#235 — thanks @cora-fresh-labs).
#### Fixed
### Fixed
- Keep traffic-road bounds crossing the antimeridian monotonic and inside the
longitude range accepted by the Overpass request path, preserving the small
@@ -724,7 +701,7 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
toggle. Camera departure cancels pending work, arrival checks the final view,
and superseded requests cannot keep a newer view loading.
#### Added
### Added
- Add MODIS NRT (Terra+Aqua, ~1 km) active fires to the FIRMS layer, sharing the
existing `FIRMS_MAP_KEY` and 30-minute cache.
@@ -852,9 +829,9 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
- Extract shared surface keyboard handling for the welcome launcher and Provider
Settings, preserving Tab/Escape behavior and releasing the listener on teardown.
#### Added
### Added
#### Security
### Security
- The CCTV media route no longer forwards a client `Range` header to the upstream
camera host as it arrived. A single `bytes=` range is canonicalized and
@@ -885,7 +862,7 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
- Cancel the active location lookup when its controls are disposed.
#### Fixed
### Fixed
- `DATA_SOURCES.md` states what the project does with camera frame content: a
successful upstream response is relayed as the provider served it, nothing in
@@ -964,7 +941,7 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
fetching, AIS records/tracks and shared request helpers; preserve existing
routes, local setup, fallback behavior and rendering.
#### Added
### Added
- **Directions layer** — keyless A→B directions without a geocoder or a
microphone (thanks @spcpza). The row's chips arm a globe click for A and B
@@ -992,7 +969,7 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
voice routes since launch, previously uncredited), with the OpenStreetMap
credit and the "fix the map" link the service's usage policy asks for.
#### Changed
### Changed
- The interface asks Google Fonts for only the icon glyphs it draws, instead of
the whole variable icon font, and no longer requests a second icon family that
@@ -1007,7 +984,7 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
- Rename standalone browser startup to `src/standalone/` and add a Node-only
`gods-eye-view/build/vite` export with checked package ownership.
#### Development
### Development
- The CCTV launcher and preview-server tests resolve their temporary fixture
root through `fs.realpath`, so they pass on macOS, where the system temp
@@ -1041,7 +1018,7 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
infrastructure modules and their consumer tests. Package boundary checks keep
those exports separate from app startup and local Node services.
#### Fixed
### Fixed
- Reduce terrain-height timeouts when Re:Earth slows down. Batches are
sized against measured response latency on both browser and server to reduce
@@ -1132,7 +1109,7 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
and upstream error bodies; response statuses and cache fallback remain intact.
Includes the security fixes contributed by Tom-Neverwinter in PR #171.
#### Fixed
### Fixed
- Map Source keyboard opening retries focus until the selected tile is visible.
Leaving the disclosure, pointer interaction, or closing the tray cancels the
@@ -1174,7 +1151,7 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
that had since recovered. A miss is now cached only when every source
consulted actually returned a verdict.
#### Added
### Added
- Keyless place search. The LOCATION search box and the `fly_to_location` voice
tool now resolve place names through Photon (komoot, over OpenStreetMap) when
+1
View File
@@ -18,6 +18,7 @@ How to read this:
| **NOAA GFS (wind)** (`noaa-gfs-bdp-pds.s3.amazonaws.com`) | Global 10 m wind, optional 2 m temperature and mean sea-level pressure for Wind. Keyless; latest 6-hourly 0.25° cycle, byte-range GRIB2 reads, cached for an hour | U.S. public domain (NOAA); keyless via NOAA Open Data on AWS | "NOAA Global Forecast System (GFS)" (courtesy; not an endorsement) |
| **ECMWF IFS (wind)** (`data.ecmwf.int/forecasts`, ECMWF Open Data) | The alternative Wind model: 10 m wind, optional 2 m temperature and mean sea-level pressure. Keyless; latest 6-hourly 0.25° run, byte-range GRIB2 reads, cached for an hour | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) plus the [ECMWF Terms of Use](https://apps.ecmwf.int/datasets/licences/general/) | "This service is based on data and products of the European Centre for Medium-Range Weather Forecasts (ECMWF)", with the CC BY 4.0 link, the modification notice and ECMWF's liability disclaimer — shown in-app |
| **NOAA nowCOAST observed weather** (`nowcoast.noaa.gov/geoserver/observations/{weather_radar,satellite}/ows`, WMS 1.1.1) | Rain radar (MRMS base reflectivity, contiguous US, about 4-minute updates) and Satellite clouds (GOES-19/18 Band 14 regional infrared, about 5-minute updates; global infrared mosaic, hourly). Keyless; layer metadata refreshed every 2 minutes, exact-time images cached for up to 24 hours | [NOAA disclaimer](https://oceanservice.noaa.gov/disclaimer.html) | "NOAA nowCOAST · NWS/OAR MRMS radar; NESDIS GOES and global satellite partners" |
| **RainViewer** (`api.rainviewer.com`, `tilecache.rainviewer.com`) | Rain radar, Global: past 2 hours of radar composite tiles at 10-minute steps, zoom ≤ 7. Keyless; manifest cached 5 minutes, tiles cached like other weather tiles, upstream limited to 80 tile requests a minute; excess requests wait up to 20 seconds in a bounded queue | [RainViewer API terms](https://www.rainviewer.com/api.html) | RainViewer (rainviewer.com), linked in-app |
| **NOAA nowCOAST lightning density** (`nowcoast.noaa.gov/geoserver/observations/lightning_detection/ows`) | Lightning density: 15-minute accumulated strike density on an approximately 8 km grid, Americas and Pacific. Keyless; layer metadata refreshed every 10 minutes, exact-time images cached for up to 24 hours | NOAA Level-5 derived product from Vaisala NLDN/GLD360; the [product description](https://ocean.weather.gov/lightning/lightning_pdd.php) permits public distribution of this density product, not of raw Vaisala detections | "NOAA/NWS nowCOAST; derived from Vaisala NLDN/GLD360" |
| **NOAA NHC / CPHC advisories** (`www.nhc.noaa.gov/CurrentStorms.json` + the `mapservices.weather.noaa.gov` tropical weather summary MapServer) | Cyclone advisories: storm positions, forecast tracks, points and cones for the Atlantic and eastern/central North Pacific. Keyless through `/api/cyclones`; cached for 5 minutes | [NWS public-data terms](https://www.weather.gov/disclaimer); no endorsement implied | "NOAA/NWS NHC / CPHC" |
| **OpenStreetMap** | Map and place data (roads, military areas and names, ALPR, place search, outlines); via vector tiles, hourly ALPR extract, bundled data, Nominatim and optional self-configured Overpass | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | © OpenStreetMap contributors |
-2
View File
@@ -279,8 +279,6 @@ _Ask for radio near anywhere and the globe starts broadcasting — every station
---
**💬 Or ask from Claude and Codex.** God's Eye View is also an MCP server: ask Claude Desktop, Codex or ChatGPT desktop to show a place, and the live globe opens right in the conversation. Setup takes a minute — see [docs/MCP_SETUP.md](docs/MCP_SETUP.md).
## 🛰️ What's on the Globe
Nineteen layers and map sources. **Seventeen have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.)
-55
View File
@@ -1,55 +0,0 @@
/**
* Lets other pages frame the app in embed mode (`?embed=1`). Every other
* document keeps the server's `X-Frame-Options: DENY` and
* `frame-ancestors 'none'`, which protect Provider Settings. Embed mode
* shows only the globe, so for those documents framing is limited to
* `ancestors` instead: `GEV_EMBED_FRAME_ANCESTORS`, or any frame by default.
* "Any" sends no framing restriction at all: `frame-ancestors *` would still
* refuse sandboxed frames with an opaque origin, which panel hosts use.
*/
const FRAMING_HEADERS = new Set(['x-frame-options', 'content-security-policy']);
/** Whether a request is for an embed-mode document. */
export function isEmbedDocumentRequest(url) {
let parsed;
try {
parsed = new URL(url || '/', 'http://localhost');
} catch {
return false;
}
return (
parsed.searchParams.get('embed') === '1' &&
(parsed.pathname === '/' || parsed.pathname.endsWith('.html'))
);
}
/** Vite plugin applying the embed framing policy on dev and preview servers. */
export function embedFramingPlugin({
ancestors = process.env.GEV_EMBED_FRAME_ANCESTORS || '*',
} = {}) {
const anywhere = ancestors.trim() === '*';
const policy = `frame-ancestors ${ancestors}`;
const install = (server) => {
server.middlewares.use((req, res, next) => {
if (!isEmbedDocumentRequest(req.url)) return next();
// The server's own headers are written when the response is sent, so
// intercept them rather than setting ours first.
const setHeader = res.setHeader.bind(res);
res.setHeader = (name, value) => {
const key = String(name).toLowerCase();
if (!FRAMING_HEADERS.has(key)) return setHeader(name, value);
if (key === 'content-security-policy' && !anywhere)
return setHeader(name, policy);
return res;
};
if (!anywhere) setHeader('Content-Security-Policy', policy);
next();
});
};
return {
name: 'embed-framing',
configureServer: install,
configurePreviewServer: install,
};
}
-153
View File
@@ -1,153 +0,0 @@
/**
* The MCP Apps panel build. A panel loads the app through its MCP server,
* one tool call per file (see src/tools/globePanel.js), so the app must be
* few files: one app script, one stylesheet, and Cesium's own script, which
* carries its workers and starts them from memory. The build is served at
* PANEL_BASE next to the app.
*/
// The panel loads these paths (src/tools/globePanel.js); a test keeps the
// two in step.
export const PANEL_BASE = '/panel/';
/** The script the panel runs ahead of Cesium's workers (workerFilesPrelude). */
export const PANEL_WORKER_PRELUDE_PATH = 'cesium/worker-prelude.js';
export const PANEL_OUT_DIR = 'dist/panel';
/** A browser Vite config changed to produce the panel build. */
export function panelBuildConfig(config) {
return {
...config,
base: PANEL_BASE,
build: {
...config.build,
outDir: PANEL_OUT_DIR,
emptyOutDir: true,
modulePreload: false,
cssCodeSplit: false,
rollupOptions: {
...config.build?.rollupOptions,
output: { inlineDynamicImports: true },
},
},
};
}
const CONTENT_TYPES = {
'.css': 'text/css',
'.geojsonl': 'application/geo+json-seq',
'.glb': 'model/gltf-binary',
'.html': 'text/html',
'.jpg': 'image/jpeg',
'.js': 'text/javascript',
'.json': 'application/json',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.wasm': 'application/wasm',
'.woff2': 'font/woff2',
'.xml': 'application/xml',
};
/**
* Vite plugin serving the panel build at PANEL_BASE on the dev server, as
* the preview server and production builds serve it from the build output.
*/
export function panelBuildPlugin({ outDir = PANEL_OUT_DIR } = {}) {
return {
name: 'panel-build',
configureServer(server) {
server.middlewares.use(async (req, res, next) => {
const { pathname } = new URL(req.url || '/', 'http://localhost');
if (!pathname.startsWith(PANEL_BASE)) return next();
const { readFile } = await import('node:fs/promises');
const { extname, resolve, sep } = await import('node:path');
const root = resolve(server.config.root, outDir);
let relative;
try {
relative = decodeURIComponent(pathname.slice(PANEL_BASE.length));
} catch {
res.statusCode = 400;
res.end('Bad path');
return;
}
const file = resolve(root, relative || 'index.html');
if (file !== root && !file.startsWith(root + sep)) return next();
try {
const body = await readFile(file);
res.setHeader(
'Content-Type',
CONTENT_TYPES[extname(file)] || 'application/octet-stream',
);
res.end(body);
} catch {
res.statusCode = 404;
res.end(
relative ? 'Not found' : 'No panel build; run npm run build:panel',
);
}
});
},
};
}
/**
* Cesium files its workers load themselves, as paths under Cesium's base.
* Workers request them from the panel page's own site, which has none of
* the app's files, so the panel build embeds them in the workers script.
*/
export const PANEL_WORKER_FILES = Object.freeze([
'Assets/approximateTerrainHeights.json',
]);
/**
* A script, run in each worker before Cesium's, that answers the worker's
* requests for `files` (Cesium-relative path → text) from memory and lets
* every other request through.
*/
export function workerFilesPrelude(files) {
const answer = (FILES) => {
const find = (url) => {
const path = String(url).split(/[?#]/)[0];
for (const name of Object.keys(FILES))
if (path.endsWith(`/${name}`)) return FILES[name];
return null;
};
const xhr = XMLHttpRequest.prototype;
const { open, send, setRequestHeader } = xhr;
xhr.open = function (method, url, ...rest) {
this.__gevFile = find(url);
if (this.__gevFile === null) return open.call(this, method, url, ...rest);
};
xhr.setRequestHeader = function (...args) {
if (this.__gevFile === null) return setRequestHeader.apply(this, args);
};
xhr.send = function (body) {
const text = this.__gevFile;
if (text === null || text === undefined) return send.call(this, body);
const type = this.responseType;
const response =
type === 'json'
? JSON.parse(text)
: type === 'arraybuffer'
? new TextEncoder().encode(text).buffer
: text;
const state = {
readyState: 4,
status: 200,
statusText: 'OK',
response,
responseText: typeof response === 'string' ? response : '',
getAllResponseHeaders: () => 'content-type: application/json\r\n',
getResponseHeader: (name) =>
name.toLowerCase() === 'content-type' ? 'application/json' : null,
};
for (const [name, value] of Object.entries(state))
Object.defineProperty(this, name, { configurable: true, value });
setTimeout(() => {
this.dispatchEvent(new Event('readystatechange'));
this.dispatchEvent(new ProgressEvent('load'));
this.dispatchEvent(new ProgressEvent('loadend'));
});
};
};
return `(${answer})(${JSON.stringify(files)});\n`;
}
+1 -10
View File
@@ -1,7 +1,5 @@
import { applicationHtmlPlugin } from './application-html.js';
import cesium from 'vite-plugin-cesium';
import { embedFramingPlugin } from './embed-framing.js';
import { panelBuildPlugin } from './panel.js';
/** Build browser assets with explicit inputs; never load environment or providers. */
export function createBrowserViteConfig({
@@ -14,13 +12,7 @@ export function createBrowserViteConfig({
command,
} = {}) {
return {
plugins: [
cesium(),
applicationHtmlPlugin(),
...plugins,
embedFramingPlugin(),
panelBuildPlugin(),
],
plugins: [cesium(), applicationHtmlPlugin(), ...plugins],
...(publicDir === undefined ? {} : { publicDir }),
// A production build must not clean the dependency cache a running dev
// server is still serving optimized module URLs from.
@@ -47,7 +39,6 @@ export function createBrowserViteConfig({
deny: ['.env', '.env.*', '*.{crt,pem}', '**/.git/**', '**/ENVIRONMENT'],
},
// These headers protect the document containing Provider Settings.
// Embed-mode documents are framable instead; see embed-framing.js.
headers: {
'X-Frame-Options': 'DENY',
'Content-Security-Policy': "frame-ancestors 'none'",
+12 -40
View File
@@ -1,42 +1,5 @@
# God's Eye View Current State
## God's Eye View in conversations — October 2, 2026
Tool answers that can be shown in God's Eye View include a view: camera, layers,
style, map, marks and an aircraft or satellite to follow, written in the
share-link format (`gods-eye-view/view`). `show_in_gods_eye_view` shows a view as live
God's Eye View inside clients that display MCP Apps, and as a link everywhere.
The panel runs the app's panel build (`npm run build:panel`, served at
`/panel/`) and loads it, with its data, through the MCP server, so it works
in Claude Desktop and in the Codex and ChatGPT desktop apps with a local
server and no HTTPS. `?embed=1` shows the app as the globe alone and takes
new views from the page that frames it. MCP leads with the tools that find
what to show. See [tools and the MCP server](TOOLS.md).
## Tools and local MCP server — October 1, 2026
`gods-eye-view/tools` defines queries that answer questions from the app's data,
and `gods-eye-view/tools/mcp` exposes a composed catalog over the Model Context
Protocol. `npm run mcp` serves Core's tools over stdio to a local MCP client,
reading from a running app's `/api` routes (default `http://localhost:4173`).
The development and preview servers also serve the tools over HTTP at `/mcp`,
accepting only local requests with loopback hosts and origins.
Queries cover earthquakes, active fires, recent launches, aircraft (in an
area, by identifier, tracks, and type and route lookups), ships (in an area,
by identifier, and tracks), satellites (next
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, 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
shared `area` argument (place name, bounding box, or point and radius) and cap
lists at 25 rows by default. Voice offers the same queries next to its app
actions: the session lists them, and the browser runs them through the same
catalog, loaded on first use. See [tools and the MCP server](TOOLS.md).
## Cyber HUD — September 23, 2026
Display > HUD > Layout includes Cyber, also available through the HUD voice
@@ -130,6 +93,15 @@ catalog construction owns each instance.
Wind animates one forecast without advancing forecast time. Separate Weather
observation layers provide radar and satellite history; none claims measured cloud volume.
Rain radar defaults to **US (NOAA)** MRMS; **Global (RainViewer)** selects a keyless
composite where national radars exist, with the past 2 hours at 10-minute steps
on the shared observed timeline, and the region choice persists in share links.
RainViewer uses pinned, validated 256 px Web Mercator tiles through zoom 7,
5-minute metadata caching and a server-wide 80-tile-request rolling-minute limit;
excess requests wait up to 20 seconds in a bounded FIFO queue;
gaps do not mean no rain. Globe hosts draw those tiles directly; on 3D Tiles,
the 6.2 km shell composes at most 24 tiles per full frame or detail window and
reprojects them to an equirectangular image.
Weather imagery drapes onto the globe basemap. On photorealistic 3D Tiles each
observed product is instead a raised, translucent shell: one rectangle over the
product bounds at a fixed height (global infrared 5.5 km, regional infrared
@@ -142,10 +114,10 @@ other map content draws over the shells, and shells are not pickable. A
map-source change tears down one renderer and restages the shown observation on
the other. A map without an imagery host pauses history while metadata refresh
continues, then resumes when a host returns.
Globe tiled products use 256 px tiles to maximum level 6. The tile proxy accepts
NOAA globe tiled products use 256 px tiles to maximum level 6. The tile proxy accepts
size=256 (default), 512 or 1024 and keys cached bytes by size, retaining the
24-hour immutable response and eight upstream slots. The image proxy serves every
product as one whole-extent PNG at its advertised bounds, up to 4096×2048 for
NOAA product as one whole-extent PNG at its advertised bounds, up to 4096×2048 for
radar, regional infrared and lightning and 2048×1024 for global infrared
(`size=W×H`, default the maximum), capped at 16 MiB and cached by product, time
and size. NOAA returned each of these sizes from a single request in a live
@@ -164,7 +136,7 @@ full-extent image covers it. A new frame swaps the full-extent image first and
keeps the previous detail image until the new one decodes, over at most one
newer frame; a moved window hides until its image arrives. Global
infrared contrast depends on the requested extent, so it keeps one image. The
image proxy accepts `bbox=west,south,east,north` for every product: inside the
image proxy accepts `bbox=west,south,east,north` for every NOAA product: inside the
product bounds, 2:1 within 1 %, rounded to 0.25°, up to 4096×2048 (the
default), cached by product, time, size and bbox. Diagnostics report
`shell.detail` (`bbox`, `size`, `ready`, `enabled`).
+4 -3
View File
@@ -81,9 +81,10 @@ Status: Open (owner-accepted 2026-07-08, documented)
### Weather layers: coverage and meaning
Status: Open (source limits, by design)
- **Rain radar covers the contiguous United States only.** It shows MRMS
reflectivity in dBZ, not rainfall rate or a forecast; a gap in coverage does not
mean no precipitation.
- **Rain radar:** the US option is MRMS for the contiguous United States; the
Global option is RainViewer’s composite where national radars exist, past
2 hours only. Both show reflectivity in dBZ, not rainfall rate or a forecast;
gaps are not “no rain”.
- **Lightning density is a ground-network grid, not GLM flashes.** It is NOAA's
15-minute density product derived from Vaisala NLDN/GLD360 on an approximately
8 km grid, for the Americas and Pacific only (110°E across the dateline to 0°,
-71
View File
@@ -1,71 +0,0 @@
# MCP setup
Use God's Eye View from Claude and Codex: tools, links, and the live globe
inside the conversation. Agents: follow these steps in order and check each
one before going on.
## 1. Run the app
```sh
npm install
npm run build:panel # the in-conversation globe; rerun after app changes
npm run dev # http://localhost:5173, keep it running
```
The MCP server reads from this app.
## 2. Add the server to a client
The server is `server/mcp/stdio.js` in this checkout. Use absolute paths
(clients do not load your shell profile), with each argument as its own entry.
| Client | How |
| --- | --- |
| Claude Code | `claude mcp add gods-eye-view -- node /abs/path/gods-eye-view/server/mcp/stdio.js --api-base http://localhost:5173` |
| Codex CLI | `codex mcp add gods-eye-view -- node /abs/path/gods-eye-view/server/mcp/stdio.js --api-base http://localhost:5173` |
| Codex and ChatGPT desktop | Same `~/.codex/config.toml` as the CLI (block below), or Settings → MCP servers → Add server (STDIO). |
| Claude Desktop | Settings → Developer → Edit Config, then add the JSON below. |
```toml
[mcp_servers.gods-eye-view]
command = "node"
args = ["/abs/path/gods-eye-view/server/mcp/stdio.js", "--api-base", "http://localhost:5173"]
```
```json
{
"mcpServers": {
"gods-eye-view": {
"command": "node",
"args": ["/abs/path/gods-eye-view/server/mcp/stdio.js", "--api-base", "http://localhost:5173"]
}
}
}
```
**Windows with the checkout in WSL:** use `"command": "wsl.exe"` and put
`"-e"` and the absolute Linux path to `node` first in `args`, for example
`["-e", "/home/you/.local/share/mise/installs/node/24/bin/node", "/home/you/gods-eye-view/server/mcp/stdio.js", "--api-base", "http://localhost:5173"]`.
## 3. Restart and try it
Fully quit the client (on Windows, from the tray icon) and reopen it, then
start a new chat; clients keep the server they started with. Ask: *"Show San
Diego in God's Eye View with military flights on."*
- Claude Desktop (a chat, not the Code tab), Codex and ChatGPT desktop show
the live globe; the first load takes 10–20 seconds.
- Claude Code and the Codex CLI answer with data and a link.
## Troubleshooting
- **The server does not appear:** run the command from step 2 in a terminal;
it should print `God's Eye View MCP server reading http://localhost:5173`
and wait. A path or `args` mistake fails here.
- **The panel says it could not load:** `npm run build:panel` was not run, or
the dev server is down.
- **Logs:** the server writes each call, and why one failed, to stderr. Claude
Desktop keeps it in its logs folder as `mcp-server-gods-eye-view.log`; in
Codex, right-click the panel → DevTools for the panel's console.
See [tools and the MCP server](TOOLS.md) for what the tools do.
-291
View File
@@ -1,291 +0,0 @@
# Tools and the MCP server
Tools answer questions from God's Eye View data for language-model clients.
They are defined once and exposed through adapters: the Model Context Protocol
(MCP) and function calling, which voice uses.
To use them from Claude or Codex, see [MCP setup](MCP_SETUP.md).
## Layers
| Owner | Responsibility |
| -------------------- | ------------------------------------------------------------------------------------------------- |
| `src/tools/` | Tool definitions, catalog composition, argument validation, shared `area` and result helpers |
| `src/tools/queries/` | Queries, one file per domain, reading only portable source contracts |
| `src/tools/mcp/` | MCP protocol (JSON-RPC) and a stateless HTTP transport; knows the catalog interface, not queries |
| `src/tools/functions.js` | Function-calling adapter: tool records and results for function-calling clients |
| `src/tools/services.js` | The default services: the layers' source factories and request services, given a resolving fetch |
| `server/mcp/` | Node composition: points the sources at a running app's `/api` routes; serves stdio and `/mcp` |
| `server/standalone/voiceTools.js`, `src/standalone/toolCatalog.js` | Standalone voice composition: the session's tool list and the browser catalog |
Dependencies point downward only. `gods-eye-view/tools` and
`gods-eye-view/tools/mcp` are portable exports: they reach no application,
rendering, Node, Cesium or browser-global code, which
`npm run check:boundaries` enforces. In the application, only voice reaches
them: `withToolCatalog` in `src/voice/gevRealtime.js` imports the
function-calling adapter, and the standalone entry supplies the catalog.
## Definitions and composition
`defineTool({ name, kind, title, description, inputSchema, requires, run })`
validates and freezes a tool. `kind` is `query` (answers from data, read-only)
or `action`. `inputSchema` uses a JSON Schema subset that `src/tools/schema.js`
checks completely; unsupported keywords are rejected at definition time.
`run(args, { services, signal })` resolves to `{ summary, data }`: one sentence
for people and a structured object for programs. A tool may also return `images`,
each `{ mimeType, data }` with base64 data; the MCP adapter sends them as image
content. MCP results carry the summary and the data as JSON text, plus the data
as `structuredContent`, for clients that read only one of them.
`composeCatalog({ tools, services, replace, interceptors })` builds a catalog:
- **Tools**: an application adds its own tools to `coreTools`. Reusing a name
fails unless the name is listed in `replace`.
- **Services**: each tool names the services it reads in `requires`. Tools
whose services are not supplied are left out.
- **Interceptors**: `(call, next) => next(call)` functions wrap every call,
outermost first. They can observe, reject or change a call.
- **Composite tools**: `run` receives `tools`, with `has(name)` and
`call(name, args)`, to call other tools through the same catalog, so
replaced tools and interceptors apply. Interceptors see such calls with
`parent`, the calling tool's name.
### Surfaces
`src/tools/surfaces.js` lists which tools MCP and voice offer. A tool is on
both unless `TOOL_SURFACES` turns it off; edit an entry to turn a tool on or
off for one surface. MCP leaves out place search, routing, plain
weather and wind, the regional brief, the HUD caption, radio, bike share and
transit, which assistants already cover or which add little without the
globe. `catalogForSurface(catalog, surface, overrides)` is the
view a surface exposes: it lists and calls only the tools it offers, while
composite tools still reach the whole catalog. `toolsForSurface` gives the
same selection as a list of definitions, such as for the voice session's tool
list. Voice leaves out tools that answer with images, link to the app, or
repeat what its app actions answer.
Expected failures throw `ToolError` with one of `invalid_arguments`,
`unavailable`, `unsupported`, `malformed` or `retry_later`. Other errors are
reported to clients without details.
## Services
`gods-eye-view/tools/services` builds the default set with
`createToolServices({ fetchImpl, appUrl })`. Services are the portable source factories the layers already use, such as
`createUsgsEarthquakeSource`, `createFirmsSource` and `createLaunchSource`,
plus a `places` service with `resolve(name, { signal })`. Sources request
relative `/api/...` paths through an injected `fetchImpl`, so the same tool
code runs wherever an application routes those paths.
The `weather`, `regional`, `terrain`, `summary` and `features` services are the
application request services from `gods-eye-view/application/requests`.
`situation_brief` and `military_awareness` run each section whose services are supplied and mark the
others unavailable. `app` is `{ baseUrl }`, the address links open. The `bikeshare` service is `{ systems, getStations }`: the system registry and
the GBFS source. `createGeocodePlaceService` resolves place names through `/api/geocode`;
`createPlaceSearchService` searches `/api/google/*` and reports when no search key
is configured; `createRouteService` plans routes through `/api/route`.
## Views
`gods-eye-view/view` (`src/view/index.js`) describes what the app shows,
independent of how it is shown: a camera (lat, lon, altitude, heading,
pitch), data layers, visual style, map imagery, and an aircraft, military
aircraft or satellite to follow. `createView` builds and bounds one,
`viewToParams` and `viewFromParams` write and read it in the share-link
format the app restores, and `viewUrl` gives the address that opens it. The
style names are the ones share links use. Ships cannot be followed from a
link yet. An aircraft's `follow` may ask for its cockpit view
(`cockpit: true`); links cannot carry that and open the app following it.
A view can also carry `annotations`, the marks the app's `annotate_map`
action draws (pins, highlights, areas, arrows, routes and labels at a place
name or coordinates). Links carry them in the `an` parameter, and the app
draws them once the link has been restored.
Tools take a view as `VIEW_ARGUMENTS`: an `area` to frame from above, or a
`camera`, plus `layers`, `style`, `map`, `follow` and `annotations`. A
tilted camera over an area looks at its center from behind; camera fields
given with an area override its framing. `resolveViewArguments` turns them
into a view.
### The God's Eye View panel
`show_in_gods_eye_view` names an MCP Apps view (`io.modelcontextprotocol/ui`):
`_meta.ui.resourceUri` is `ui://gods-eye-view/globe`, a `text/html;profile=mcp-app`
resource from `createGlobePanelResource({ runtime })` in
`src/tools/globePanel.js`, with the panel's script from
`src/app/globePanelRuntime.js`. Clients that display apps render it inside
the conversation. The panel completes the MCP Apps handshake
(`ui/initialize`, `ui/notifications/initialized`,
`ui/notifications/size-changed`), and for each
`ui/notifications/tool-result` carrying a view it loads the app in inline
embed mode the first time and posts later views to that same app, so the
globe changes without reloading. Its "Open in God's Eye View" button asks
the host to open the link (`ui/open-link`).
Hosts serve panels from their own sites and may refuse other addresses;
Codex, for one, refuses any address on the user's machine. So the panel
never requests the app's server itself. It loads everything from the app's
own paths through `panel_request`, a tool only the panel may call
(`_meta.ui.visibility: ["app"]`): the MCP server requests the path from the
app's server and returns the response, compressed and in parts when large.
The same path works in every host. Only
map imagery, tiles and fonts load directly, from the providers the
resource's `csp` lists. `panel_request` refuses `/api/setup`, which writes
provider keys.
The panel loads the app's panel build, which `npm run build:panel` writes to
`dist/panel` and the servers serve at `/panel/`: one app script, one
stylesheet, and Cesium's script, which carries its workers and starts them
from memory (`CESIUM_WORKERS`). Files those workers load themselves are
embedded in a prelude the panel runs ahead of them, since a worker's
requests reach the panel page's own site. Rebuild it after changing
the app.
Hosts differ in ways the panel works around, all inside the panel only:
images and stylesheet files arrive as `data:` URLs, since some hosts refuse
`blob:` images; code that needs an https address for the app gets
`GEV_APP_BASE_URL`, since some hosts serve the page from their own scheme;
the globe keeps drawing from a timer when the host reports the panel hidden
and stops animation frames; and 2D canvases are kept in memory
(`willReadFrequently`), since a host that treats the panel as off screen may
drop their GPU contents and show the overlays as black over the globe.
Tools declare a UI resource with `defineTool({ ui: { resourceUri } })`, and
an app-only tool with `ui: { visibility: ['app'] }`;
`createMcpServer({ resources })` serves `resources/list` and
`resources/read`.
### Embed mode
`?embed=1` shows only the globe: clean view, with the HUD, panels, welcome
and setup prompts hidden; provider attribution stays. A page that frames it
changes the view by posting `{ type: 'gev:view', id, view }` to the frame.
The app applies it through its own actions (style, map, exactly the view's
layers, annotations, then the followed entity, retried until its layer has
it, or the camera when nothing is followed or the entity is not there yet,
since a camera flight would end the follow; then cockpit view when asked) and
answers `{ type: 'gev:view-applied', id, ok, steps }`. It
posts `{ type: 'gev:ready' }` once it can take views, and only its parent
page can send them. See `src/app/embed.js`.
The development and preview servers let other pages frame embed-mode
documents only; every other document keeps `X-Frame-Options: DENY` and
`frame-ancestors 'none'`. `GEV_EMBED_FRAME_ANCESTORS` restricts which pages
may frame them (any by default).
Answers that have something to show include `data.view`: the view that
shows them, with the matching layers on, an area framed from above, and a
single aircraft or satellite followed, plus `url` to open it (null when the
app's address is not configured). `suggestView` in `src/tools/views.js`
builds one.
## The `area` argument
Location-scoped tools take `area` as exactly one of a `place` name, a `bbox`
(`[west, south, east, north]`, crossing the antimeridian when west exceeds
east), or `lat`, `lon` and `radius_km`. Lists default to 25 rows, at most 200,
and report `total`, `returned` and `truncated`.
## MCP
`createMcpServer({ catalog, name, version, instructions, descriptions, decorate })`
implements `initialize`, `ping`, `tools/list` and `tools/call` for protocol
revisions 2025-11-25, 2025-06-18 and 2025-03-26. `descriptions` overrides a
tool's title or description for this surface; `decorate(definition, tool)`
merges extra fields into each listed definition. `createMcpHttpHandler(server)`
returns a `Request`-to-`Response` handler for stateless Streamable HTTP: one
JSON-RPC message per POST, answered with JSON. The host owns routing and any
access control in front of it.
## Voice
Voice offers the catalog's queries next to its app actions.
`toFunctionTools(tools, { exclude })` turns tools into
`{ type: 'function', name, description, parameters }` records, and
`toFunctionOutput(name, result)` turns a result into
`{ ok, tool, summary, data }`, counting images in `images_omitted` instead of
sending them.
The voice session token endpoint takes its tool list as `realtime.tools`.
`realtimeSessionTools(additional)` appends function tools to the app actions,
skipping names an action already uses, so `next_satellite_pass` stays the
action. The standalone server supplies the core queries that
`src/tools/surfaces.js` offers on voice, which leaves out tools that answer
with images, link to the app, or repeat what voice's app actions answer
(aircraft, ships, earthquakes, fires, datacenters, dams and satellites
overhead, which `analyst_query` covers).
In the browser, `initGevVoiceCommands({ toolCatalog })` takes a function that
resolves a catalog. App action names go to the action runner; other names the
catalog has go to `catalog.call` with the call's abort signal. The standalone
entry composes the catalog with `createToolServices` over the page's fetch and
loads it the first time voice calls a query.
## Running locally
Start the app (`npm run dev` or `npm run preview`), then register the stdio
server with an MCP client, for example Claude Code:
```bash
claude mcp add gods-eye-view -- npm --prefix /path/to/gods-eye-view run --silent mcp
```
`npm run mcp -- --api-base http://localhost:4173` selects another server.
The development and preview servers also serve the same tools over HTTP at
`/mcp`, for clients that connect by URL:
```bash
claude mcp add --transport http gods-eye-view http://localhost:4173/mcp
```
The route accepts only requests from this machine that name a loopback host
and, when a browser sends an `Origin`, come from a loopback origin. This is
local transport safety, not authentication. The
local server makes no requests other than to the app's `/api` routes and the
public feeds the sources already use.
## Tools
| Tool | Reads | Returns |
| --------------------- | ------------- | ------------------------------------------------------------- |
| `get_earthquakes` | `earthquakes` | USGS M2.5+ events in the last 24 hours, strongest first |
| `get_active_fires` | `fires` | NASA FIRMS detections in an area, highest radiative power first |
| `get_recent_launches` | `launches` | Launch Library 2 launches in the last 30 days, newest first |
| `aircraft_in_area` | `aircraft` | Aircraft in an area, nearest first; `military: true` reads the `military` feed |
| `find_aircraft` | `aircraft` | Aircraft anywhere by callsign, ICAO address or registration |
| `get_aircraft_track` | `aircraft` | Recent positions of one aircraft, thinned to 200 points |
| `get_aircraft_info` | `aircraft` | Aircraft type and registration, and flight route, from adsbdb |
| `vessels_in_area` | `vessels` | Ships reported by AIS in an area, nearest first, optionally by type |
| `find_vessel` | `vessels` | Ships anywhere by MMSI, IMO number or name |
| `get_vessel_track` | `vessels` | Recent positions of one ship, thinned to 200 points |
| `next_satellite_pass` | `satellites` | Next pass over a place or point (default the ISS), with naked-eye visibility |
| `satellites_overhead` | `satellites` | Satellites in a CelesTrak group above a place or point now, highest first |
| `find_cctv_cameras` | `cctv` | Public cameras in an area, nearest first |
| `get_cctv_snapshot` | `cctv` | The current image from one camera, returned as image content |
| `find_alpr_cameras` | `alpr` | OpenStreetMap-mapped license plate readers in a US/Canadian area up to 3° |
| `find_radio_stations` | `radio` | Radio Browser stations by area and/or search terms, with stream URLs |
| `search_places` | `placeSearch` | Points of interest matching a query within an area (Google Places) |
| `places_nearby` | `placeSearch` | Notable places around a place or point (Google Places) |
| `plan_route` | `routing` | Walking, driving or cycling route over OpenStreetMap, with a simplified path |
| `get_bike_share` | `bikeshare` | Live GBFS stations in an area, with bikes and docks available |
| `get_transit_vehicles` | `transit` | Live GTFS-Realtime vehicle positions in an area, optionally one route |
| `get_traffic_flow` | `traffic` | TomTom flow in a city-sized area: speed vs free flow, congested and closed road |
| `get_weather` | `weather` | Current conditions at a place or point |
| `get_weather_map` | `weatherMaps` | The latest NOAA radar, satellite or lightning map image over an area |
| `get_wind` | `wind` | GFS or IFS model wind 10 m above ground at a location |
| `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 |
| `get_terrain_height` | `terrain` | Ground, geoid and ellipsoid heights at up to 20 points |
| `find_military_installations` | `installations` | OpenStreetMap military sites in an area of at most 10° per side |
| `get_map_features` | `features` | Administrative areas, named places or monuments at a location (needs Overpass) |
| `situation_brief` | `weather` | Weather, earthquakes, fires, aircraft, ships and cyclones for an area, by section |
| `military_awareness` | `military` | Military and other aircraft, ships and military installations within 250 km of a point, by section |
| `get_hud_caption` | `weather`, `summary` | The app's heads-up display caption for an area |
| `show_in_gods_eye_view` | `app` | A view in God's Eye View: the live panel in clients with MCP Apps, and a link everywhere; takes another answer's view or an area or camera, layers, style, map, marks and something to follow |
| `panel_request` | `app` | Panel only: loads a path from the app's server for the God's Eye View panel |
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "gods-eye-view",
"version": "0.2.0",
"version": "0.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gods-eye-view",
"version": "0.2.0",
"version": "0.1.1",
"license": "MIT",
"dependencies": {
"@jtarrio/signals": "^0.10.0",
+3 -9
View File
@@ -1,7 +1,7 @@
{
"name": "gods-eye-view",
"private": true,
"version": "0.2.0",
"version": "0.1.1",
"description": "A real-time intelligence console for planet Earth — photorealistic 3D globe, live aircraft/ships/satellites/earthquakes/CCTV, and hands-free voice control. Runs in a browser.",
"type": "module",
"license": "MIT",
@@ -41,14 +41,12 @@
"dev:secure": "./scripts/dev-secure.sh",
"opensky:import": "./scripts/opensky-import-client.sh",
"build": "vite build",
"build:panel": "node scripts/build-panel.mjs",
"preview": "vite preview",
"test": "node scripts/run-unit-tests.mjs",
"test:track": "node scripts/track-regression.mjs",
"qa:map-source-tray": "node scripts/qa-map-source-tray.mjs",
"check:boundaries": "node scripts/check-import-directions.mjs && node scripts/check-package-boundaries.mjs",
"qa:transit": "node scripts/qa-transit.mjs",
"mcp": "node server/mcp/stdio.js"
"qa:transit": "node scripts/qa-transit.mjs"
},
"dependencies": {
"@jtarrio/signals": "^0.10.0",
@@ -252,10 +250,6 @@
"./layers/transit/standalone": "./src/data/transit.js",
"./layers/transit/source": "./src/layers/transit/source.js",
"./sources/transit-service": "./src/sources/transitService.js",
"./sources/map-features": "./src/sources/overpassFeatures.js",
"./tools": "./src/tools/index.js",
"./tools/mcp": "./src/tools/mcp/index.js",
"./tools/services": "./src/tools/services.js",
"./view": "./src/view/index.js"
"./sources/map-features": "./src/sources/overpassFeatures.js"
}
}
+1 -2
View File
@@ -18,8 +18,7 @@ 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, including the `get_bhote_koshi_flood` tool in
`src/tools/queries/events.js`.
imports before building.
Sources:
-46
View File
@@ -1,46 +0,0 @@
#!/usr/bin/env node
/**
* Build the MCP Apps panel: the app under dist/panel, served at /panel/.
* See build/panel.js.
*/
import { readFile, rename, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { build } from 'vite';
import standaloneConfig from '../server/standalone/vite.config.js';
import {
PANEL_BASE,
PANEL_OUT_DIR,
PANEL_WORKER_FILES,
PANEL_WORKER_PRELUDE_PATH,
panelBuildConfig,
workerFilesPrelude,
} from '../build/panel.js';
const root = fileURLToPath(new URL('../', import.meta.url));
const outDir = join(root, PANEL_OUT_DIR);
const config = standaloneConfig({ command: 'build', mode: 'production' });
await build({
configFile: false,
root,
...panelBuildConfig(config),
});
// Cesium's plugin copies its files under the base path inside outDir.
const nested = join(outDir, PANEL_BASE, 'cesium');
await rm(join(outDir, 'cesium'), { recursive: true, force: true });
await rename(nested, join(outDir, 'cesium'));
await rm(join(outDir, PANEL_BASE.split('/')[1]), { recursive: true });
// Cesium's workers load these files themselves; embed them in a prelude the
// panel runs ahead of Cesium's workers script.
const files = {};
for (const name of PANEL_WORKER_FILES)
files[name] = await readFile(join(outDir, 'cesium', name), 'utf8');
await writeFile(
join(outDir, PANEL_WORKER_PRELUDE_PATH),
workerFilesPrelude(files),
);
console.log(`Panel build written to ${PANEL_OUT_DIR}`);
-4
View File
@@ -37,10 +37,6 @@ const portableExport = (key) =>
) ||
[
'./director',
'./tools',
'./tools/mcp',
'./tools/services',
'./view',
'./voice/action-schemas',
'./voice/session',
'./data/lifecycle',
+2 -28
View File
@@ -865,32 +865,6 @@
"scripts/qa-journey-recorder.mjs",
"scripts/qa-alpr-journey.mjs",
"scripts/qa-context-arrivals.mjs",
"src/tools/catalog.test.mjs",
"src/tools/area.test.mjs",
"src/tools/queries/queries.test.mjs",
"src/tools/mcp/mcp.test.mjs",
"src/tools/localServer.test.mjs",
"src/tools/queries/aviation.test.mjs",
"src/tools/queries/space.test.mjs",
"src/tools/queries/media.test.mjs",
"src/tools/queries/places.test.mjs",
"src/layers/transit/source.test.mjs",
"src/tools/queries/mobility.test.mjs",
"src/tools/queries/environment.test.mjs",
"src/tools/queries/maritime.test.mjs",
"src/tools/queries/share.test.mjs",
"src/tools/localRoute.test.mjs",
"src/tools/queries/atmosphere.test.mjs",
"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/events.test.mjs",
"src/tools/functions.test.mjs",
"src/tools/surfaces.test.mjs",
"src/tools/places.test.mjs",
"src/view/index.test.mjs",
"src/tools/views.test.mjs",
"src/app/embed.test.mjs",
"src/tools/globePanel.test.mjs"
"src/data/weatherRainviewer.test.mjs",
"src/layers/weather/mercatorMosaic.test.mjs"
]
+6 -163
View File
@@ -10,8 +10,7 @@
"src/data/localGeojsonCore.js",
"src/data/localGeojsonLod.js",
"src/data/inputOwnership.js",
"src/data/infrastructureOverlayEntry.js",
"src/sources/infrastructureData.js"
"src/data/infrastructureOverlayEntry.js"
],
"external": ["cesium"]
},
@@ -28,12 +27,7 @@
"vite-build": {
"runtime": "node",
"exports": ["./build/vite", "./build/html"],
"modules": [
"build/vite.js",
"build/application-html.js",
"build/embed-framing.js",
"build/panel.js"
],
"modules": ["build/vite.js", "build/application-html.js"],
"external": ["vite-plugin-cesium"]
},
"live-providers": {
@@ -519,7 +513,6 @@
"src/data/detectionPolicy.js",
"src/data/installationFeedback.js",
"src/data/layerState.js",
"src/data/layerStateCoordinator.js",
"src/data/layerStateTokenReservations.json",
"src/data/militaryAwarenessEngine.js",
"src/data/scenePick.js",
@@ -841,7 +834,6 @@
"src/data/contactMatch.js",
"src/data/iconOrientation.js",
"src/data/layerState.js",
"src/data/layerStateCoordinator.js",
"src/data/layerStateTokenReservations.json",
"src/data/modelVisualAnchor.js",
"src/data/motionModel.js",
@@ -930,7 +922,6 @@
"src/data/issPass.js",
"src/data/satelliteClass.js",
"src/data/satellitePass.js",
"src/sources/tle.js",
"src/layers/satellites/catalog.js",
"src/layers/satellites/controls.js",
"src/layers/satellites/index.js",
@@ -1088,7 +1079,6 @@
"src/layers/bikeshare/ingestion.js",
"src/layers/bikeshare/lifecycle.js",
"src/layers/bikeshare/model.js",
"src/sources/gbfsStations.js",
"src/layers/bikeshare/policy.js",
"src/layers/bikeshare/queries.js",
"src/layers/bikeshare/registry.js",
@@ -1443,7 +1433,6 @@
"src/data/issPass.js",
"src/data/labelArbiter.js",
"src/data/layerState.js",
"src/data/layerStateCoordinator.js",
"src/data/layerStateTokenReservations.json",
"src/data/lifecycle.js",
"src/data/localGeojson.js",
@@ -1536,7 +1525,6 @@
"src/layers/bikeshare/ingestion.js",
"src/layers/bikeshare/lifecycle.js",
"src/layers/bikeshare/model.js",
"src/sources/gbfsStations.js",
"src/layers/bikeshare/policy.js",
"src/layers/bikeshare/queries.js",
"src/layers/bikeshare/registry.js",
@@ -1671,7 +1659,6 @@
"src/layers/satellites/labels.js",
"src/layers/satellites/lifecycle.js",
"src/layers/satellites/orbits.js",
"src/sources/tle.js",
"src/layers/satellites/records.js",
"src/layers/satellites/policy.js",
"src/layers/satellites/rendering.js",
@@ -1966,6 +1953,7 @@
"src/layers/weather/shellRendering.js",
"src/layers/weather/infraredAlpha.js",
"src/layers/weather/infraredImage.js",
"src/layers/weather/mercatorMosaic.js",
"src/layers/weather/source.js",
"src/director/clock.js",
"src/director/timeline.js",
@@ -2040,12 +2028,7 @@
"src/sources/requestDeadline.js",
"src/data/local_data/osm_military_names/names.json",
"src/layers/installations/namedMarkers.js",
"src/data/infrastructureOverlayEntry.js",
"src/sources/infrastructureData.js",
"src/tools/functions.js",
"src/voice/actionSchemas.js",
"src/view/index.js",
"src/app/embed.js"
"src/data/infrastructureOverlayEntry.js"
],
"external": [
"hls.js",
@@ -2207,7 +2190,6 @@
"src/layers/bikeshare/ingestion.js",
"src/layers/bikeshare/lifecycle.js",
"src/layers/bikeshare/model.js",
"src/sources/gbfsStations.js",
"src/layers/bikeshare/policy.js",
"src/layers/bikeshare/queries.js",
"src/layers/bikeshare/registry.js",
@@ -2342,7 +2324,6 @@
"src/layers/satellites/labels.js",
"src/layers/satellites/lifecycle.js",
"src/layers/satellites/orbits.js",
"src/sources/tle.js",
"src/layers/satellites/records.js",
"src/layers/satellites/policy.js",
"src/layers/satellites/rendering.js",
@@ -2485,6 +2466,7 @@
"src/layers/weather/shellRendering.js",
"src/layers/weather/infraredAlpha.js",
"src/layers/weather/infraredImage.js",
"src/layers/weather/mercatorMosaic.js",
"src/layers/weather/source.js",
"src/layers/weather/imageryOrder.js",
"src/layers/weather/imageryHost.js",
@@ -2531,8 +2513,7 @@
"src/sources/requestDeadline.js",
"src/data/local_data/osm_military_names/names.json",
"src/layers/installations/namedMarkers.js",
"src/data/infrastructureOverlayEntry.js",
"src/sources/infrastructureData.js"
"src/data/infrastructureOverlayEntry.js"
],
"external": [
"hls.js",
@@ -2626,7 +2607,6 @@
"modules": [
"src/data/installationFeedback.js",
"src/data/layerState.js",
"src/data/layerStateCoordinator.js",
"src/data/layerStateTokenReservations.json",
"src/loadingFeedback.js",
"src/navigationPolicy.js",
@@ -3094,142 +3074,5 @@
"src/layers/perimeters/source.js"
],
"external": []
},
"tools": {
"exports": ["./tools"],
"modules": [
"src/tools/index.js",
"src/tools/catalog.js",
"src/tools/schema.js",
"src/tools/area.js",
"src/tools/results.js",
"src/tools/places.js",
"src/tools/queries/hazards.js",
"src/tools/queries/space.js",
"src/tools/queries/aviation.js",
"src/tools/queries/media.js",
"src/tools/queries/places.js",
"src/tools/queries/mobility.js",
"src/tools/queries/environment.js",
"src/tools/queries/brief.js",
"src/tools/queries/maritime.js",
"src/tools/queries/share.js",
"src/data/layerState.js",
"src/data/layerStateTokenReservations.json",
"src/sources/gbfsStations.js",
"src/sources/tle.js",
"src/data/satellitePass.js",
"src/tools/queries/atmosphere.js",
"src/layers/wind/inspection.js",
"src/layers/wind/model.js",
"src/layers/wind/fields.js",
"src/tools/queries/imagery.js",
"src/tools/queries/cables.js",
"src/tools/queries/surveillance.js",
"src/tools/queries/infrastructure.js",
"src/tools/queries/events.js",
"src/data/bhoteKoshiFloodPath.js",
"src/tools/functions.js",
"src/tools/surfaces.js",
"src/layers/transit/policy.js",
"src/data/transitFeeds.js",
"src/layers/transit/movement.js",
"src/data/contactPlayback.js",
"src/data/layerSnapshot.js",
"src/data/feedState.js",
"src/loadingFeedback.js",
"src/data/installationFeedback.js",
"src/view/index.js",
"src/tools/views.js",
"src/tools/globePanel.js",
"src/tools/queries/panelRequest.js"
],
"external": ["satellite.js"]
},
"tools-mcp": {
"exports": ["./tools/mcp"],
"modules": [
"src/tools/mcp/index.js",
"src/tools/mcp/protocol.js",
"src/tools/mcp/http.js",
"src/tools/catalog.js",
"src/tools/schema.js"
],
"external": []
},
"tools-services": {
"exports": ["./tools/services"],
"modules": [
"src/tools/services.js",
"src/layers/bikeshare/registry.js",
"src/layers/bikeshare/source.js",
"src/layers/cctv/source.js",
"src/layers/cctv/sourcePolicy.js",
"src/layers/cyclones/source.js",
"src/sources/httpBody.js",
"src/layers/earthquakes/source.js",
"src/layers/earthquakes/records.js",
"src/layers/firms/source.js",
"src/layers/installations/source.js",
"src/sources/requestDeadline.js",
"src/data/militaryNames.js",
"src/sources/militaryTileGeometry.js",
"src/data/bundledJson.js",
"src/data/retryableLoad.js",
"src/data/local_data/osm_military_names/names.json",
"src/sources/openFreeMap.js",
"src/sources/vectorTiles.js",
"src/sources/phaseTiming.js",
"src/data/tomtomTiles.js",
"src/sources/capability.js",
"src/data/militaryInstallationData.js",
"src/layers/launches/source.js",
"src/layers/perimeters/source.js",
"src/layers/radio/source.js",
"src/layers/radio/policy.js",
"src/layers/satellites/source.js",
"src/layers/traffic/source.js",
"src/layers/traffic/roadModes.js",
"src/data/flowMatch.js",
"src/layers/traffic/flowSource.js",
"src/layers/traffic/flowDecode.js",
"src/sources/overpassRoads.js",
"src/layers/transit/source.js",
"src/sources/transitHistory.js",
"src/services/requests.js",
"src/sources/overpassFeatures.js",
"src/sources/featureSource.js",
"src/sources/overpassFeaturesRecords.js",
"src/sources/featureGeometry.js",
"src/sources/live/standalone.js",
"src/sources/live/contract.js",
"src/sources/live/aircraft.js",
"src/sources/live/vessels.js",
"src/tools/places.js",
"src/tools/catalog.js",
"src/tools/schema.js",
"src/layers/weather/source.js",
"src/layers/wind/source.js",
"src/sources/windBody.js",
"src/layers/recentImagery/catalog.js",
"src/layers/recentImagery/model.js",
"src/layers/submarineCables/bundledSource.js",
"src/layers/alpr/source.js",
"src/layers/alpr/policy.js",
"src/layers/alpr/records.js",
"src/layers/alpr/tileRecords.js",
"src/sources/infrastructureData.js",
"src/sources/eventPacks.js"
],
"external": ["@mapbox/vector-tile", "pbf"]
},
"view": {
"exports": ["./view"],
"modules": [
"src/view/index.js",
"src/data/layerState.js",
"src/data/layerStateTokenReservations.json"
],
"external": []
}
}
-129
View File
@@ -1,129 +0,0 @@
/**
* Serves the local MCP server at /mcp on the development and preview servers.
*
* This is local transport safety, not authentication: requests must come from
* this machine, name a loopback host (blocking DNS rebinding) and, when a
* browser sends an Origin, come from a loopback origin.
*/
import { createMcpHttpHandler } from '../../src/tools/mcp/index.js';
import { createLocalMcpServer } from './server.js';
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
const LOOPBACK_ADDRESSES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
const MAX_BODY_BYTES = 1024 * 1024;
/** Whether a request may reach the local MCP server. */
export function isLocalMcpRequest({ remoteAddress, host, origin }) {
if (!LOOPBACK_ADDRESSES.has(remoteAddress)) return false;
let hostUrl;
try {
hostUrl = new URL(`http://${host}`);
} catch {
return false;
}
if (!LOOPBACK_HOSTS.has(hostUrl.hostname)) return false;
if (origin == null) return true;
try {
const originUrl = new URL(origin);
return (
['http:', 'https:'].includes(originUrl.protocol) &&
LOOPBACK_HOSTS.has(originUrl.hostname)
);
} catch {
return false;
}
}
/** Vite plugin that mounts the local MCP server at /mcp. */
export function localMcpPlugin({ createServer = createLocalMcpServer } = {}) {
const handlers = new Map();
const handlerFor = (apiBase) => {
if (!handlers.has(apiBase))
handlers.set(apiBase, createMcpHttpHandler(createServer({ apiBase })));
return handlers.get(apiBase);
};
const install = (server) => {
server.middlewares.use('/mcp', async (req, res) => {
const host = req.headers.host || '';
if (
!isLocalMcpRequest({
remoteAddress: req.socket?.remoteAddress,
host,
origin: req.headers.origin,
})
) {
res.writeHead(403, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
});
res.end(
JSON.stringify({
error: 'The MCP server only accepts local requests',
}),
);
return;
}
// A client that disconnects before its answer cancels the tool call.
const disconnect = new AbortController();
const onClose = () => {
if (!res.writableFinished) disconnect.abort();
};
res.on('close', onClose);
try {
const body = req.method === 'POST' ? await readBody(req) : undefined;
const request = new Request(`http://${host}/mcp`, {
method: req.method,
headers: Object.entries(req.headers).flatMap(([name, value]) =>
value === undefined ? [] : [[name, String(value)]],
),
body,
signal: disconnect.signal,
});
const response = await handlerFor(`http://${host}`)(request);
res.writeHead(response.status, Object.fromEntries(response.headers));
res.end(Buffer.from(await response.arrayBuffer()));
} catch (error) {
if (disconnect.signal.aborted) return;
if (res.headersSent) return res.destroy();
res.writeHead(error?.status === 413 ? 413 : 400, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
});
res.end(
JSON.stringify({
error: error?.status === 413 ? 'Request too large' : 'Bad request',
}),
);
} finally {
res.off('close', onClose);
}
});
};
return {
name: 'local-mcp',
configureServer: install,
configurePreviewServer: install,
};
}
function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
let size = 0;
let tooLarge = false;
req.on('data', (chunk) => {
size += chunk.length;
// Keep draining without storing, so the 413 response can still be sent.
if (tooLarge || size <= MAX_BODY_BYTES) {
if (!tooLarge) chunks.push(chunk);
return;
}
tooLarge = true;
chunks.length = 0;
reject(Object.assign(new Error('Request too large'), { status: 413 }));
});
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject);
});
}
-44
View File
@@ -1,44 +0,0 @@
/** The local MCP server: Core's tools over services backed by a running app. */
import { readFileSync } from 'node:fs';
import {
composeCatalog,
coreTools,
catalogForSurface,
} from '../../src/tools/index.js';
import { panelRuntime } from '../../src/app/globePanelRuntime.js';
import { createGlobePanelResource } from '../../src/tools/globePanel.js';
import { createMcpServer } from '../../src/tools/mcp/index.js';
import { DEFAULT_API_BASE, createLocalToolServices } from './services.js';
const INSTRUCTIONS =
"Tools answer questions from God's Eye View's live public data. Location " +
'tools take an area: a place name, a bbox, or lat/lon with radius_km. ' +
'Results are capped; check truncated and total before concluding there is nothing more. ' +
"Answers that can be shown in God's Eye View include data.view; to show one, call " +
'show_in_gods_eye_view with that view, adding layers, style, a camera or marks as ' +
"needed. It shows live God's Eye View where the client displays apps and " +
'returns a link everywhere.';
/** Construct the local MCP server for Core's tools. */
export function createLocalMcpServer({
apiBase = DEFAULT_API_BASE,
fetchImpl,
} = {}) {
const { version } = JSON.parse(
readFileSync(new URL('../../package.json', import.meta.url), 'utf8'),
);
return createMcpServer({
catalog: catalogForSurface(
composeCatalog({
tools: coreTools,
services: createLocalToolServices({ apiBase, fetchImpl }),
}),
'mcp',
),
name: 'gods-eye-view',
version,
instructions: INSTRUCTIONS,
resources: [createGlobePanelResource({ runtime: panelRuntime })],
});
}
-63
View File
@@ -1,63 +0,0 @@
/**
* Local composition of tool services: Core's default services, pointed at a
* running God's Eye View server's `/api` routes.
*/
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { createToolServices } from '../../src/tools/services.js';
// Bundled data some sources read by module-relative URL.
const BUNDLED_DATA = new URL('../../src/data/local_data/', import.meta.url);
export const DEFAULT_API_BASE = 'http://localhost:4173';
/**
* Resolve the sources' relative `/api/...` requests against `apiBase`, and
* serve `file:` URLs inside the bundled data directory from disk, as a
* browser would load those module-relative assets.
*/
export function createApiFetch({
apiBase = DEFAULT_API_BASE,
fetchImpl = (...args) => globalThis.fetch(...args),
} = {}) {
const base = new URL(apiBase);
if (!['http:', 'https:'].includes(base.protocol))
throw new TypeError(`apiBase must be an http(s) URL: ${apiBase}`);
return (input, init) => {
const url = String(input instanceof Request ? input.url : input);
if (url.startsWith('file:')) return readBundled(url);
return fetchImpl(
typeof input === 'string' && input.startsWith('/')
? new URL(input, base)
: input,
init,
);
};
}
async function readBundled(url) {
const href = new URL(url).href;
if (!href.startsWith(BUNDLED_DATA.href) || href.includes('/../'))
return new Response(null, { status: 404 });
try {
const body = await readFile(fileURLToPath(href));
return new Response(body, {
headers: {
'Content-Type': href.endsWith('.json')
? 'application/json'
: 'application/octet-stream',
},
});
} catch {
return new Response(null, { status: 404 });
}
}
/** Construct every service Core's tools read, backed by the local server. */
export function createLocalToolServices(options = {}) {
return createToolServices({
fetchImpl: createApiFetch(options),
appUrl: options.apiBase ?? DEFAULT_API_BASE,
});
}
-90
View File
@@ -1,90 +0,0 @@
/**
* Local MCP server over stdio for agents such as Claude Code and Codex.
*
* npm run mcp -- [--api-base http://localhost:4173]
*
* Reads newline-delimited JSON-RPC from stdin and writes responses to stdout.
* Tools read data from a running God's Eye View server (`npm run dev` or
* `npm run preview`) at the API base. Diagnostics go to stderr.
*/
import { createInterface } from 'node:readline';
import { fileURLToPath } from 'node:url';
import { parseErrorResponse } from '../../src/tools/mcp/index.js';
import { createLocalMcpServer } from './server.js';
import { DEFAULT_API_BASE } from './services.js';
/**
* A one-line description of a request for the diagnostic log: its method,
* and the tool or resource it names. Arguments and data are never logged.
*/
export function describeRequest(message) {
const method = typeof message?.method === 'string' ? message.method : null;
if (!method) return null;
const subject =
method === 'tools/call'
? message.params?.name
: method === 'resources/read'
? message.params?.uri
: null;
return typeof subject === 'string' ? `${method} ${subject}` : method;
}
/** Serve newline-delimited JSON-RPC until `input` ends. */
export async function serveStdio(server, { input, output, log = () => {} }) {
const lines = createInterface({ input, crlfDelay: Infinity });
const pending = new Set();
for await (const line of lines) {
if (!line.trim()) continue;
const work = respond(server, line, log).then((response) => {
if (response) output.write(`${JSON.stringify(response)}\n`);
});
pending.add(work);
work.finally(() => pending.delete(work));
}
await Promise.all(pending);
}
async function respond(server, line, log) {
let message;
try {
message = JSON.parse(line);
} catch {
return parseErrorResponse();
}
const described = describeRequest(message);
if (described) log(described);
const response = await server.handle(message);
// A failed tool call answers the client, not the log; say why here too.
if (response?.result?.isError)
log(` ${described} failed: ${response.result.content?.[0]?.text}`);
return response;
}
/** Read `--api-base <url>` from command-line arguments. */
export function parseArgs(argv) {
const options = {};
for (let index = 0; index < argv.length; index += 1) {
if (argv[index] === '--api-base' && argv[index + 1])
options.apiBase = argv[(index += 1)];
else throw new Error(`Unknown argument: ${argv[index]}`);
}
return options;
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
try {
const options = parseArgs(process.argv.slice(2));
console.error(
`God's Eye View MCP server reading ${options.apiBase || DEFAULT_API_BASE}`,
);
await serveStdio(createLocalMcpServer(options), {
input: process.stdin,
output: process.stdout,
log: (line) => console.error(`<- ${line}`),
});
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}
+9 -33
View File
@@ -25,36 +25,6 @@ let _openskyCacheMeta = null;
let _openskyCacheSourceEpochMs = null;
/** TTL for the OpenSky response cache (ms). */
const OPENSKY_CACHE_MS = 9000;
/** Per-attempt limit for the global snapshot; it normally arrives in ~1.5 s. */
const OPENSKY_ATTEMPT_TIMEOUT_MS = 10_000;
const OPENSKY_STATES_URL =
'https://opensky-network.org/api/states/all?extended=1';
/**
* Fetch and read the global snapshot, giving each attempt
* OPENSKY_ATTEMPT_TIMEOUT_MS and retrying once when an attempt times out or
* the connection fails. A second failure throws, which the proxy answers
* from its stale cache or the regional fallback.
* @param {Record<string, string>} headers Request headers.
* @returns {Promise<{upstream: Response, body: string}>}
*/
async function fetchOpenSkyStates(headers) {
for (let attempt = 1; ; attempt++) {
try {
const upstream = await fetch(OPENSKY_STATES_URL, {
headers,
signal: AbortSignal.timeout(OPENSKY_ATTEMPT_TIMEOUT_MS),
});
return { upstream, body: await upstream.text() };
} catch (error) {
if (attempt >= 2) throw error;
console.warn(
'[OpenSky] global snapshot attempt failed, retrying:',
error?.message || error,
);
}
}
}
// --- OpenSky credit governor (field-test fix 2026-07-06) -------------------
// The global /states/all this proxy fetches costs 4 CREDITS per call against
// OpenSky's ~4000/day authenticated budget — a day with the app open burned
@@ -137,7 +107,6 @@ export async function getOpenSkyToken() {
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
signal: AbortSignal.timeout(OPENSKY_ATTEMPT_TIMEOUT_MS),
body: `grant_type=client_credentials&client_id=${encodeURIComponent(clientId)}&client_secret=${encodeURIComponent(clientSecret)}`,
},
);
@@ -501,7 +470,10 @@ export function openSkyProxy() {
}
}
let { upstream, body } = await fetchOpenSkyStates(headers);
let upstream = await fetch(
'https://opensky-network.org/api/states/all?extended=1',
{ headers },
);
// Auto-mode fallback: if OAuth was rejected, retry with Basic credentials
if (
(upstream.status === 401 || upstream.status === 403) &&
@@ -513,11 +485,15 @@ export function openSkyProxy() {
Accept: 'application/json',
Authorization: `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`,
};
({ upstream, body } = await fetchOpenSkyStates(retryHeaders));
upstream = await fetch(
'https://opensky-network.org/api/states/all?extended=1',
{ headers: retryHeaders },
);
usedMode = 'basic';
reason = 'oauth_rejected_fallback_basic';
}
let body = await upstream.text();
const sourceEpochMs = upstream.ok ? openSkySourceEpochMs(body) : null;
if (
upstream.ok &&
-2
View File
@@ -143,10 +143,8 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
if (url.pathname === '/sources') {
const body = {
trimmedPacks: getCctvSources.trimmedPacks?.() ?? [],
sources: sources.map((source) => ({
id: source.id,
pack: source.pack,
name: source.name,
city: source.city,
cityId: source.cityId,
+2 -22
View File
@@ -3,7 +3,7 @@ import path from 'node:path';
import { DEFAULT_CCTV_SOURCE_FILE, CCTV_SOURCE_CACHE_MS } from './constants.js';
import { allocateSourceCap, resolveCatalogCap } from './cap.js';
import { loadGroundHeights, joinGroundHeights } from './groundHeights.js';
import { cameraRegion, normalizeSourceItem } from './normalize.js';
import { normalizeSourceItem } from './normalize.js';
import {
loadAustinSourcesFromOpenData,
loadCaltransSourcesFromOpenData,
@@ -143,9 +143,6 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
/** @type {Promise<Array<object>>|null} In-flight refresh, shared by concurrent
* callers so a post-TTL burst launches ONE refetch, not one per request. */
let _cctvSourceInflight = null;
/** @type {Array<{pack: string, available: number, served: number}>} Packs
* that serve fewer cameras than they offer. */
let _cctvTrimmedPacks = [];
/**
* Assemble and cache the merged CCTV source list.
@@ -212,12 +209,8 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
name,
sources: items
.filter((item) => item && typeof item === 'object')
.map((item) => ({ ...normalizeSourceItem(item), pack: name }))
.map((item) => normalizeSourceItem(item))
.filter((item) => item.id),
available: Number.isFinite(items.available)
? items.available
: items.length,
region: items.region ?? null,
});
const packs = [
...LIVE_PACKS.map((pack, index) =>
@@ -241,16 +234,6 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
loadGroundHeights(sourceRoot),
);
const trimmed = allocation.packs.filter((pack) => pack.kept < pack.offered);
// Each pack's own nearest-first cap and the shared catalog cap both trim.
const trimmedPacks = packs
.map((pack, index) => ({
pack: pack.name,
available: Math.max(pack.available, allocation.packs[index].offered),
served: allocation.packs[index].kept,
// Where the pack's cameras are, including those not served.
region: pack.region ?? cameraRegion(pack.sources),
}))
.filter((pack) => pack.served < pack.available);
if (trimmed.length) {
const detail = trimmed
.map((pack) => `${pack.name} ${pack.kept}/${pack.offered}`)
@@ -261,7 +244,6 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
}
if (capped.length > 0 || _cctvSourceCache.length === 0) {
_cctvSourceCache = capped;
_cctvTrimmedPacks = trimmedPacks;
} else {
// Every source came back empty (all live packs timed out / upstream outage)
// but a good catalog is already cached — serve it stale rather than blanking
@@ -275,7 +257,5 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
return _cctvSourceCache;
}
/** Packs serving fewer cameras than they offer, as of the last refresh. */
getCctvSources.trimmedPacks = () => _cctvTrimmedPacks;
return getCctvSources;
}
+1 -25
View File
@@ -470,31 +470,7 @@ export function prioritizeSources(cameras, maxCount, anchors) {
return a.idx - b.idx;
});
const kept = scored.slice(0, cap).map((entry) => entry.camera);
// How many cameras the pack offered before trimming, and where, for
// coverage reports.
Object.defineProperty(kept, 'available', { value: list.length });
Object.defineProperty(kept, 'region', { value: cameraRegion(list) });
return kept;
}
/** The bounding box of cameras with coordinates, or null. */
export function cameraRegion(cameras) {
let region = null;
for (const camera of cameras || []) {
const lat = Number(camera?.lat);
const lon = Number(camera?.lon);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
region = region
? {
west: Math.min(region.west, lon),
south: Math.min(region.south, lat),
east: Math.max(region.east, lon),
north: Math.max(region.north, lat),
}
: { west: lon, south: lat, east: lon, north: lat };
}
return region;
return scored.slice(0, cap).map((entry) => entry.camera);
}
/**
+3 -6
View File
@@ -26,11 +26,8 @@ import { firePerimetersProxy } from './firePerimeters.js';
import { cycloneProxy } from './cyclones.js';
import { windProxy } from './wind.js';
/**
* Construct the local provider plugins in their established order.
* `realtime` configures the voice session token endpoint.
*/
function localProviderPlugins({ realtime } = {}) {
/** Construct the local provider plugins in their established order. */
function localProviderPlugins() {
return [
openSkyProxy(),
celestrakProxy(),
@@ -52,7 +49,7 @@ function localProviderPlugins({ realtime } = {}) {
adsbLolProxy(),
aisLiveProxy(),
trackBackfillProxies(),
openAiRealtimeProxy({ realtime }),
openAiRealtimeProxy(),
googlePlacesContextProxy(),
windProxy(),
weatherProxy(),
-2
View File
@@ -19,7 +19,6 @@ function realtimeInstructions(
'analyst_query ANSWERS questions; it never moves the camera or starts tracking. For requests to FOLLOW or TRACK a specific aircraft/ship, call track_entity (get_entity_context first when the target is ambiguous), never analyst_query as the final or only action. For "follow/track the nearest aircraft", first call analyst_query with the aircraft layer(s), sortBy=distance, and limit=1, then call track_entity with the returned aircraft identity in the same turn. The lookup alone does not fulfill a follow/track command.',
'For a request to enable an aircraft layer and SELECT or FIND the nearest/closest aircraft near a named place — for example, "Turn on flights and select the closest aircraft to Austin" — call select_nearest_aircraft once. It atomically turns on the requested aircraft layer first, waits for location arrival, refreshes that layer for the destination viewport, filters out landed/on-ground records, and selects the nearest airborne result. A healthy fallback feed is valid data: report the returned feed source briefly, never call it an enable failure. Do not also call fly_to_location, set_layer_visibility, analyst_query, track_entity, set_context_mode, or control_cockpit for the same request. SELECT/FIND never implies Contacts or Cockpit unless the user explicitly asks for either mode.',
'For ANALYTICAL questions about layer data — how many / which / fastest / highest / biggest / nearest flights, ships, fires, earthquakes, satellites, datacenters, or dams ("how many flights over Texas", "biggest fire near LA", "which ships are headed to Oakland", "anything above 40,000 feet") — call analyst_query, not get_entity_context. Narrate the count plus two or three notable examples by name, and reflect the result\'s coverage note honestly: the answer covers data loaded by enabled layers, not the whole world. If the needed layer is disabled, say so and offer to enable it. For follow-ups about the same set ("which of THOSE is closest?"), call analyst_query with followUp=true and only the new filter/sort.',
'WORLD QUERIES — the query tools (get_weather, get_wind, search_places, places_nearby, plan_route, get_cyclones, get_fire_perimeters, get_bike_share, get_transit_vehicles, get_traffic_flow, situation_brief, military_awareness and the other get_* and find_* tools you have) look up data anywhere in the world without changing the map and without any layer being on. Use them when the user asks about a place they are not looking at or data whose layer is off ("what is the weather in Tokyo", "how do I drive from Austin to Dallas", "any wildfires in California"). For aircraft, ships, earthquakes, active fires, datacenters, dams and satellites overhead, use analyst_query over the layers instead, turning the layer on first when the user wants it. Use analyst_query and get_entity_context for what the app has loaded or what is on screen. Answer from the result summary and data, mention its source when it has one, and never claim the map changed. If the user also wants to SEE it, use the matching app action (fly_to_location, set_layer_visibility) as well.',
'FEED STATE CONTRACT — analyst_query and get_current_view_state carry feedProvenance from the same layerFeedState the Data Layers chips use (nominal/loading/degraded/stale/fallback/unavailable, or off). NEVER narrate a count as live or current when feedProvenance.overall is not nominal. Say the feedState in the same breath as the count ("12 flights in view, STALE"). If unavailable, do not invent a count; say the feed is unavailable. Fallback is valid data — name the fallback source, never call it an enable failure. Confirm only what the tool result returned.',
'COUNTING CONTRACT — what "near" means. (1) While Contacts is ACTIVE, "near / nearby / how many aircraft" means the Contacts window: answer from contactsWindow in the tool result — those are the exact numbers on the user\'s panel. set_context_mode, analyst_query, and get_current_view_state carry it after Contacts settles. For "Open Contacts and tell me how many aircraft are within 250 km", call set_context_mode{mode:"contacts"} first and answer from contactsWindow.aircraft; do not answer from a pre-Contacts analyst query. analyst_query\'s own count measures currently-loaded records and is usually lower; never give it as the window count. CENTER PRECEDENCE for a nearby/how-many ask, in order: an explicit place in the question ("over Texas", "near Austin") always wins and ignores Contacts state; else the CONTACTS SUBJECT when Contacts is active and has one — a selected datacenter, dam, fire, or cable does NOT silently become the center; else an entity the user explicitly names ("around this datacenter"); else the current view, said aloud ("nothing is selected, so this is the current view"). With Contacts active but NO subject yet, use the view and say so; never read an empty panel. (2) With Contacts OFF, "nearby" means in view; "near <place>" means a radius around that place. (3) EVERY count names its scope in words — "42 in your window", "8 in view", "about 30 within 250 km of Austin" — never a bare number; analyst_query returns scopeLabel for exactly this. Two different numbers with named scopes are not a contradiction; say both if asked. (4) State counts VERBATIM — never estimate, round, or hedge ("a few", "less than a dozen"): if a tool returns 46, say 46. (5) When it matters, add once: counts cover loaded data, and the flights layer loads where you look.',
'While Cockpit is active, navigate with control_cockpit (next/previous, optionally targetLayer or aircraftClass). track_entity and fly_to_location are REFUSED by design while Cockpit owns the camera — that refusal is correct, not an error to retry. To go somewhere else, exit Cockpit first. control_cockpit enter establishes Contacts itself, so do not call set_context_mode before or after it.',
@@ -49,7 +48,6 @@ function realtimeInstructions(
// string is the whole rollback.
'NAMED VIEWS are shorthand for tool calls you already have — there is no "mode" tool for them. Treat ONLY these as the shorthand: "infrastructure mode" / "the infrastructure view" / "show me global infrastructure" means three set_layer_visibility calls (local-datacenters, local-dams, telegeography-submarine-cables) plus zoom_to_globe; "environmental mode" / "earth watch" / "active events", said as the name of a view, means set_layer_visibility for local-firms and earthquakes plus zoom_to_globe. Anything vaguer is NOT this shorthand — an open-ended question about the world or the news is an ordinary question: answer it, or use analyst_query over the layers already on. Never switch a whole view on to answer a question nobody asked to see. When you do run one, make every call before speaking, then give one confirmation naming the resulting state; if the fires layer comes back unavailable because no FIRMS key is configured, say so plainly — the earthquakes still loaded. "Live contacts" and "space missions" are NOT this pattern: they stay set_context_mode{mode:"contacts"} and set_context_mode{mode:"space-missions"}.',
'For visual filter requests, call set_visual_style with one of the allowed style IDs.',
'SHIPS vs STYLES: "vessels" is easily heard as "visuals". "Show me vessels", "turn on vessels", "the vessels", "visuals" or "vestibules" said WITHOUT a style name means the ships layer: set_layer_visibility{layerId:"ais-live-vessels"}, exactly as "ships" or "boats" does. Call set_visual_style only when the user names a style (retro/CRT, surveillance/night vision, thermal, anime, noir, snow, normal) or says filter, effect or look. Never turn on a different data layer (such as fires) for a ships request.',
'Disambiguation table — basemap vs layer vs style: basemap switching requires an explicit stack name — "Bing aerial" means set_map_stack bing-aerial, "aerial with labels" means bing-labels, "OSM"/"road map" means osm, "Esri"/"Esri imagery" means esri-imagery, "Google 3D"/"photorealistic" means photoreal. Any mention of "satellite" or "satellites" ALWAYS means the satellites DATA LAYER via set_layer_visibility, never a basemap. "surveillance"/"night vision"/"thermal" are visual STYLES via set_visual_style.',
'HUD requests ("hud on/off", "switch to operator/minimal/tactical/cyber layout") use set_hud. Detection requests ("detection on", "dense mode", "balanced mode", "sparse mode", "set density to 25", "use weighted allocation") use set_detection. Density snaps to 0/25/50/75/100 and derives Sparse/Balanced/Dense; panoptic is a legacy alias for Dense.',
'Cyber sonar requests use set_cyber_sonar: enabled, rings, rangePct, intensityPct (Power), opacityPct, sectorDeg. For settings questions read get_current_view_state.controls.hud.sonar. Sonar is not detection. Do not silently switch themes, reveal the HUD or enable sonar when changing a slider. If the user explicitly requests Cyber plus sonar settings, call set_hud first, wait for success, then set_cyber_sonar. Confirm returned values; mapSweepActive and contactSweepActive distinguish saved settings from active map effects, including in Cockpit or reduced motion. Do not claim visible contacts solely because enabled is true.',
+1 -2
View File
@@ -20,7 +20,6 @@ function createRealtimeTokenHandler({
fetchImpl = (...args) => fetch(...args),
resolveApiKey = () => process.env.OPENAI_API_KEY,
models = {},
tools = GEV_REALTIME_TOOLS,
} = {}) {
return async (req, res) => {
res.setHeader('Cache-Control', 'no-store');
@@ -114,7 +113,7 @@ function createRealtimeTokenHandler({
output: { voice },
},
instructions: realtimeInstructions(annotationGuidance),
tools,
tools: GEV_REALTIME_TOOLS,
tool_choice: 'auto',
},
};
-12
View File
@@ -2,15 +2,3 @@ import { createActionTools } from '../../../src/voice/actionSchemas.js';
import { ACTION_DESCRIPTIONS } from './toolDescriptions.js';
export const GEV_REALTIME_TOOLS = createActionTools(ACTION_DESCRIPTIONS);
/**
* The session's tools: the app actions, then additional function tools
* whose names no action already uses.
*/
export function realtimeSessionTools(additional = []) {
const names = new Set(GEV_REALTIME_TOOLS.map((tool) => tool.name));
return [
...GEV_REALTIME_TOOLS,
...additional.filter((tool) => !names.has(tool.name)),
];
}
+100
View File
@@ -0,0 +1,100 @@
import { setTimeout as sleepDefault } from 'node:timers/promises';
/** Admit at most 80 requests in a rolling minute; return retry seconds or zero.
* Call only at the upstream boundary, after cache lookup and coalescing. */
export function createRainViewerGovernor() {
const attempts = [];
const queue = [];
let timer;
const delay = (now) => {
while (attempts.length && now - attempts[0] >= 60_000) attempts.shift();
return attempts.length >= 80 ? 60_000 - (now - attempts[0]) : 0;
};
const retry = (now) => Math.max(1, Math.ceil(delay(now) / 1000));
const check = (now) => {
if (delay(now) || queue.length) return retry(now);
attempts.push(now);
return 0;
};
function finish(waiter, value, error) {
queue.splice(queue.indexOf(waiter), 1);
waiter.signal?.removeEventListener('abort', waiter.abort);
if (error) waiter.reject(error);
else waiter.resolve(value);
}
function pump() {
timer?.abort();
timer = null;
for (const waiter of [...queue]) {
const time = waiter.now();
if (time > waiter.deadline || delay(time) > waiter.deadline - time)
finish(waiter, retry(time));
}
while (queue.length) {
const waiter = queue[0];
const time = waiter.now();
const wait = delay(time);
if (wait > waiter.deadline - time) {
finish(waiter, retry(time));
continue;
}
if (wait) {
const controller = new AbortController();
timer = controller;
const duration = Math.min(
wait,
...queue.map((entry) => entry.deadline - entry.now()),
);
Promise.resolve()
.then(() =>
waiter.sleep(duration, undefined, { signal: controller.signal }),
)
.then(
() => {
if (timer === controller) pump();
},
(error) => {
if (timer !== controller) return;
timer = null;
for (const entry of [...queue]) finish(entry, undefined, error);
},
);
return;
}
attempts.push(time);
finish(waiter, 0);
}
}
// Pass a clock function so each wake-up checks the current rolling window.
check.admit = async (
now = Date.now,
{ signal, maxWaitMs = 20_000, maxQueued = 256, sleep = sleepDefault } = {},
) => {
signal?.throwIfAborted();
const time = now();
if (!queue.length && !delay(time)) {
attempts.push(time);
return 0;
}
if (queue.length >= maxQueued || delay(time) > maxWaitMs)
return retry(time);
return new Promise((resolve, reject) => {
const waiter = {
now,
sleep,
signal,
deadline: time + maxWaitMs,
resolve,
reject,
};
waiter.abort = () => {
finish(waiter, undefined, signal.reason);
pump();
};
queue.push(waiter);
signal?.addEventListener('abort', waiter.abort, { once: true });
pump();
});
};
return check;
}
+154 -37
View File
@@ -1,7 +1,11 @@
import { createRainViewerGovernor } from './rainViewerGovernor.js';
import { readResponseTextCapped } from './common/http.js';
import { readWindBody as readBytesCapped } from '../../src/sources/windBody.js';
const BASE = 'https://nowcoast.noaa.gov/geoserver/observations/';
const RAINVIEWER_MANIFEST =
'https://api.rainviewer.com/public/weather-maps.json';
const RAINVIEWER_TILES = 'https://tilecache.rainviewer.com';
const HOUR = 3600_000;
const PRODUCTS = Object.freeze({
lightning: Object.freeze({
@@ -28,6 +32,25 @@ const PRODUCTS = Object.freeze({
'Observed MRMS radar base reflectivity (dBZ), approximately 1 km and 4-minute updates; not a rainfall forecast.',
image: Object.freeze({ width: 4096, height: 2048 }),
}),
'radar-global': Object.freeze({
service: 'rainviewer',
title: 'Global radar reflectivity',
coverage: 'Global · where radars exist',
description:
'RainViewer global radar composite (dBZ). Coverage follows national radar networks; a gap does not mean no rain.',
source: 'RainViewer',
attribution: 'RainViewer',
metadataTtlMs: 300_000,
tilingScheme: 'web-mercator',
maxLevel: 7,
bounds: Object.freeze({
west: -180,
south: -85.0511,
east: 180,
north: 85.0511,
}),
image: Object.freeze({ width: 2048, height: 1024 }),
}),
clouds: Object.freeze({
service: 'satellite',
layer: 'global_longwave_imagery_mosaic',
@@ -169,6 +192,52 @@ export function parseWeatherCapabilities(xml, product, nowMs = Date.now()) {
return { bounds, times: recent.slice(-13), allowedTimes: recent };
}
/** Accept only observed frames with bounded paths; never trust the manifest host. */
export function parseRainViewerManifest(text) {
const manifest = JSON.parse(text);
if (manifest?.version !== '2.0' || !Array.isArray(manifest.radar?.past))
throw failure('invalid_weather_metadata');
const frames = new Map();
for (const frame of manifest.radar.past) {
if (
!Number.isSafeInteger(frame?.time) ||
frame.time <= 0 ||
typeof frame.path !== 'string' ||
!/^\/v2\/radar\/[A-Za-z0-9]{1,64}$/.test(frame.path)
)
continue;
const date = new Date(frame.time * 1000);
if (!Number.isFinite(date.getTime()) || date.getUTCFullYear() > 9999)
continue;
frames.set(date.toISOString(), frame.path);
}
const times = [...frames.keys()].sort().slice(-13);
if (!times.length) throw failure('invalid_weather_metadata');
return {
bounds: PRODUCTS['radar-global'].bounds,
times,
allowedTimes: times,
framePaths: Object.fromEntries(
times.map((time) => [time, frames.get(time)]),
),
};
}
/** Web Mercator XYZ has one square root, unlike NOAA's geographic grid. */
export function rainViewerTileCoordinates(z, x, y) {
if (
![z, x, y].every(Number.isInteger) ||
z < 0 ||
z > 7 ||
x < 0 ||
y < 0 ||
x >= 2 ** z ||
y >= 2 ** z
)
throw failure('invalid_weather_tile', 400);
return [z, x, y];
}
/** A detail window `west,south,east,north` in degrees, rounded to 0.25° so cache
* keys repeat, with a 2:1 aspect within 1 %; null when absent. Containment in
* the product bounds is checked once the metadata is known. */
@@ -234,12 +303,14 @@ function validatePng(bytes, width, height) {
return buffer;
}
/** Fixed NOAA observed-weather metadata and WMS tiles; no client-supplied destinations. */
/** Fixed observed-weather metadata and tiles; no client-supplied destinations. */
export function weatherProxy({
fetchImpl = fetch,
now = () => Date.now(),
timeoutMs = 12_000,
rainViewerSleep,
} = {}) {
const admitRainViewerTile = createRainViewerGovernor();
const metadata = new Map();
const attempts = new Map();
const operations = new Map();
@@ -264,7 +335,7 @@ export function weatherProxy({
});
}
}
async function shared(key, work, clientSignal) {
async function shared(key, work, clientSignal, extraWaitMs = 0) {
clientSignal.throwIfAborted();
let operation = operations.get(key);
if (operation?.controller.signal.aborted) {
@@ -286,7 +357,10 @@ export function weatherProxy({
operation.reject(failure('weather_request_cancelled'));
};
controller.signal.addEventListener('abort', cancel, { once: true });
const timer = setTimeout(() => controller.abort(), timeoutMs);
const timer = setTimeout(
() => controller.abort(),
timeoutMs + extraWaitMs,
);
operation.promise = operation.promise.finally(() => {
clearTimeout(timer);
controller.signal.removeEventListener('abort', cancel);
@@ -310,12 +384,18 @@ export function weatherProxy({
if (!clientSignal.aborted) operation.waiters--;
}
}
async function upstream(url, signal, image = null) {
async function upstream(url, signal, image = null, json = false) {
signal.throwIfAborted();
const response = await fetchImpl(url, {
signal,
redirect: 'error',
headers: { Accept: image ? 'image/png' : 'application/xml,text/xml' },
headers: {
Accept: image
? 'image/png'
: json
? 'application/json'
: 'application/xml,text/xml',
},
});
if (
!response.ok ||
@@ -331,7 +411,11 @@ export function weatherProxy({
image.width,
image.height,
)
: await readResponseTextCapped(response, 512 * 1024, signal);
: await readResponseTextCapped(
response,
(json ? 256 : 512) * 1024,
signal,
);
signal.throwIfAborted();
return value;
}
@@ -348,14 +432,19 @@ export function weatherProxy({
)
throw failure('weather_upstream_unavailable');
attempts.set(product, now());
const url = `${BASE}${spec.service}/ows?service=WMS&version=1.3.0&request=GetCapabilities`;
const xml = await shared(
const globalRadar = product === 'radar-global';
const url = globalRadar
? RAINVIEWER_MANIFEST
: `${BASE}${spec.service}/ows?service=WMS&version=1.3.0&request=GetCapabilities`;
const text = await shared(
`metadata:${spec.service}`,
(activeSignal) => upstream(url, activeSignal),
(activeSignal) => upstream(url, activeSignal, null, globalRadar),
signal,
);
const value = {
...parseWeatherCapabilities(xml, product, now()),
...(globalRadar
? parseRainViewerManifest(text)
: parseWeatherCapabilities(text, product, now())),
fetchedAt: now(),
};
metadata.set(product, value);
@@ -388,7 +477,7 @@ export function weatherProxy({
title: spec.title,
coverage: spec.coverage,
description: spec.description,
source: 'NOAA nowCOAST',
source: spec.source ?? 'NOAA nowCOAST',
attribution: spec.attribution ?? 'NOAA/NWS/NESDIS nowCOAST',
bounds: value?.bounds ?? null,
times: value?.times ?? [],
@@ -404,22 +493,23 @@ export function weatherProxy({
? 'Cached weather metadata; upstream unavailable'
: null,
tileSize: 256,
maxLevel: 6,
tilingScheme: 'geographic',
maxLevel: spec.maxLevel ?? 6,
tilingScheme: spec.tilingScheme ?? 'geographic',
tileTemplate: time
? `/api/weather/tile?product=${product}&time=${encodeURIComponent(time)}&z={z}&x={x}&y={y}`
: null,
imageUrl: time
? `/api/weather/image?product=${product}&time=${encodeURIComponent(time)}`
: null,
imageUrl:
time && product !== 'radar-global'
? `/api/weather/image?product=${product}&time=${encodeURIComponent(time)}`
: null,
imageSize: { ...spec.image },
};
}
function json(res, status, body) {
function json(res, status, body, retryAfter = 2) {
res.writeHead(status, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
...(status === 429 ? { 'Retry-After': '2' } : {}),
...(status === 429 ? { 'Retry-After': String(retryAfter) } : {}),
});
res.end(JSON.stringify(body));
}
@@ -451,6 +541,9 @@ export function weatherProxy({
if (!Object.hasOwn(PRODUCTS, product))
return json(res, 400, { error: 'unknown_weather_product' });
const wholeImage = url.pathname === '/image';
const globalRadar = product === 'radar-global';
if (wholeImage && globalRadar)
throw failure('whole_image_unsupported', 400);
const detailBox = wholeImage
? weatherImageBbox(url.searchParams.get('bbox'))
: null;
@@ -462,7 +555,7 @@ export function weatherProxy({
wholeImage
? !IMAGE_SIZES.includes(size) ||
Number.parseInt(size, 10) > largest.width
: !['256', '512', '1024'].includes(size)
: !(globalRadar ? ['256'] : ['256', '512', '1024']).includes(size)
)
throw failure(
wholeImage
@@ -487,7 +580,9 @@ export function weatherProxy({
if (coords?.some((value) => !/^(?:0|[1-9]\d{0,2})$/.test(value ?? '')))
throw failure('invalid_weather_tile', 400);
const tileBounds = coords
? weatherTileBounds(...coords.map(Number))
? globalRadar
? rainViewerTileCoordinates(...coords.map(Number))
: weatherTileBounds(...coords.map(Number))
: null;
const time = url.searchParams.get('time');
if (!time || observationTime(time) !== time)
@@ -495,7 +590,9 @@ export function weatherProxy({
const value = await getMetadata(product, controller.signal);
controller.signal.throwIfAborted();
if (
!value.allowedTimes.includes(time) ||
(globalRadar
? !Object.hasOwn(value.framePaths, time)
: !value.allowedTimes.includes(time)) ||
now() - Date.parse(time) > 24 * HOUR
)
throw failure('unknown_weather_time', 400);
@@ -543,26 +640,45 @@ export function weatherProxy({
if (now() - (tileFailures.get(key) ?? -Infinity) < 30_000)
throw failure('weather_upstream_unavailable');
const spec = PRODUCTS[product];
const upstreamUrl = new URL(`${BASE}${spec.service}/ows`);
upstreamUrl.search = new URLSearchParams({
service: 'WMS',
version: '1.1.1',
request: 'GetMap',
layers: spec.layer,
styles: spec.style,
srs: 'EPSG:4326',
bbox: bbox.join(','),
width: String(imageShape.width),
height: String(imageShape.height),
format: 'image/png',
transparent: 'true',
time,
}).toString();
const upstreamUrl = new URL(
globalRadar
? `${RAINVIEWER_TILES}${value.framePaths[time]}/256/${coords.join('/')}/2/1_1.png`
: `${BASE}${spec.service}/ows`,
);
if (!globalRadar)
upstreamUrl.search = new URLSearchParams({
service: 'WMS',
version: '1.1.1',
request: 'GetMap',
layers: spec.layer,
styles: spec.style,
srs: 'EPSG:4326',
bbox: bbox.join(','),
width: String(imageShape.width),
height: String(imageShape.height),
format: 'image/png',
transparent: 'true',
time,
}).toString();
try {
const bytes = await shared(
`image:${key}`,
(signal) => upstream(upstreamUrl.href, signal, imageShape),
async (signal) => {
if (globalRadar) {
const retryAfter = await admitRainViewerTile.admit(now, {
signal,
sleep: rainViewerSleep,
});
if (retryAfter)
throw Object.assign(failure('weather_busy', 429), {
retryAfter,
});
}
signal.throwIfAborted();
return upstream(upstreamUrl.href, signal, imageShape);
},
controller.signal,
globalRadar ? 20_000 : 0,
);
rememberTile(key, bytes);
cached = { bytes };
@@ -593,6 +709,7 @@ export function weatherProxy({
? error.code
: 'weather_upstream_unavailable',
},
error.retryAfter,
);
} finally {
res.removeListener?.('close', close);
+1 -7
View File
@@ -2,9 +2,7 @@ import { fileURLToPath } from 'node:url';
import { defineConfig, loadEnv } from 'vite';
import { createBrowserViteConfig } from '../../build/vite.js';
import { localProviderPlugins } from '../providers/local.js';
import { localMcpPlugin } from '../mcp/plugin.js';
import { apiNotFoundPlugin } from './api-not-found.js';
import { standaloneVoiceTools } from './voiceTools.js';
const root = fileURLToPath(new URL('../../', import.meta.url));
@@ -15,11 +13,7 @@ export default defineConfig(({ command, mode }) => {
if (process.env[key] === undefined) process.env[key] = value;
}
return createBrowserViteConfig({
plugins: [
...localProviderPlugins({ realtime: { tools: standaloneVoiceTools() } }),
localMcpPlugin(),
apiNotFoundPlugin(),
],
plugins: [...localProviderPlugins(), apiNotFoundPlugin()],
googleApiKey: process.env.GOOGLE_MAPS_API_KEY,
cesiumToken: process.env.CESIUM_ION_TOKEN,
host: process.env.HOST,
-18
View File
@@ -1,18 +0,0 @@
/**
* The voice session's tools for the standalone server: the app actions plus
* the catalog tools voice offers (see src/tools/surfaces.js), which the
* browser runs through the same catalog.
*/
import {
coreTools,
toFunctionTools,
toolsForSurface,
} from '../../src/tools/index.js';
import { realtimeSessionTools } from '../providers/openai/tools.js';
export function standaloneVoiceTools() {
return realtimeSessionTools(
toFunctionTools(toolsForSurface(coreTools, 'voice')),
);
}
-293
View File
@@ -1,293 +0,0 @@
/**
* Embed mode and views in the running app.
*
* With `?embed=1` the app shows only the globe: clean view, with the HUD,
* welcome and setup prompts hidden. A page that frames it changes what it
* shows by posting `{ type: 'gev:view', id, view }`; the app applies the view
* through its own actions and answers `{ type: 'gev:view-applied', id, ok,
* steps }`. It announces `{ type: 'gev:ready' }` once it can take views.
*
* Annotations in a share link are drawn once the link has been restored,
* embedded or not.
*/
import * as Cesium from 'cesium';
import { announceNavigationAuthority } from '../navigationPolicy.js';
import { annotationsFromParams, createView } from '../view/index.js';
export const EMBED_VIEW_MESSAGE = 'gev:view';
export const EMBED_APPLIED_MESSAGE = 'gev:view-applied';
export const EMBED_READY_MESSAGE = 'gev:ready';
const FOLLOW_LAYERS = {
aircraft: 'flights',
military_aircraft: 'military',
satellite: 'satellites',
};
const FOLLOW_ATTEMPTS = 20;
const FOLLOW_RETRY_MS = 1000;
const FLIGHT_SECONDS = 2;
/**
* Whether this page shows the app embedded: `?embed=1` when another page
* frames it, or inline when a panel page loads the app into itself and sets
* `globalThis.GEV_EMBED_INLINE` first.
*/
export function isEmbedded(location = globalThis.location) {
return (
isEmbeddedInline() ||
new URLSearchParams(location?.search || '').get('embed') === '1'
);
}
/** Whether a panel page loaded the app into itself. */
export function isEmbeddedInline() {
return globalThis.GEV_EMBED_INLINE === true;
}
// A panel keeps drawing at about this rate when its host stops animation
// frames; see keepPanelRendering.
const PANEL_FRAME_MS = 33;
const MISSED_FRAMES_MS = 250;
/** A render error's details, including the plain objects workers report. */
function describeError(error) {
if (error instanceof Error)
return `${error.name}: ${error.message}\n${error.stack}`;
try {
return JSON.stringify(error, Object.getOwnPropertyNames(error ?? {}));
} catch {
return String(error);
}
}
/**
* Keep the globe drawing in an inline panel. Some hosts report a panel on
* screen as hidden, which stops the browser's animation frames and with them
* Cesium's render loop; while frames stop arriving, draw from a timer.
* Returns a function that stops it.
*/
export function keepPanelRendering(
viewer,
{ windowRef = globalThis.window, now = () => performance.now() } = {},
) {
let lastFrame = now();
let frameRequest = null;
const onFrame = () => {
lastFrame = now();
frameRequest = windowRef.requestAnimationFrame(onFrame);
};
frameRequest = windowRef.requestAnimationFrame(onFrame);
// Cesium stops drawing after a render error, and so does the timer.
// Report the error in full: workers report plain objects, which Cesium
// prints as [object Object].
let failed = false;
const removeErrorListener = viewer.scene?.renderError?.addEventListener(
(_scene, error) => {
failed = true;
console.error(
"[God's Eye View panel] render error:",
describeError(error),
);
},
);
const timer = windowRef.setInterval(() => {
if (
failed ||
now() - lastFrame < MISSED_FRAMES_MS ||
viewer.isDestroyed?.()
)
return;
viewer.resize();
viewer.render();
}, PANEL_FRAME_MS);
return () => {
windowRef.clearInterval(timer);
windowRef.cancelAnimationFrame(frameRequest);
removeErrorListener?.();
};
}
const delay = (ms, signal) =>
new Promise((resolve) => {
const timer = setTimeout(resolve, ms);
signal?.addEventListener(
'abort',
() => {
clearTimeout(timer);
resolve();
},
{ once: true },
);
});
/** Fly the camera to a view's camera and resolve when the flight ends. */
function flyTo(viewer, camera) {
announceNavigationAuthority('embed-view');
return new Promise((resolve) => {
viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(
camera.lon,
camera.lat,
camera.altitude_m,
),
orientation: {
heading: Cesium.Math.toRadians(camera.heading_deg),
pitch: Cesium.Math.toRadians(camera.pitch_deg),
roll: 0,
},
duration: FLIGHT_SECONDS,
complete: () => resolve(true),
cancel: () => resolve(false),
});
});
}
/**
* Make the app show a view: style and map, exactly the view's layers,
* annotations, the camera, and the followed entity. Each step runs through the
* app's own actions (`run(name, args)`); a failed step is reported and the
* rest still run. Resolves to the steps and whether each succeeded.
*/
export async function applyView(
view,
{ viewer, dataManager, run, signal, retryMs = FOLLOW_RETRY_MS },
) {
const steps = [];
const act = async (name, args) => {
let result = null;
try {
result = await run(name, args);
} catch (error) {
result = { ok: false, error: error?.message || String(error) };
}
steps.push({
step: name,
ok: result?.ok !== false,
...(result?.error ? { error: result.error } : {}),
});
return result;
};
if (view.style) await act('set_visual_style', { style: view.style });
if (view.map) await act('set_map_stack', { stack: view.map });
const wanted = new Set(view.layers);
for (const layer of dataManager.getAll()) {
if (layer.enabled && !wanted.has(layer.id))
await act('set_layer_visibility', { layerId: layer.id, enabled: false });
}
for (const layerId of view.layers) {
if (!dataManager.isEnabled(layerId))
await act('set_layer_visibility', { layerId, enabled: true });
}
if (!view.follow) await act('stop_tracking', {});
// Annotations go first: drawing them may frame the marks, and the view's
// own camera should have the last word.
await act('clear_annotations', {});
if (view.annotations.length)
await act('annotate_map', { annotations: [...view.annotations] });
if (!view.follow) {
steps.push({ step: 'camera', ok: await flyTo(viewer, view.camera) });
return steps;
}
// A followed entity owns the camera, and a camera flight would end the
// follow, so fly to the view only while the entity is not there yet. It
// appears once its layer has data; retry until then.
const layerId = FOLLOW_LAYERS[view.follow.kind];
const follow = async () => {
try {
return (
(await run('track_entity', { query: view.follow.id, layerId }))?.ok ===
true
);
} catch {
return false;
}
};
let followed = await follow();
if (!followed) {
steps.push({ step: 'camera', ok: await flyTo(viewer, view.camera) });
for (let attempt = 1; attempt < FOLLOW_ATTEMPTS && !followed; attempt++) {
if (signal?.aborted) break;
await delay(retryMs, signal);
followed = await follow();
}
}
steps.push({ step: 'follow', ok: followed });
if (followed && view.follow.cockpit)
await act('control_cockpit', { action: 'enter', targetLayer: layerId });
return steps;
}
/**
* Install embed mode when the page asks for it, and draw any annotations the
* opening link carries. Returns a function that removes the message handler.
*/
export function installViews({
shell,
viewer,
dataManager,
run,
signal,
location = globalThis.location,
windowRef = globalThis.window,
}) {
const ready = Promise.resolve(shell.initialRestorePromise).catch(() => {});
const linked = annotationsFromParams(
new URLSearchParams(String(location?.hash || '').replace(/^#/, '')),
);
if (linked.length)
void ready.then(() => {
if (!signal?.aborted) void run('annotate_map', { annotations: linked });
});
if (!isEmbedded(location)) return () => {};
windowRef.document.body.classList.add('ui-embed');
shell.setCleanView?.(true);
const stopRendering = isEmbeddedInline()
? keepPanelRendering(viewer, { windowRef })
: () => {};
// A framing page talks to the app across frames; an inline panel shares
// the page with it and talks through the page's own window.
const peer = isEmbeddedInline() ? windowRef : windowRef.parent;
const post = (message) => {
if (peer && (peer !== windowRef || isEmbeddedInline()))
peer.postMessage(message, '*');
};
// Views apply one at a time, in the order they arrive.
let queue = ready;
const onMessage = (event) => {
if (event.source !== peer || event.data?.type !== EMBED_VIEW_MESSAGE)
return;
const { id = null } = event.data;
let view;
try {
view = createView(event.data.view);
} catch (error) {
post({
type: EMBED_APPLIED_MESSAGE,
id,
ok: false,
error: error.message,
});
return;
}
queue = queue.then(async () => {
if (signal?.aborted) return;
const steps = await applyView(view, { viewer, dataManager, run, signal });
post({
type: EMBED_APPLIED_MESSAGE,
id,
ok: steps.every((step) => step.ok),
steps,
});
});
};
windowRef.addEventListener('message', onMessage);
void ready.then(() => {
if (!signal?.aborted) post({ type: EMBED_READY_MESSAGE });
});
return () => {
stopRendering();
windowRef.removeEventListener('message', onMessage);
};
}
-312
View File
@@ -1,312 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
applyView,
installViews,
isEmbedded,
keepPanelRendering,
} from './embed.js';
import { createView } from '../view/index.js';
const fakeViewer = () => {
const flights = [];
return {
flights,
camera: {
flyTo(options) {
flights.push(options);
options.complete();
},
},
};
};
const fakeLayers = (enabled) => ({
getAll: () =>
['flights', 'earthquakes', 'ais-live-vessels'].map((id) => ({
id,
enabled: enabled.has(id),
})),
isEnabled: (id) => enabled.has(id),
});
const recorder = (answers = {}) => {
const calls = [];
const run = async (name, args) => {
calls.push([name, args]);
const answer = answers[name];
return typeof answer === 'function'
? answer(calls)
: (answer ?? { ok: true });
};
return { calls, run };
};
test('embed mode is opt-in by query parameter', () => {
assert.equal(isEmbedded({ search: '?embed=1' }), true);
assert.equal(isEmbedded({ search: '?welcome=0' }), false);
});
test('a view is applied through the app actions in order', async () => {
const viewer = fakeViewer();
const { calls, run } = recorder();
const view = createView({
camera: { lat: 25, lon: 121, altitude_m: 300000, pitch_deg: -40 },
layers: ['ais-live-vessels'],
style: 'thermal',
map: 'osm',
annotations: [{ type: 'pin', target: 'Taipei 101' }],
});
const steps = await applyView(view, {
viewer,
dataManager: fakeLayers(new Set(['earthquakes'])),
run,
});
assert.deepEqual(
calls.map(([name, args]) => [
name,
args.layerId ?? args.style ?? args.stack ?? null,
args.enabled ?? null,
]),
[
['set_visual_style', 'thermal', null],
['set_map_stack', 'osm', null],
['set_layer_visibility', 'earthquakes', false],
['set_layer_visibility', 'ais-live-vessels', true],
['stop_tracking', null, null],
['clear_annotations', null, null],
['annotate_map', null, null],
],
);
assert.equal(viewer.flights.length, 1);
assert.ok(steps.every((step) => step.ok));
assert.ok(steps.some((step) => step.step === 'camera'));
});
test('a followed entity is retried until its layer has it', async () => {
let tries = 0;
const { run } = recorder({
track_entity: () => ({ ok: ++tries >= 3 }),
});
const view = createView({
camera: { lat: 0, lon: 0 },
follow: { kind: 'aircraft', id: 'abc123' },
});
const steps = await applyView(view, {
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run,
retryMs: 1,
});
assert.equal(tries, 3);
assert.deepEqual(steps.at(-1), { step: 'follow', ok: true });
});
test('an embedded page takes views only from its parent and answers it', async () => {
const posted = [];
const parent = { postMessage: (message) => posted.push(message) };
const windowRef = new EventTarget();
windowRef.parent = parent;
windowRef.document = { body: { classList: new Set() } };
windowRef.document.body.classList.add = Set.prototype.add;
const shell = {
initialRestorePromise: Promise.resolve(),
clean: null,
setCleanView(value) {
this.clean = value;
},
};
const { calls, run } = recorder();
const remove = installViews({
shell,
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run,
location: { search: '?embed=1', hash: '' },
windowRef,
});
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(shell.clean, true);
assert.deepEqual(posted, [{ type: 'gev:ready' }]);
const send = (source, data) => {
const event = new Event('message');
Object.assign(event, { source, data });
windowRef.dispatchEvent(event);
};
send({}, { type: 'gev:view', id: 1, view: { camera: { lat: 1, lon: 2 } } });
send(parent, { type: 'gev:view', id: 2, view: { camera: {} } });
send(parent, {
type: 'gev:view',
id: 3,
view: { camera: { lat: 1, lon: 2 }, layers: ['flights'] },
});
await new Promise((resolve) => setTimeout(resolve, 10));
assert.deepEqual(posted[1], {
type: 'gev:view-applied',
id: 2,
ok: false,
error: 'A view needs a camera lat and lon',
});
assert.equal(posted[2].id, 3);
assert.equal(posted[2].ok, true);
assert.ok(
calls.some(
([name, args]) =>
name === 'set_layer_visibility' && args.layerId === 'flights',
),
);
assert.equal(posted.filter((message) => message.id === 1).length, 0);
remove();
});
test('an app loaded inline into a panel page talks through its own window', async () => {
const posted = [];
const windowRef = new EventTarget();
windowRef.parent = windowRef;
windowRef.postMessage = (message) => posted.push(message);
windowRef.document = { body: { classList: new Set() } };
windowRef.document.body.classList.add = Set.prototype.add;
Object.assign(windowRef, {
requestAnimationFrame: () => 1,
cancelAnimationFrame() {},
setInterval: () => 1,
clearInterval() {},
});
globalThis.GEV_EMBED_INLINE = true;
try {
assert.equal(isEmbedded({ search: '' }), true);
const remove = installViews({
shell: { initialRestorePromise: Promise.resolve() },
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run: recorder().run,
location: { search: '', hash: '' },
windowRef,
});
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(posted, [{ type: 'gev:ready' }]);
const event = new Event('message');
Object.assign(event, {
source: windowRef,
data: { type: 'gev:view', id: 7, view: { camera: { lat: 1, lon: 2 } } },
});
windowRef.dispatchEvent(event);
await new Promise((resolve) => setTimeout(resolve, 10));
assert.equal(posted[1].id, 7);
assert.equal(posted[1].ok, true);
remove();
} finally {
delete globalThis.GEV_EMBED_INLINE;
}
});
test('annotations in a link are drawn after it restores, embedded or not', async () => {
const { calls, run } = recorder();
const an = encodeURIComponent(
JSON.stringify([{ type: 'pin', target: 'Austin' }]),
);
installViews({
shell: { initialRestorePromise: Promise.resolve() },
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run,
location: { search: '', hash: `#v=2&lat=1&lon=2&an=${an}` },
windowRef: new EventTarget(),
});
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(calls, [
['annotate_map', { annotations: [{ type: 'pin', target: 'Austin' }] }],
]);
});
test('a panel keeps drawing from a timer while animation frames stop', () => {
let clock = 0;
let tick = null;
let frame = null;
const windowRef = {
requestAnimationFrame: (fn) => ((frame = fn), 1),
cancelAnimationFrame: () => (frame = null),
setInterval: (fn) => ((tick = fn), 1),
clearInterval: () => (tick = null),
};
const drawn = [];
const viewer = {
resize: () => drawn.push('resize'),
render: () => drawn.push('render'),
};
const stop = keepPanelRendering(viewer, { windowRef, now: () => clock });
// Frames arrive: the render loop draws, so the timer does nothing.
clock = 100;
frame();
tick();
assert.deepEqual(drawn, []);
// Frames stop, as in a panel its host reports hidden: the timer draws.
clock = 400;
tick();
assert.deepEqual(drawn, ['resize', 'render']);
stop();
assert.equal(tick, null);
assert.equal(frame, null);
});
test('a panel reports a render error in full and stops drawing', () => {
let listener = null;
let tick = null;
const windowRef = {
requestAnimationFrame: () => 1,
cancelAnimationFrame() {},
setInterval: (fn) => ((tick = fn), 1),
clearInterval() {},
};
const drawn = [];
const viewer = {
resize() {},
render: () => drawn.push('render'),
scene: {
renderError: {
addEventListener: (fn) => ((listener = fn), () => (listener = null)),
},
},
};
const logged = [];
const original = console.error;
console.error = (...args) => logged.push(args.join(' '));
try {
let clock = 0;
const stop = keepPanelRendering(viewer, { windowRef, now: () => clock });
listener(viewer.scene, { name: 'RuntimeError', message: 'worker failed' });
assert.match(logged[0], /render error: .*"message":"worker failed"/);
clock = 1000;
tick();
assert.deepEqual(drawn, []);
stop();
assert.equal(listener, null);
} finally {
console.error = original;
}
});
test('a followed aircraft owns the camera, and cockpit view is entered after it', async () => {
const viewer = fakeViewer();
const { calls, run } = recorder();
const steps = await applyView(
createView({
camera: { lat: 32.7, lon: -117.2 },
follow: { kind: 'military_aircraft', id: 'AE1234', cockpit: true },
}),
{ viewer, dataManager: fakeLayers(new Set()), run, retryMs: 0 },
);
// Following worked at once, so no camera flight ended it.
assert.equal(viewer.flights.length, 0);
assert.deepEqual(calls.slice(-2), [
['track_entity', { query: 'ae1234', layerId: 'military' }],
['control_cockpit', { action: 'enter', targetLayer: 'military' }],
]);
assert.ok(steps.every((step) => step.ok));
assert.throws(
() =>
createView({
camera: { lat: 0, lon: 0 },
follow: { kind: 'satellite', id: '25544', cockpit: true },
}),
/no cockpit view/,
);
});
-661
View File
@@ -1,661 +0,0 @@
/**
* The God's Eye View panel's script, run inside the panel page a host shows.
* It is sent as source text (see src/tools/globePanel.js), so it must not use
* imports or anything outside the function.
*
* The page lives on the host's site, and hosts may refuse other addresses,
* such as a server on the user's machine. So the panel never requests the
* app's server itself: every request for the app's own paths goes to the
* panel's MCP server as a call to `config.toolName`, which requests the path
* from the app's server. The app's built page, scripts, styles, images and
* data all arrive that way; map imagery from providers loads directly.
*/
export function panelRuntime(config) {
const status = document.getElementById('status');
const open = document.getElementById('open');
const expand = document.getElementById('expand');
const say = (text) => {
if (status.isConnected) status.textContent = text;
};
let blocked = null;
document.addEventListener('securitypolicyviolation', (event) => {
blocked = `${event.effectiveDirective} blocked ${event.blockedURI || 'a resource'}`;
});
// JSON-RPC with the host.
let nextId = 1;
const pending = new Map();
const send = (message) =>
window.parent.postMessage({ jsonrpc: '2.0', ...message }, '*');
const request = (method, params) =>
new Promise((resolve, reject) => {
const id = nextId++;
pending.set(id, { resolve, reject });
send({ id, method, params });
});
const notify = (method, params) => send({ method, params });
// Requests to the app's server, through the MCP server.
const MAX_CALLS = 6;
let calls = 0;
const waiting = [];
async function callTool(args) {
if (calls >= MAX_CALLS) await new Promise((go) => waiting.push(go));
calls += 1;
try {
const result = await request('tools/call', {
name: config.toolName,
arguments: args,
});
if (result?.isError || !result?.structuredContent)
throw new Error(result?.content?.[0]?.text || 'The request failed');
return result.structuredContent;
} finally {
calls -= 1;
waiting.shift()?.();
}
}
const toBase64 = (bytes) => {
let binary = '';
for (let start = 0; start < bytes.length; start += 0x8000)
binary += String.fromCharCode(...bytes.subarray(start, start + 0x8000));
return btoa(binary);
};
const fromBase64 = (text) =>
Uint8Array.from(atob(text), (char) => char.charCodeAt(0));
async function bodyBytes(body) {
if (body == null) return null;
if (typeof body === 'string') return new TextEncoder().encode(body);
if (body instanceof ArrayBuffer) return new Uint8Array(body);
if (ArrayBuffer.isView(body))
return new Uint8Array(body.buffer, body.byteOffset, body.byteLength);
return new Uint8Array(await new Response(body).arrayBuffer());
}
const NULL_BODY = new Set([101, 204, 205, 304]);
const held = new Map();
/** Request a path on the app's server; resolves to a Response. */
function appFetch(path, init = {}) {
const method = (init.method || 'GET').toUpperCase();
// Built files never change while the panel runs, so each loads once.
const shared = method === 'GET' && path.startsWith(config.panelBase);
if (shared && held.has(path)) return held.get(path).then(respond);
const loading = (async () => {
const headers = {};
new Headers(init.headers || {}).forEach((value, name) => {
headers[name] = value;
});
const bytes = await bodyBytes(init.body);
let part = await callTool({
path,
method,
headers,
...(bytes ? { body: toBase64(bytes) } : {}),
});
const parts = [fromBase64(part.body)];
while (part.nextOffset !== undefined) {
if (init.signal?.aborted) break;
part = await callTool({ id: part.id, offset: part.nextOffset });
parts.push(fromBase64(part.body));
}
let body = new Blob(parts);
if (part.encoding === 'gzip')
body = await new Response(
body.stream().pipeThrough(new DecompressionStream('gzip')),
).blob();
return {
body: await body.arrayBuffer(),
status: part.status,
statusText: part.statusText,
headers: part.headers,
};
})();
if (shared) {
held.set(path, loading);
loading.catch(() => held.delete(path));
}
const result = loading.then(respond);
if (!init.signal) return result;
return Promise.race([
result,
new Promise((_, reject) => {
const abort = () =>
reject(new DOMException('The request was aborted', 'AbortError'));
if (init.signal.aborted) abort();
init.signal.addEventListener('abort', abort, { once: true });
}),
]);
}
function respond({ body, status, statusText, headers }) {
return new Response(NULL_BODY.has(status) ? null : body.slice(0), {
status,
statusText,
headers,
});
}
// The app's own paths: relative ones, absolute ones on this page's site
// (matched by scheme and host, since a host may use its own scheme), and
// ones under the app's address in the page, an https address for code
// that needs one (`config.appBaseUrl`).
const page = new URL(document.baseURI);
const appOrigin = new URL(config.appBaseUrl).origin;
function appPath(value) {
let url;
try {
url = new URL(String(value), document.baseURI);
} catch {
return null;
}
const own =
url.origin === appOrigin ||
(url.protocol === page.protocol && url.host === page.host);
return own ? url.pathname + url.search : null;
}
const fileUrls = new Map();
/**
* A data: URL holding an app file, for elements that load by URL. Hosts
* allow data: images where some refuse blob: ones. Files of the panel
* build never change and are kept; others, such as live camera frames
* that change address on every refresh, are not.
*/
function appFileUrl(path) {
if (fileUrls.has(path)) return fileUrls.get(path);
const loading = appFetch(path)
.then((response) => response.blob())
.then(
(blob) =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob);
}),
);
if (path.startsWith(config.panelBase)) {
fileUrls.set(path, loading);
loading.catch(() => fileUrls.delete(path));
}
return loading;
}
function installRequests() {
const nativeFetch = window.fetch.bind(window);
window.fetch = async (input, init = {}) => {
const isRequest = input instanceof Request;
const path = appPath(isRequest ? input.url : input);
if (path === null) return nativeFetch(input, init);
if (!isRequest) return appFetch(path, init);
return appFetch(path, {
method: init.method || input.method,
headers: init.headers || input.headers,
body:
init.body ??
(['GET', 'HEAD'].includes(input.method)
? undefined
: await input.arrayBuffer()),
signal: init.signal || input.signal,
});
};
// XMLHttpRequest for app paths: answered from appFetch, with the
// instance's state replaced, and the usual events dispatched.
const xhr = XMLHttpRequest.prototype;
const nativeOpen = xhr.open;
const nativeSend = xhr.send;
const nativeHeader = xhr.setRequestHeader;
const nativeAbort = xhr.abort;
xhr.open = function (method, url, ...rest) {
const path = appPath(url);
if (path === null) {
this.__gev = null;
return nativeOpen.call(this, method, url, ...rest);
}
this.__gev = { method, path, headers: {}, controller: null };
setState(this, { readyState: 1 });
this.dispatchEvent(new Event('readystatechange'));
};
xhr.setRequestHeader = function (name, value) {
if (!this.__gev) return nativeHeader.call(this, name, value);
this.__gev.headers[name] = value;
};
xhr.abort = function () {
if (!this.__gev) return nativeAbort.call(this);
this.__gev.controller?.abort();
};
xhr.send = function (body) {
const state = this.__gev;
if (!state) return nativeSend.call(this, body);
state.controller = new AbortController();
appFetch(state.path, {
method: state.method,
headers: state.headers,
body,
signal: state.controller.signal,
})
.then(async (response) => {
const type = this.responseType;
const value =
type === 'arraybuffer'
? await response.arrayBuffer()
: type === 'blob'
? await response.blob()
: await response.text();
const text = typeof value === 'string' ? value : null;
let parsed = value;
if (type === 'json') {
try {
parsed = JSON.parse(text);
} catch {
parsed = null;
}
} else if (type === 'document')
parsed = new DOMParser().parseFromString(text, 'text/html');
const headers = [...response.headers]
.map(([name, val]) => `${name}: ${val}`)
.join('\r\n');
setState(this, {
readyState: 4,
status: response.status,
statusText: response.statusText,
response: parsed,
responseText: type === '' || type === 'text' ? text : '',
responseURL: new URL(state.path, document.baseURI).href,
getAllResponseHeaders: () => headers,
getResponseHeader: (name) => response.headers.get(name),
});
for (const name of ['readystatechange', 'load', 'loadend'])
this.dispatchEvent(
name === 'readystatechange'
? new Event(name)
: new ProgressEvent(name),
);
})
.catch((error) => {
setState(this, { readyState: 4, status: 0 });
const name = error?.name === 'AbortError' ? 'abort' : 'error';
for (const event of ['readystatechange', name, 'loadend'])
this.dispatchEvent(
event === 'readystatechange'
? new Event(event)
: new ProgressEvent(event),
);
});
};
function setState(target, values) {
for (const [name, value] of Object.entries(values))
Object.defineProperty(target, name, {
configurable: true,
...(typeof value === 'function' ? { value } : { get: () => value }),
});
}
const nativeBeacon = navigator.sendBeacon?.bind(navigator);
if (nativeBeacon)
navigator.sendBeacon = (url, data) => {
const path = appPath(url);
if (path === null) return nativeBeacon(url, data);
appFetch(path, { method: 'POST', body: data }).catch(() => {});
return true;
};
// Images name app files by URL; give them the file as a data: URL.
const image = Object.getOwnPropertyDescriptor(
HTMLImageElement.prototype,
'src',
);
Object.defineProperty(HTMLImageElement.prototype, 'src', {
...image,
set(value) {
const path = appPath(value);
if (path === null) return image.set.call(this, value);
appFileUrl(path).then(
(url) => image.set.call(this, url),
() => this.dispatchEvent(new Event('error')),
);
},
});
// Workers on the app's server start from a small worker here that is
// handed the script once it has loaded; messages wait until then.
const NativeWorker = window.Worker;
const BOOT =
'const early=[];let ready=false;' +
"addEventListener('message',e=>{if(e.data&&e.data.__gevScript){" +
'const go=()=>{ready=true;for(const d of early)dispatchEvent(new MessageEvent("message",{data:d}))};' +
'if(e.data.module)import(e.data.__gevScript).then(go);else{importScripts(e.data.__gevScript);go()}' +
'e.stopImmediatePropagation();return}' +
'if(!ready){early.push(e.data);e.stopImmediatePropagation()}});';
window.Worker = function Worker(url, options) {
const path = appPath(url);
if (path === null || String(url).startsWith('blob:'))
return new NativeWorker(url, options);
const worker = new NativeWorker(
URL.createObjectURL(new Blob([BOOT], { type: 'text/javascript' })),
options,
);
appFetch(path)
.then((response) => response.blob())
.then((blob) => URL.createObjectURL(blob))
.then((script) =>
worker.postMessage({
__gevScript: script,
module: options?.type === 'module',
}),
);
return worker;
};
window.Worker.prototype = NativeWorker.prototype;
// Some hosts drop the GPU contents of a panel's 2D canvases while they
// consider the panel off screen, and the overlays then cover the globe
// in black. Keep 2D canvases in memory instead, which hosts do not drop.
const getContext = HTMLCanvasElement.prototype.getContext;
HTMLCanvasElement.prototype.getContext = function (type, options) {
return type === '2d'
? getContext.call(this, type, { ...options, willReadFrequently: true })
: getContext.call(this, type, options);
};
// Markup the app adds as HTML text would request its images from this
// page's site at once; hold their addresses until the files are here.
const htmlWithHeldImages = (html) =>
String(html).replace(
/(<(?:img|source)\b[^>]*?\s)src=(["'])([^"']*)\2/gi,
(tag, before, quote, value) =>
appPath(value) === null
? tag
: `${before}data-gev-src=${quote}${value}${quote}`,
);
const fillHeldImages = (root) => {
for (const element of root?.querySelectorAll?.('[data-gev-src]') ?? []) {
const path = appPath(element.getAttribute('data-gev-src'));
element.removeAttribute('data-gev-src');
appFileUrl(path).then((url) => element.setAttribute('src', url));
}
};
const innerHtml = Object.getOwnPropertyDescriptor(
Element.prototype,
'innerHTML',
);
Object.defineProperty(Element.prototype, 'innerHTML', {
...innerHtml,
set(value) {
innerHtml.set.call(this, htmlWithHeldImages(value));
fillHeldImages(this);
},
});
const insertHtml = Element.prototype.insertAdjacentHTML;
Element.prototype.insertAdjacentHTML = function (position, html) {
insertHtml.call(this, position, htmlWithHeldImages(html));
fillHeldImages(this.parentNode ?? this);
};
// Markup and styles the app adds later name app files too.
new MutationObserver((records) => {
for (const record of records)
for (const node of record.addedNodes) {
if (node.nodeType !== 1) continue;
if (node.nodeName === 'STYLE') void inlineStyleUrls(node);
for (const element of [node, ...node.querySelectorAll('img[src]')]) {
if (element.nodeName !== 'IMG') continue;
const path = appPath(element.getAttribute('src'));
if (path !== null)
appFileUrl(path).then((url) => element.setAttribute('src', url));
}
}
}).observe(document.documentElement, { childList: true, subtree: true });
}
/** Replace url() references to app files in CSS with data: URLs. */
async function cssWithFiles(text, baseUrl) {
const found = new Map();
for (const match of text.matchAll(/url\(\s*(['"]?)([^'")]+)\1\s*\)/g)) {
const reference = match[2];
if (/^(data|blob):/.test(reference)) continue;
const path = appPath(new URL(reference, baseUrl).href);
if (path !== null && !found.has(reference))
found.set(
reference,
appFileUrl(path).catch(() => null),
);
}
let result = text;
for (const [reference, loading] of found) {
const url = await loading;
if (url) result = result.split(reference).join(url);
}
return result;
}
async function inlineStyleUrls(style) {
if (!/url\(/.test(style.textContent)) return;
style.textContent = await cssWithFiles(style.textContent, document.baseURI);
}
async function addStylesheet(href) {
const path = appPath(href);
const style = document.createElement('style');
document.head.appendChild(style);
if (path === null) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = href;
style.replaceWith(link);
return;
}
const text = await (await appFetch(path)).text();
style.textContent = await cssWithFiles(
text,
new URL(path, document.baseURI).href,
);
}
function runScript(text, type) {
const script = document.createElement('script');
if (type) script.type = type;
script.textContent = text;
document.body.appendChild(script);
}
/**
* Load God's Eye View into this page from the app's panel build, in
* inline embed mode, starting at the first view as its link would.
*/
async function startApp(url, view) {
window.GEV_EMBED_INLINE = true;
window.GEV_APP_BASE_URL = config.appBaseUrl;
try {
history.replaceState(
null,
'',
location.href.split('#')[0] + new URL(url).hash,
);
// The app restores this view from its link, following included, so
// it needs applying again only for what links cannot carry: cockpit.
if (queued === view && !view.follow?.cockpit) queued = null;
} catch {
// The view still arrives once the app is ready.
}
installRequests();
const response = await appFetch(config.panelBase);
if (!response.ok)
throw new Error(
response.status === 404
? 'the app has no panel build (npm run build:panel)'
: `the app answered ${response.status}`,
);
const built = new DOMParser().parseFromString(
await response.text(),
'text/html',
);
window.CESIUM_BASE_URL = `${config.panelBase}cesium/`;
const [prelude] = await Promise.all([
appFetch(config.panelBase + config.workerPreludePath).then((r) =>
r.text(),
),
...[...built.head.querySelectorAll('link[rel="stylesheet"]')].map(
(link) => addStylesheet(link.getAttribute('href')),
),
]);
// Cesium's script sets CESIUM_WORKERS, its workers as one script it
// starts from memory. Workers request files themselves, which this page
// cannot answer, so the prelude, holding those files, runs ahead of it.
let workers;
Object.defineProperty(window, 'CESIUM_WORKERS', {
configurable: true,
get: () => workers,
set: (script) => {
workers = prelude + script;
},
});
// Images in the markup load their files once added; give them the
// files instead of letting them request this page's site.
const images = [];
for (const image of built.body.querySelectorAll('img[src]')) {
const path = appPath(image.getAttribute('src'));
if (path === null) continue;
image.removeAttribute('src');
images.push([image, path]);
}
for (const node of [...built.body.childNodes]) {
if (node.nodeName !== 'SCRIPT')
document.body.insertBefore(document.adoptNode(node), status);
}
for (const [image, path] of images)
appFileUrl(path).then((url) => image.setAttribute('src', url));
const scripts = await Promise.all(
[...built.querySelectorAll('script')].map(async (script) => ({
type: script.type,
text: script.getAttribute('src')
? await (await appFetch(appPath(script.getAttribute('src')))).text()
: script.textContent,
})),
);
for (const { type, text } of scripts) runScript(text, type);
// Browsers cap live 3D contexts across pages and take one back when
// there are too many; say so rather than show no map.
document.addEventListener(
'webglcontextlost',
() => {
const notice = document.createElement('div');
notice.id = 'status';
notice.textContent =
"This panel lost its 3D graphics, likely because other God's Eye " +
"View panels in this conversation hold them. Use Open in God's " +
'Eye View above, or show it in a new conversation.';
document.body.appendChild(notice);
},
{ capture: true, once: true },
);
}
// Tool results: the first view starts the app, later ones move it.
let started = false;
let ready = false;
let queued = null;
let currentUrl = null;
const postView = (view) =>
window.postMessage({ type: 'gev:view', id: nextId++, view }, '*');
function show(result) {
const data = result?.structuredContent;
const view = data?.view;
const url = data?.url || view?.url;
if (!view || !url) return;
currentUrl = url;
open.hidden = false;
if (ready) return postView(view);
queued = view;
if (started) return;
started = true;
say("Loading God's Eye View…");
startApp(url, view).catch((error) =>
say(`God's Eye View could not load here: ${error?.message || error}.`),
);
setTimeout(() => {
if (!ready)
say(
"God's Eye View did not load here" +
(blocked ? ` (${blocked})` : '') +
". Use Open in God's Eye View above.",
);
}, config.loadTimeoutMs);
}
window.addEventListener('message', (event) => {
const message = event.data;
if (event.source === window) {
if (message?.type === 'gev:ready') {
ready = true;
status.remove();
if (queued) postView(queued);
queued = null;
}
return;
}
if (event.source !== window.parent || message?.jsonrpc !== '2.0') return;
if (
message.id !== undefined &&
pending.has(message.id) &&
!message.method
) {
const { resolve, reject } = pending.get(message.id);
pending.delete(message.id);
if (message.error) reject(message.error);
else resolve(message.result);
return;
}
if (message.method === 'ui/notifications/tool-result') show(message.params);
if (message.method === 'ui/notifications/host-context-changed')
showDisplayMode(message.params);
});
// Fullscreen, where the host offers it, gives the globe the room.
let displayMode = 'inline';
const showDisplayMode = (context) => {
if (context?.displayMode) displayMode = context.displayMode;
if (context?.availableDisplayModes)
expand.hidden = !context.availableDisplayModes.includes('fullscreen');
expand.textContent = displayMode === 'fullscreen' ? 'Collapse' : 'Expand';
};
expand.addEventListener('click', () => {
request('ui/request-display-mode', {
mode: displayMode === 'fullscreen' ? 'inline' : 'fullscreen',
}).then(
(result) => showDisplayMode({ displayMode: result?.mode }),
() => {},
);
});
open.addEventListener('click', () => {
if (!currentUrl) return;
request('ui/open-link', { url: currentUrl }).catch(() =>
window.open(currentUrl, '_blank', 'noopener'),
);
});
// The handshake the MCP Apps SDK's App.connect performs: hosts validate
// these parameters, and keep a view hidden until it is initialized.
request('ui/initialize', {
appCapabilities: { availableDisplayModes: ['inline', 'fullscreen'] },
appInfo: {
name: 'gods-eye-view',
title: "God's Eye View",
version: '1.0.0',
},
protocolVersion: config.protocolVersion,
}).then(
(result) => {
showDisplayMode(result?.hostContext);
send({ method: 'ui/notifications/initialized' });
notify('ui/notifications/size-changed', {
width: document.body.clientWidth,
height: config.panelHeight,
});
},
(error) =>
say(
"This client did not accept the God's Eye View panel" +
(error?.message ? `: ${error.message}` : '.'),
),
);
}
+1 -13
View File
@@ -4,7 +4,6 @@ import { initDrawTool } from '../annotations/drawTool.js';
import { initImageryBoxTool } from '../ui/imageryBoxTool.js';
import { createRecentImageryPanel } from '../ui/recentImagery.js';
import { initGevVoiceCommands } from '../voice/gevRealtime.js';
import { installViews, isEmbeddedInline } from './embed.js';
import { installScopeMask, destroyScopeMask } from '../scopeMask.js';
import {
installRenderGovernor,
@@ -119,9 +118,7 @@ export function createApplicationTools({
// GPU. Holder/data state is untouched, so return is seamless: restore
// the loop, refresh the one DOM surface we gated, render a frame.
const syncVisibilitySuspension = () => {
// A panel's host may report it hidden while it is on screen; the panel
// keeps drawing itself (see keepPanelRendering in embed.js).
const hidden = document.hidden && !isEmbeddedInline();
const hidden = document.hidden;
viewer.useDefaultRenderLoop = !hidden;
cockpitCloudEffects?.setSuspended?.(hidden);
if (!hidden) {
@@ -179,14 +176,5 @@ export function createApplicationTools({
delete window.__gevVoiceCommands;
});
debug.voiceCommands = voiceCommands;
defer(
installViews({
shell: styleManager,
viewer,
dataManager,
run: (name, args) => voiceCommands.runner(name, args, { signal }),
signal,
}),
);
return { sceneDirector, annotations, voiceCommands };
}
-3
View File
@@ -5,9 +5,6 @@ import { createAisStreamSource } from '../sources/live/standalone.js';
const aisLiveVesselsLayer = createApplicationVessels({
source: createAisStreamSource({
apiUrl: import.meta.env?.VITE_AIS_LIVE_API_URL || '/api/ais-live',
// Resolved against the document's address, which a panel host may serve
// from its own scheme.
origin: () => globalThis.document?.baseURI ?? 'http://localhost',
}),
options: {
maxRows: import.meta.env?.VITE_AIS_LIVE_MAX_ROWS,
-25
View File
@@ -128,14 +128,6 @@ test('the catalog applies the fair cap to configured packs end to end', async ()
sources.slice(0, 3).map((s) => s.id),
['env-0', 'env-1', 'env-2'],
);
assert.equal(sources[0].pack, 'env');
const [trimmed] = getSources.trimmedPacks();
assert.deepEqual(
{ ...trimmed, region: undefined },
{ pack: 'env', available: 12, served: 9, region: undefined },
);
assert.equal(trimmed.region.south, 30);
assert.ok(Math.abs(trimmed.region.north - 30.11) < 1e-9);
} finally {
for (const key of Object.keys(process.env)) {
if (!(key in saved)) delete process.env[key];
@@ -143,20 +135,3 @@ test('the catalog applies the fair cap to configured packs end to end', async ()
Object.assign(process.env, saved);
}
});
test('a pack trimmed to its nearest cameras reports how many it offered', async () => {
const { prioritizeSources } =
await import('../../server/providers/cctv/normalize.js');
const cameras = Array.from({ length: 5 }, (_, i) => ({
id: `c${i}`,
lat: i,
lon: 0,
}));
const kept = prioritizeSources(cameras, 2, [{ lat: 0, lon: 0 }]);
assert.deepEqual(
kept.map((camera) => camera.id),
['c0', 'c1'],
);
assert.equal(kept.available, 5);
assert.deepEqual(kept.region, { west: 0, south: 0, east: 0, north: 4 });
});
+4
View File
@@ -271,6 +271,10 @@ export const DATA_CREDITS = [
'<a href="https://terrain.reearth.land" target="_blank" rel="noopener">Re:Earth Terrain</a> / ' +
'Mapterhorn (CC BY 4.0) / EGM2008 (NGA)',
},
{
key: 'rainviewer',
html: 'Global rain radar: <a href="https://www.rainviewer.com" target="_blank" rel="noopener">RainViewer</a>',
},
{
key: 'weather-noaa',
html: 'Observed weather: <a href="https://nowcoast.noaa.gov/" target="_blank" rel="noopener">NOAA nowCOAST</a> · NWS/OAR MRMS radar; NESDIS GOES and global satellite partners. <a href="https://oceanservice.noaa.gov/disclaimer.html" target="_blank" rel="noopener">Source disclaimer</a>.',
+7 -3
View File
@@ -1,8 +1,12 @@
import { createLocalGeoJsonLayer } from './localGeojsonCore.js';
import { INFRASTRUCTURE_DATA_URLS } from '../sources/infrastructureData.js';
const datacentersUrl = INFRASTRUCTURE_DATA_URLS['local-datacenters'];
const damsUrl = INFRASTRUCTURE_DATA_URLS['local-dams'];
// Resolved by Vite in builds and relative to this module in other consumers.
const datacentersUrl = new URL(
'./local_data/datacenters/datacenters.geojsonl',
import.meta.url,
).href;
const damsUrl = new URL('./local_data/dams/dams.geojsonl', import.meta.url)
.href;
/**
* Create fresh datacenter and dam layers without starting or loading them.
-1
View File
@@ -192,7 +192,6 @@ test('consumer build includes only infrastructure code and resolves assets under
const sources = Object.keys(entry.modules).filter((id) => id.endsWith('.js'));
assert.deepEqual(sources.map((id) => id.split('/').at(-1)).sort(), [
'infrastructure.js',
'infrastructureData.js',
'infrastructureOverlayEntry.js',
'localGeojsonCore.js',
'localGeojsonLod.js',
+727 -2
View File
@@ -11,6 +11,8 @@ export const LAYER_STATE_TOKEN_ALPHABET =
'0123456789abcdefghijklmnopqrstuvwxyz';
const LAYER_STATE_TOKEN_PATTERN = /^[a-z0-9]{1,2}$/;
const NEW_SINGLE_CHARACTER_TOKEN_PATTERN = /^[0-9]$/;
/** Re-check cadence while a shared subject waits for its feed row to arrive. */
const PENDING_TRACKING_POLL_MS = 1_000;
/**
* A tracking ID is a transponder address, not free text: 6 hex digits for an
* ICAO24, with slack for TIS-B (`~abc123`) and similar prefixed forms. Bounding
@@ -310,6 +312,10 @@ const OPTION_GROUPS = Object.freeze({
}),
]),
'weather-radar': Object.freeze([
enumOption('product', 'p', 'radar', ['radar', 'radar-global'], {
radar: 'r',
'radar-global': 'g',
}),
enumOption('opacity', 'o', 'strong', ['light', 'strong'], {
light: 'l',
strong: 's',
@@ -431,6 +437,12 @@ const OPTION_GROUPS = Object.freeze({
]),
});
const TRACKING_OPTION_KEY_BY_LAYER = Object.freeze({
flights: 'selectedFlightsTrackingId',
military: 'selectedMilitaryTrackingId',
satellites: 'selectedSatTrackingId',
});
export const SHARE_TRACKING_RESTORE_POLICIES = Object.freeze({
flights: Object.freeze({
optionOwner: 'flights',
@@ -732,7 +744,7 @@ export function validateLayerStateAllocations(
return true;
}
export const REGISTRY_BY_ID = new Map(
const REGISTRY_BY_ID = new Map(
LAYER_STATE_REGISTRY.map((entry) => [entry.id, entry]),
);
const REGISTRY_BY_TOKEN = new Map(
@@ -744,7 +756,7 @@ const OPTION_OWNER_IDS = Object.freeze([
),
]);
export function optionSpecs(ownerId) {
function optionSpecs(ownerId) {
return OPTION_GROUPS[ownerId] || [];
}
@@ -1047,3 +1059,716 @@ export function layerOptionsForRestore(state, layerId) {
if (!entry?.optionOwner) return null;
return { ...normalizeLayerState(state).options[entry.optionOwner] };
}
function safeStorage() {
try {
return globalThis.localStorage || null;
} catch {
return null;
}
}
function currentLayerOutcome(dataManager, layerId) {
const state = dataManager.getLayerLifecycleState?.(layerId);
return {
settledEnabled: Boolean(state?.enabled),
lifecycleState: state?.lifecycleState || 'missing',
lifecycleUncertain: Boolean(state?.uncertain),
};
}
/**
* Owns durable user layer preferences independently from transient runtime
* choreography, and coordinates passive post-registration restoration.
*/
export class LayerStateCoordinator {
constructor(
dataManager,
shareLinkManager,
{
storage = safeStorage(),
restoreGate = null,
onDurableStateChange = null,
onTrackingRestoreStatus = null,
now = () => Date.now(),
// Injectable so the pending-window behavior is deterministically testable
// without sleeping out a 90 s expiry.
setTimer = (fn, ms) => setTimeout(fn, ms),
clearTimer = (handle) => clearTimeout(handle),
} = {},
) {
if (!dataManager?.registrationsFinalized) {
throw new Error(
'Layer state requires finalized data-layer registrations',
);
}
this.dataManager = dataManager;
this.shareLinkManager = shareLinkManager || null;
this.storage = storage;
this.restoreGate = restoreGate;
this.onDurableStateChange = onDurableStateChange;
this.onTrackingRestoreStatus = onTrackingRestoreStatus;
this.now = now;
this.setTimer = setTimer;
this.clearTimer = clearTimer;
this._durableState = createDefaultLayerState();
this._source = 'defaults';
this._destroyed = false;
this._restoreControllers = new Map();
this._shareCreatedAtMs = null;
this._trackingRestoreController = null;
this._trackingRestoreGeneration = 0;
this._pendingTrackingTimer = null;
this._pendingTrackingContext = null;
this._unsubscribe = this.dataManager.subscribe((change) =>
this._handleManagerChange(change),
);
this._unsubscribeVisibilityRequests =
this.dataManager.subscribeVisibilityRequests((change) =>
this._handleVisibilityRequest(change),
);
this.restorePromise = Promise.resolve([]);
this.lastRestoreResults = [];
}
start({
shareLayerState = null,
allowLocalState = true,
shareCreatedAtMs = null,
} = {}) {
if (this._destroyed)
throw new Error('Layer-state coordinator is destroyed');
let selected = shareLayerState
? normalizeLayerState(shareLayerState)
: null;
if (selected) {
this._source = 'share';
this._shareCreatedAtMs = Number.isFinite(shareCreatedAtMs)
? shareCreatedAtMs
: null;
} else if (allowLocalState) {
let stored = null;
try {
stored = parseStoredLayerState(
this.storage?.getItem?.(LAYER_STATE_STORAGE_KEY),
);
} catch {
/* best effort */
}
if (stored) {
selected = stored;
this._source = 'local';
}
} else {
// A valid historical camera/style share with no v2 layer payload keeps
// the exact legacy default-layer behavior. It must not inherit an
// unrelated recipient's saved local layer preferences.
this._source = 'legacy-share';
}
this._durableState = selected || createDefaultLayerState();
this.shareLinkManager?.setLayerStateProvider?.(() =>
this.getDurableState(),
);
this.shareLinkManager?.onLayerStateChange?.();
this._notifyDurableState();
if (!selected) return this.restorePromise;
this.restorePromise = this._restoreSelectedState(
this._source === 'share'
? LAYER_RESTORE_ORIGINS.share
: LAYER_RESTORE_ORIGINS.local,
);
return this.restorePromise;
}
get source() {
return this._source;
}
getDurableState() {
return cloneLayerState(this._durableState);
}
_notifyDurableState() {
try {
this.onDurableStateChange?.(this.getDurableState());
} catch {
/* UI sync is best effort */
}
}
_handleVisibilityRequest(change) {
if (!isExplicitLayerStateOrigin(change?.origin)) return;
this._restoreControllers
.get(change.layerId)
?.abort('superseded-by-explicit-visibility');
if (SHARE_TRACKING_RESTORE_POLICIES[change.layerId]) {
this._revokePendingTrackingWatch('superseded-by-explicit-visibility');
}
}
/** Revoke every passive restore before explicit navigation can be reclaimed. */
cancelPendingRestores(reason = 'superseded-by-explicit-navigation') {
for (const controller of this._restoreControllers.values())
controller.abort(reason);
this._revokePendingTrackingWatch(reason);
}
/**
* Revoke a pending shared Follow. Physical navigation may also clear only
* the exact passive selection, without writing recipient preferences.
*/
cancelPendingShareTracking(
reason = 'superseded-by-explicit-navigation',
{ clearSelection = false } = {},
) {
this._revokePendingTrackingWatch(reason);
if (!clearSelection) return false;
const selected = this._selectedShareTrackingTarget();
return selected ? this._passivelyClearTrackingSelection(selected) : false;
}
_handleManagerChange(change) {
if (!change || this._destroyed) return;
// Parameter and visibility ownership are independent. A newer explicit
// option request may replace passive share options, but it must not abort
// the same layer's visibility lifecycle.
if (change.type === 'params-requested') {
if (
isExplicitLayerStateOrigin(change.origin) &&
SHARE_TRACKING_RESTORE_POLICIES[change.layerId]
) {
this._revokePendingTrackingWatch('superseded-by-explicit-params');
}
return;
}
// A layer that goes away takes its latch with it, at ANY origin — a
// programmatic disable or teardown never reaches the explicit-intent path
// below, so revoke here before that early return.
if (
change.type === 'visibility' &&
change.enabled === false &&
SHARE_TRACKING_RESTORE_POLICIES[change.layerId]
) {
this._revokePendingTrackingWatch('owner-layer-disabled');
}
if (!isExplicitLayerStateOrigin(change.origin)) return;
if (change.type === 'visibility') {
this._restoreControllers
.get(change.layerId)
?.abort('superseded-by-explicit-visibility');
if (SHARE_TRACKING_RESTORE_POLICIES[change.layerId]) {
this._revokePendingTrackingWatch('superseded-by-explicit-visibility');
}
const enabled = new Set(this._durableState.enabledLayerIds);
if (change.enabled) enabled.add(change.layerId);
else enabled.delete(change.layerId);
this._commitExplicit({
...this._durableState,
enabledLayerIds: [...enabled],
});
return;
}
if (change.type !== 'params') return;
const entry = REGISTRY_BY_ID.get(change.layerId);
if (!entry?.optionOwner) return;
const ownerId = entry.optionOwner;
const nextOwnerOptions = { ...this._durableState.options[ownerId] };
const requestedParams = change.requestedParams || {};
const trackingOptionKey =
TRACKING_OPTION_KEY_BY_LAYER[change.layerId] || null;
let changed = false;
for (const spec of optionSpecs(ownerId)) {
// Only persist keys present in this explicit request. getLayerParams()
// can return a wider live snapshot containing transient or passively
// changed values that this user action did not own. Tracking is the one
// exception: an unrelated explicit option cancels a pending restoration
// in that family, so its wider live value (active ID or null) must replace
// the formerly durable pending ID instead of allowing reload resurrection.
const explicitlyRequested = Object.hasOwn(requestedParams, spec.key);
const implicitTrackingSync =
!explicitlyRequested && spec.key === trackingOptionKey;
if (!explicitlyRequested && !implicitTrackingSync) continue;
const value = spec.normalize(change.params[spec.key]);
if (value === null) {
if (implicitTrackingSync) {
nextOwnerOptions[spec.key] = null;
changed = true;
continue;
}
if (spec.defaultValue !== null || change.params[spec.key] !== null)
continue;
}
nextOwnerOptions[spec.key] = value;
changed = true;
}
if (!changed) return;
this._commitExplicit({
...this._durableState,
options: { ...this._durableState.options, [ownerId]: nextOwnerOptions },
});
}
_commitExplicit(candidate) {
this._durableState = normalizeLayerState(candidate);
const serialized = serializeStoredLayerState(this._durableState);
try {
if (this.storage?.getItem?.(LAYER_STATE_STORAGE_KEY) !== serialized) {
this.storage?.setItem?.(LAYER_STATE_STORAGE_KEY, serialized);
}
} catch {
/* storage can be unavailable or quota-limited */
}
this.shareLinkManager?.onLayerStateChange?.();
this._notifyDurableState();
}
async _waitForRestoreGate() {
if (!this.restoreGate) return;
await (typeof this.restoreGate === 'function'
? this.restoreGate()
: this.restoreGate);
}
async _restoreSelectedState(origin) {
for (const entry of LAYER_STATE_REGISTRY) {
this._restoreControllers.set(entry.id, new AbortController());
}
try {
await this._waitForRestoreGate();
const enabled = new Set(this._durableState.enabledLayerIds);
const settled = await Promise.allSettled(
LAYER_STATE_REGISTRY.map(async (entry) => {
const controller = this._restoreControllers.get(entry.id);
const targetEnabled = enabled.has(entry.id);
const options = layerOptionsForRestore(this._durableState, entry.id);
if (origin === LAYER_RESTORE_ORIGINS.share && options) {
for (const trackingKey of Object.values(
TRACKING_OPTION_KEY_BY_LAYER,
)) {
delete options[trackingKey];
}
}
if (this._destroyed || controller?.signal.aborted) {
return {
layerId: entry.id,
targetEnabled,
origin,
phase: 'reserved',
...currentLayerOutcome(this.dataManager, entry.id),
appliedOptions: {},
cancellationReason: this._destroyed ? 'destroyed' : 'superseded',
errorClass: 'cancelled',
persistenceWrite: false,
succeeded: false,
};
}
// Reserve passive option state before any asynchronous lifecycle work.
// A later explicit params intent then wins on its own lane without
// cancelling or being overwritten by the visibility restore.
const paramsSucceeded =
!options ||
Object.keys(options).length === 0 ||
this.dataManager.setLayerParams(entry.id, options, { origin });
return this.dataManager
.restoreLayerState(
entry.id,
{
enabled: targetEnabled,
params: null,
},
{ origin, signal: controller.signal },
)
.then((result) => ({
...result,
appliedOptions: paramsSucceeded && options ? options : {},
errorClass: paramsSucceeded
? result.errorClass
: 'ParamsRejected',
succeeded: paramsSucceeded && result.succeeded,
}));
}),
);
this.lastRestoreResults = settled.map((result, index) => {
if (result.status === 'fulfilled') return result.value;
const entry = LAYER_STATE_REGISTRY[index];
return {
layerId: entry.id,
targetEnabled: enabled.has(entry.id),
origin,
phase: 'coordinator',
...currentLayerOutcome(this.dataManager, entry.id),
appliedOptions: {},
cancellationReason: null,
errorClass: result.reason?.name || 'Error',
error: String(result.reason?.message || result.reason),
persistenceWrite: false,
succeeded: false,
};
});
return this.lastRestoreResults.map((result) => ({ ...result }));
} finally {
this._restoreControllers.clear();
this._notifyDurableState();
}
}
_selectedShareTrackingTarget() {
if (this._source !== 'share') return null;
for (const [layerId, policy] of Object.entries(
SHARE_TRACKING_RESTORE_POLICIES,
)) {
const targetId =
this._durableState.options?.[policy.optionOwner]?.[policy.optionKey];
if (targetId !== null && targetId !== undefined && targetId !== '') {
return { layerId, targetId, ...policy };
}
}
return null;
}
_passivelyClearTrackingSelection(selected) {
const current =
this._durableState.options?.[selected.optionOwner]?.[selected.optionKey];
if (String(current) !== String(selected.targetId)) return false;
const ownerOptions = {
...this._durableState.options[selected.optionOwner],
[selected.optionKey]: null,
};
this._durableState = normalizeLayerState({
...this._durableState,
options: {
...this._durableState.options,
[selected.optionOwner]: ownerOptions,
},
});
this.dataManager.setLayerParams?.(
selected.layerId,
{ [selected.optionKey]: null },
{ origin: LAYER_RESTORE_ORIGINS.share },
);
this.shareLinkManager?.onLayerStateChange?.();
this._notifyDurableState();
return true;
}
/**
* `atMs` is the moment the subject was first found ABSENT, not the moment the
* verdict is delivered. Waiting out the pending window must not by itself
* push a fresh link into the "expired" wording — that word describes the
* SHARE's age, not how long this client watched for the subject.
*/
_classifyMissingTrackingTarget(selected, atMs = this.now()) {
const copiedAt = this._shareCreatedAtMs;
if (!Number.isFinite(copiedAt)) return 'unavailable';
const ageMs = atMs - copiedAt;
return ageMs > selected.expiryWindowMs ? 'expired' : 'unavailable';
}
/** Whether the owning layer currently follows the shared subject. */
_trackingTargetLatched(selected) {
const params = this.dataManager.getLayerParams?.(selected.layerId);
const active = params?.[selected.optionKey];
return (
active !== null &&
active !== undefined &&
String(active) === String(selected.targetId)
);
}
/** Stop watching a pending shared subject without deciding its fate. */
_cancelPendingTrackingWatch() {
if (this._pendingTrackingTimer !== null)
this.clearTimer(this._pendingTrackingTimer);
this._pendingTrackingTimer = null;
const pending = this._pendingTrackingContext;
if (pending?.signal && pending.abortHandler) {
pending.signal.removeEventListener('abort', pending.abortHandler);
pending.abortHandler = null;
}
}
/** Publish a share-follow lifecycle update without allowing UI errors to own state. */
_publishTrackingRestoreStatus(status) {
try {
this.onTrackingRestoreStatus?.(status);
} catch {
/* status UI is best effort */
}
}
/**
* Revoke a pending shared Follow wholesale.
*
* The watch and the LAYER's own deferred-restore latch are two halves of one
* mechanism, so they must die together. Aborting only the restore controller
* left the timer alive: the controller has already settled by the time the
* watch exists, so the abort was a no-op and the orphaned timer went on to
* announce "Shared … unavailable" for a subject whose latch had been
* cancelled — a notice about work no longer being attempted.
*/
_revokePendingTrackingWatch(reason) {
this._trackingRestoreController?.abort(reason);
this._trackingRestoreGeneration += 1;
this._cancelPendingTrackingWatch();
const pending = this._pendingTrackingContext;
this._pendingTrackingContext = null;
if (pending) {
this.dataManager.cancelPendingLayerRestore?.(pending.selected.layerId, {
origin: LAYER_RESTORE_ORIGINS.share,
reason: String(reason || 'cancelled'),
});
this._publishTrackingRestoreStatus({
...pending.probe,
...pending.selected,
status: 'cancelled',
classification: 'cancelled',
reason: String(reason || 'cancelled'),
cleared: false,
});
}
}
/**
* Hold a not-yet-arrived shared subject PENDING instead of declaring it gone.
*
* A recipient's first authoritative refresh routinely lands without a given
* contact — the feed is polled, coverage is partial, and rendering trails the
* snapshot by a poll. Reload-from-local already survives this: its restore
* arms the layer's own deferred-restore latch, which re-attempts on every
* later poll. The shared path used to decide on that single refresh, clear
* the subject from durable state AND from the URL, then post a failure notice
* seconds into startup — so the same link healed on reload but never on the
* share.
*
* Arm the SAME latch, then watch it in the background: the caller is never
* blocked (startup must not wait out a 90 s window before it may write the
* URL again), and the terminal verdict is deferred until the source-specific
* window has genuinely expired. The existing wordings are unchanged.
*/
async _beginPendingTrackingRestore(selected, probe, signal = null) {
const generation = this._trackingRestoreGeneration;
const absentAtMs = this.now();
// Arm the layer's deferred-restore latch under the passive share origin, so
// it re-attempts each poll and never rewrites recipient preferences.
let armed = false;
let armError = null;
try {
armed =
(await this.dataManager.setLayerParams?.(
selected.layerId,
{ [selected.optionKey]: selected.targetId },
{ origin: LAYER_RESTORE_ORIGINS.share },
)) === true;
} catch (error) {
armError = error;
}
if (
this._destroyed ||
generation !== this._trackingRestoreGeneration ||
signal?.aborted
) {
if (armed) {
this.dataManager.cancelPendingLayerRestore?.(selected.layerId, {
origin: LAYER_RESTORE_ORIGINS.share,
reason: String(signal?.reason || 'superseded'),
});
}
return {
...probe,
...selected,
status: 'cancelled',
classification: 'cancelled',
reason: String(signal?.reason || 'superseded'),
cleared: false,
};
}
if (!armed) {
const terminal = {
...probe,
...selected,
status: 'source-unavailable',
classification: 'source-unavailable',
reason: String(
armError?.message || armError || 'tracking restore latch rejected',
),
cleared: this._passivelyClearTrackingSelection(selected),
};
this._publishTrackingRestoreStatus(terminal);
return terminal;
}
const deadline = this.now() + selected.expiryWindowMs;
const settle = (terminal) => {
if (this._pendingTrackingContext?.generation !== generation) return;
this._cancelPendingTrackingWatch();
this._pendingTrackingContext = null;
this._publishTrackingRestoreStatus(terminal);
};
const poll = () => {
this._pendingTrackingTimer = null;
if (this._destroyed || generation !== this._trackingRestoreGeneration)
return;
// The layer that owns the latch may have gone away since the last tick
// (disable, teardown, replacement). There is nothing left attempting this
// restore, so abandon it silently rather than announcing a verdict.
if (this.dataManager.isEffectivelyEnabled?.(selected.layerId) === false) {
this._revokePendingTrackingWatch('owner-layer-disabled');
return;
}
if (this._trackingTargetLatched(selected)) {
settle({
...probe,
...selected,
status: 'found',
classification: 'followed',
cleared: false,
});
return;
}
if (this.now() >= deadline) {
// The window really has elapsed — only now does the verdict apply.
const classification =
probe.status === 'missing'
? this._classifyMissingTrackingTarget(selected, absentAtMs)
: 'source-unavailable';
const cleared = this._passivelyClearTrackingSelection(selected);
settle({ ...probe, ...selected, classification, cleared });
return;
}
this._pendingTrackingTimer = this.setTimer(
poll,
PENDING_TRACKING_POLL_MS,
);
this._pendingTrackingTimer?.unref?.();
};
this._cancelPendingTrackingWatch();
const pending = {
...probe,
...selected,
status: 'pending',
classification: 'pending',
cleared: false,
};
const pendingContext = {
generation,
selected,
probe,
signal,
abortHandler: null,
};
if (signal) {
pendingContext.abortHandler = () => {
if (this._pendingTrackingContext?.generation !== generation) return;
this._revokePendingTrackingWatch(signal.reason || 'aborted');
};
signal.addEventListener('abort', pendingContext.abortHandler, {
once: true,
});
}
this._pendingTrackingContext = pendingContext;
this._publishTrackingRestoreStatus(pending);
this._pendingTrackingTimer = this.setTimer(poll, PENDING_TRACKING_POLL_MS);
this._pendingTrackingTimer?.unref?.();
return pending;
}
/**
* Refresh and restore the one shareable tracked target after destination
* camera and ordinary layer restoration have settled.
*/
async restoreShareTrackingSelection({ signal = null } = {}) {
const selected = this._selectedShareTrackingTarget();
if (!selected || this._destroyed)
return { status: 'skipped', reason: 'no-shared-target' };
this._revokePendingTrackingWatch('superseded-by-newer-restore');
const controller = new AbortController();
const combinedSignal = signal
? AbortSignal.any([signal, controller.signal])
: controller.signal;
this._trackingRestoreController = controller;
const generation = ++this._trackingRestoreGeneration;
let result;
try {
result = await this.dataManager.resolveLayerTrackingTarget(
selected.layerId,
selected.targetId,
{ signal: combinedSignal, origin: LAYER_RESTORE_ORIGINS.share },
);
} catch (error) {
result = combinedSignal.aborted
? {
status: 'cancelled',
reason: String(combinedSignal.reason || 'aborted'),
}
: {
status: 'source-unavailable',
reason: String(error?.message || error),
};
}
if (
generation !== this._trackingRestoreGeneration ||
this._destroyed ||
combinedSignal.aborted
) {
if (this._trackingRestoreController === controller)
this._trackingRestoreController = null;
return {
...result,
status: 'cancelled',
reason: String(combinedSignal.reason || 'superseded'),
};
}
if (this._trackingRestoreController === controller)
this._trackingRestoreController = null;
if (result.status === 'found') {
const terminal = {
...result,
...selected,
classification: 'followed',
cleared: false,
};
this._publishTrackingRestoreStatus(terminal);
return terminal;
}
if (['cancelled', 'superseded', 'destroyed'].includes(result.status))
return result;
// A subject that is simply not here YET is not a subject that is gone. Hold
// it on the layer's own deferred-restore latch for its source-specific
// window before any verdict is reached or shown. `unsupported` layers have
// no latch to arm, so they still decide immediately.
if (result.status === 'missing' || result.status === 'source-unavailable') {
return this._beginPendingTrackingRestore(
selected,
result,
combinedSignal,
);
}
const classification =
result.status === 'missing'
? this._classifyMissingTrackingTarget(selected)
: 'source-unavailable';
const cleared = this._passivelyClearTrackingSelection(selected);
const terminal = { ...result, ...selected, classification, cleared };
this._publishTrackingRestoreStatus(terminal);
return terminal;
}
destroy() {
if (this._destroyed) return;
this._destroyed = true;
for (const controller of this._restoreControllers.values())
controller.abort('coordinator-destroyed');
this._restoreControllers.clear();
this._revokePendingTrackingWatch('coordinator-destroyed');
this._trackingRestoreController = null;
this._unsubscribe?.();
this._unsubscribe = null;
this._unsubscribeVisibilityRequests?.();
this._unsubscribeVisibilityRequests = null;
this.shareLinkManager?.setLayerStateProvider?.(null);
this.onDurableStateChange = null;
this.onTrackingRestoreStatus = null;
}
}
+30 -1
View File
@@ -11,6 +11,7 @@ import {
LAYER_STATE_STORAGE_KEY,
LAYER_STATE_TOKEN_ALPHABET,
LAYER_STATE_TOKEN_RESERVATIONS,
LayerStateCoordinator,
REGISTERED_LAYER_IDS,
SHARE_TRACKING_RESTORE_POLICIES,
createDefaultLayerState,
@@ -23,7 +24,6 @@ import {
validateLayerStateAllocations,
validateLayerStateRegistry,
} from './layerState.js';
import { LayerStateCoordinator } from './layerStateCoordinator.js';
import radioLayer from './radio.js';
import { stampInitialShareGesture } from '../navigationPolicy.js';
@@ -2642,3 +2642,32 @@ test('fire perimeters uses digit 2 without colliding with wind or recent imagery
decoded,
);
});
test('RainViewer radar choice round trips on the existing v token and old links default to NOAA', () => {
const entry = LAYER_STATE_REGISTRY.find(({ id }) => id === 'weather-radar');
assert.equal(entry.token, 'v');
assert.equal(entry.optionOwner, 'weather-radar');
for (const product of ['radar', 'radar-global']) {
const state = normalizeLayerState({
enabledLayerIds: ['weather-radar'],
options: { 'weather-radar': { product, opacity: 'light', play: true } },
});
const decoded = decodeLayerStateParams(new URLSearchParams(encode(state)));
assert.deepEqual(decoded, state);
assert.equal(decoded.options['weather-radar'].product, product);
assert.equal(
Object.hasOwn(decoded.options['weather-radar'], 'play'),
false,
);
}
const old = normalizeLayerState({
enabledLayerIds: ['weather-radar'],
options: { 'weather-radar': { opacity: 'light' } },
});
assert.equal(old.options['weather-radar'].product, 'radar');
assert.equal(
normalizeLayerState({ options: { 'weather-radar': { product: 'clouds' } } })
.options['weather-radar'].product,
'radar',
);
});
-742
View File
@@ -1,742 +0,0 @@
/**
* Applies layer state to the running application: restores shared and stored
* layer choices and persists changes to browser storage.
*/
import {
LAYER_RESTORE_ORIGINS,
LAYER_STATE_REGISTRY,
LAYER_STATE_STORAGE_KEY,
REGISTRY_BY_ID,
SHARE_TRACKING_RESTORE_POLICIES,
cloneLayerState,
createDefaultLayerState,
isExplicitLayerStateOrigin,
layerOptionsForRestore,
normalizeLayerState,
optionSpecs,
parseStoredLayerState,
serializeStoredLayerState,
} from './layerState.js';
/** Re-check cadence while a shared subject waits for its feed row to arrive. */
const PENDING_TRACKING_POLL_MS = 1_000;
const TRACKING_OPTION_KEY_BY_LAYER = Object.freeze({
flights: 'selectedFlightsTrackingId',
military: 'selectedMilitaryTrackingId',
satellites: 'selectedSatTrackingId',
});
function safeStorage() {
try {
return globalThis.localStorage || null;
} catch {
return null;
}
}
function currentLayerOutcome(dataManager, layerId) {
const state = dataManager.getLayerLifecycleState?.(layerId);
return {
settledEnabled: Boolean(state?.enabled),
lifecycleState: state?.lifecycleState || 'missing',
lifecycleUncertain: Boolean(state?.uncertain),
};
}
/**
* Owns durable user layer preferences independently from transient runtime
* choreography, and coordinates passive post-registration restoration.
*/
export class LayerStateCoordinator {
constructor(
dataManager,
shareLinkManager,
{
storage = safeStorage(),
restoreGate = null,
onDurableStateChange = null,
onTrackingRestoreStatus = null,
now = () => Date.now(),
// Injectable so the pending-window behavior is deterministically testable
// without sleeping out a 90 s expiry.
setTimer = (fn, ms) => setTimeout(fn, ms),
clearTimer = (handle) => clearTimeout(handle),
} = {},
) {
if (!dataManager?.registrationsFinalized) {
throw new Error(
'Layer state requires finalized data-layer registrations',
);
}
this.dataManager = dataManager;
this.shareLinkManager = shareLinkManager || null;
this.storage = storage;
this.restoreGate = restoreGate;
this.onDurableStateChange = onDurableStateChange;
this.onTrackingRestoreStatus = onTrackingRestoreStatus;
this.now = now;
this.setTimer = setTimer;
this.clearTimer = clearTimer;
this._durableState = createDefaultLayerState();
this._source = 'defaults';
this._destroyed = false;
this._restoreControllers = new Map();
this._shareCreatedAtMs = null;
this._trackingRestoreController = null;
this._trackingRestoreGeneration = 0;
this._pendingTrackingTimer = null;
this._pendingTrackingContext = null;
this._unsubscribe = this.dataManager.subscribe((change) =>
this._handleManagerChange(change),
);
this._unsubscribeVisibilityRequests =
this.dataManager.subscribeVisibilityRequests((change) =>
this._handleVisibilityRequest(change),
);
this.restorePromise = Promise.resolve([]);
this.lastRestoreResults = [];
}
start({
shareLayerState = null,
allowLocalState = true,
shareCreatedAtMs = null,
} = {}) {
if (this._destroyed)
throw new Error('Layer-state coordinator is destroyed');
let selected = shareLayerState
? normalizeLayerState(shareLayerState)
: null;
if (selected) {
this._source = 'share';
this._shareCreatedAtMs = Number.isFinite(shareCreatedAtMs)
? shareCreatedAtMs
: null;
} else if (allowLocalState) {
let stored = null;
try {
stored = parseStoredLayerState(
this.storage?.getItem?.(LAYER_STATE_STORAGE_KEY),
);
} catch {
/* best effort */
}
if (stored) {
selected = stored;
this._source = 'local';
}
} else {
// A valid historical camera/style share with no v2 layer payload keeps
// the exact legacy default-layer behavior. It must not inherit an
// unrelated recipient's saved local layer preferences.
this._source = 'legacy-share';
}
this._durableState = selected || createDefaultLayerState();
this.shareLinkManager?.setLayerStateProvider?.(() =>
this.getDurableState(),
);
this.shareLinkManager?.onLayerStateChange?.();
this._notifyDurableState();
if (!selected) return this.restorePromise;
this.restorePromise = this._restoreSelectedState(
this._source === 'share'
? LAYER_RESTORE_ORIGINS.share
: LAYER_RESTORE_ORIGINS.local,
);
return this.restorePromise;
}
get source() {
return this._source;
}
getDurableState() {
return cloneLayerState(this._durableState);
}
_notifyDurableState() {
try {
this.onDurableStateChange?.(this.getDurableState());
} catch {
/* UI sync is best effort */
}
}
_handleVisibilityRequest(change) {
if (!isExplicitLayerStateOrigin(change?.origin)) return;
this._restoreControllers
.get(change.layerId)
?.abort('superseded-by-explicit-visibility');
if (SHARE_TRACKING_RESTORE_POLICIES[change.layerId]) {
this._revokePendingTrackingWatch('superseded-by-explicit-visibility');
}
}
/** Revoke every passive restore before explicit navigation can be reclaimed. */
cancelPendingRestores(reason = 'superseded-by-explicit-navigation') {
for (const controller of this._restoreControllers.values())
controller.abort(reason);
this._revokePendingTrackingWatch(reason);
}
/**
* Revoke a pending shared Follow. Physical navigation may also clear only
* the exact passive selection, without writing recipient preferences.
*/
cancelPendingShareTracking(
reason = 'superseded-by-explicit-navigation',
{ clearSelection = false } = {},
) {
this._revokePendingTrackingWatch(reason);
if (!clearSelection) return false;
const selected = this._selectedShareTrackingTarget();
return selected ? this._passivelyClearTrackingSelection(selected) : false;
}
_handleManagerChange(change) {
if (!change || this._destroyed) return;
// Parameter and visibility ownership are independent. A newer explicit
// option request may replace passive share options, but it must not abort
// the same layer's visibility lifecycle.
if (change.type === 'params-requested') {
if (
isExplicitLayerStateOrigin(change.origin) &&
SHARE_TRACKING_RESTORE_POLICIES[change.layerId]
) {
this._revokePendingTrackingWatch('superseded-by-explicit-params');
}
return;
}
// A layer that goes away takes its latch with it, at ANY origin — a
// programmatic disable or teardown never reaches the explicit-intent path
// below, so revoke here before that early return.
if (
change.type === 'visibility' &&
change.enabled === false &&
SHARE_TRACKING_RESTORE_POLICIES[change.layerId]
) {
this._revokePendingTrackingWatch('owner-layer-disabled');
}
if (!isExplicitLayerStateOrigin(change.origin)) return;
if (change.type === 'visibility') {
this._restoreControllers
.get(change.layerId)
?.abort('superseded-by-explicit-visibility');
if (SHARE_TRACKING_RESTORE_POLICIES[change.layerId]) {
this._revokePendingTrackingWatch('superseded-by-explicit-visibility');
}
const enabled = new Set(this._durableState.enabledLayerIds);
if (change.enabled) enabled.add(change.layerId);
else enabled.delete(change.layerId);
this._commitExplicit({
...this._durableState,
enabledLayerIds: [...enabled],
});
return;
}
if (change.type !== 'params') return;
const entry = REGISTRY_BY_ID.get(change.layerId);
if (!entry?.optionOwner) return;
const ownerId = entry.optionOwner;
const nextOwnerOptions = { ...this._durableState.options[ownerId] };
const requestedParams = change.requestedParams || {};
const trackingOptionKey =
TRACKING_OPTION_KEY_BY_LAYER[change.layerId] || null;
let changed = false;
for (const spec of optionSpecs(ownerId)) {
// Only persist keys present in this explicit request. getLayerParams()
// can return a wider live snapshot containing transient or passively
// changed values that this user action did not own. Tracking is the one
// exception: an unrelated explicit option cancels a pending restoration
// in that family, so its wider live value (active ID or null) must replace
// the formerly durable pending ID instead of allowing reload resurrection.
const explicitlyRequested = Object.hasOwn(requestedParams, spec.key);
const implicitTrackingSync =
!explicitlyRequested && spec.key === trackingOptionKey;
if (!explicitlyRequested && !implicitTrackingSync) continue;
const value = spec.normalize(change.params[spec.key]);
if (value === null) {
if (implicitTrackingSync) {
nextOwnerOptions[spec.key] = null;
changed = true;
continue;
}
if (spec.defaultValue !== null || change.params[spec.key] !== null)
continue;
}
nextOwnerOptions[spec.key] = value;
changed = true;
}
if (!changed) return;
this._commitExplicit({
...this._durableState,
options: { ...this._durableState.options, [ownerId]: nextOwnerOptions },
});
}
_commitExplicit(candidate) {
this._durableState = normalizeLayerState(candidate);
const serialized = serializeStoredLayerState(this._durableState);
try {
if (this.storage?.getItem?.(LAYER_STATE_STORAGE_KEY) !== serialized) {
this.storage?.setItem?.(LAYER_STATE_STORAGE_KEY, serialized);
}
} catch {
/* storage can be unavailable or quota-limited */
}
this.shareLinkManager?.onLayerStateChange?.();
this._notifyDurableState();
}
async _waitForRestoreGate() {
if (!this.restoreGate) return;
await (typeof this.restoreGate === 'function'
? this.restoreGate()
: this.restoreGate);
}
async _restoreSelectedState(origin) {
for (const entry of LAYER_STATE_REGISTRY) {
this._restoreControllers.set(entry.id, new AbortController());
}
try {
await this._waitForRestoreGate();
const enabled = new Set(this._durableState.enabledLayerIds);
const settled = await Promise.allSettled(
LAYER_STATE_REGISTRY.map(async (entry) => {
const controller = this._restoreControllers.get(entry.id);
const targetEnabled = enabled.has(entry.id);
const options = layerOptionsForRestore(this._durableState, entry.id);
if (origin === LAYER_RESTORE_ORIGINS.share && options) {
for (const trackingKey of Object.values(
TRACKING_OPTION_KEY_BY_LAYER,
)) {
delete options[trackingKey];
}
}
if (this._destroyed || controller?.signal.aborted) {
return {
layerId: entry.id,
targetEnabled,
origin,
phase: 'reserved',
...currentLayerOutcome(this.dataManager, entry.id),
appliedOptions: {},
cancellationReason: this._destroyed ? 'destroyed' : 'superseded',
errorClass: 'cancelled',
persistenceWrite: false,
succeeded: false,
};
}
// Reserve passive option state before any asynchronous lifecycle work.
// A later explicit params intent then wins on its own lane without
// cancelling or being overwritten by the visibility restore.
const paramsSucceeded =
!options ||
Object.keys(options).length === 0 ||
this.dataManager.setLayerParams(entry.id, options, { origin });
return this.dataManager
.restoreLayerState(
entry.id,
{
enabled: targetEnabled,
params: null,
},
{ origin, signal: controller.signal },
)
.then((result) => ({
...result,
appliedOptions: paramsSucceeded && options ? options : {},
errorClass: paramsSucceeded
? result.errorClass
: 'ParamsRejected',
succeeded: paramsSucceeded && result.succeeded,
}));
}),
);
this.lastRestoreResults = settled.map((result, index) => {
if (result.status === 'fulfilled') return result.value;
const entry = LAYER_STATE_REGISTRY[index];
return {
layerId: entry.id,
targetEnabled: enabled.has(entry.id),
origin,
phase: 'coordinator',
...currentLayerOutcome(this.dataManager, entry.id),
appliedOptions: {},
cancellationReason: null,
errorClass: result.reason?.name || 'Error',
error: String(result.reason?.message || result.reason),
persistenceWrite: false,
succeeded: false,
};
});
return this.lastRestoreResults.map((result) => ({ ...result }));
} finally {
this._restoreControllers.clear();
this._notifyDurableState();
}
}
_selectedShareTrackingTarget() {
if (this._source !== 'share') return null;
for (const [layerId, policy] of Object.entries(
SHARE_TRACKING_RESTORE_POLICIES,
)) {
const targetId =
this._durableState.options?.[policy.optionOwner]?.[policy.optionKey];
if (targetId !== null && targetId !== undefined && targetId !== '') {
return { layerId, targetId, ...policy };
}
}
return null;
}
_passivelyClearTrackingSelection(selected) {
const current =
this._durableState.options?.[selected.optionOwner]?.[selected.optionKey];
if (String(current) !== String(selected.targetId)) return false;
const ownerOptions = {
...this._durableState.options[selected.optionOwner],
[selected.optionKey]: null,
};
this._durableState = normalizeLayerState({
...this._durableState,
options: {
...this._durableState.options,
[selected.optionOwner]: ownerOptions,
},
});
this.dataManager.setLayerParams?.(
selected.layerId,
{ [selected.optionKey]: null },
{ origin: LAYER_RESTORE_ORIGINS.share },
);
this.shareLinkManager?.onLayerStateChange?.();
this._notifyDurableState();
return true;
}
/**
* `atMs` is the moment the subject was first found ABSENT, not the moment the
* verdict is delivered. Waiting out the pending window must not by itself
* push a fresh link into the "expired" wording — that word describes the
* SHARE's age, not how long this client watched for the subject.
*/
_classifyMissingTrackingTarget(selected, atMs = this.now()) {
const copiedAt = this._shareCreatedAtMs;
if (!Number.isFinite(copiedAt)) return 'unavailable';
const ageMs = atMs - copiedAt;
return ageMs > selected.expiryWindowMs ? 'expired' : 'unavailable';
}
/** Whether the owning layer currently follows the shared subject. */
_trackingTargetLatched(selected) {
const params = this.dataManager.getLayerParams?.(selected.layerId);
const active = params?.[selected.optionKey];
return (
active !== null &&
active !== undefined &&
String(active) === String(selected.targetId)
);
}
/** Stop watching a pending shared subject without deciding its fate. */
_cancelPendingTrackingWatch() {
if (this._pendingTrackingTimer !== null)
this.clearTimer(this._pendingTrackingTimer);
this._pendingTrackingTimer = null;
const pending = this._pendingTrackingContext;
if (pending?.signal && pending.abortHandler) {
pending.signal.removeEventListener('abort', pending.abortHandler);
pending.abortHandler = null;
}
}
/** Publish a share-follow lifecycle update without allowing UI errors to own state. */
_publishTrackingRestoreStatus(status) {
try {
this.onTrackingRestoreStatus?.(status);
} catch {
/* status UI is best effort */
}
}
/**
* Revoke a pending shared Follow wholesale.
*
* The watch and the LAYER's own deferred-restore latch are two halves of one
* mechanism, so they must die together. Aborting only the restore controller
* left the timer alive: the controller has already settled by the time the
* watch exists, so the abort was a no-op and the orphaned timer went on to
* announce "Shared … unavailable" for a subject whose latch had been
* cancelled — a notice about work no longer being attempted.
*/
_revokePendingTrackingWatch(reason) {
this._trackingRestoreController?.abort(reason);
this._trackingRestoreGeneration += 1;
this._cancelPendingTrackingWatch();
const pending = this._pendingTrackingContext;
this._pendingTrackingContext = null;
if (pending) {
this.dataManager.cancelPendingLayerRestore?.(pending.selected.layerId, {
origin: LAYER_RESTORE_ORIGINS.share,
reason: String(reason || 'cancelled'),
});
this._publishTrackingRestoreStatus({
...pending.probe,
...pending.selected,
status: 'cancelled',
classification: 'cancelled',
reason: String(reason || 'cancelled'),
cleared: false,
});
}
}
/**
* Hold a not-yet-arrived shared subject PENDING instead of declaring it gone.
*
* A recipient's first authoritative refresh routinely lands without a given
* contact — the feed is polled, coverage is partial, and rendering trails the
* snapshot by a poll. Reload-from-local already survives this: its restore
* arms the layer's own deferred-restore latch, which re-attempts on every
* later poll. The shared path used to decide on that single refresh, clear
* the subject from durable state AND from the URL, then post a failure notice
* seconds into startup — so the same link healed on reload but never on the
* share.
*
* Arm the SAME latch, then watch it in the background: the caller is never
* blocked (startup must not wait out a 90 s window before it may write the
* URL again), and the terminal verdict is deferred until the source-specific
* window has genuinely expired. The existing wordings are unchanged.
*/
async _beginPendingTrackingRestore(selected, probe, signal = null) {
const generation = this._trackingRestoreGeneration;
const absentAtMs = this.now();
// Arm the layer's deferred-restore latch under the passive share origin, so
// it re-attempts each poll and never rewrites recipient preferences.
let armed = false;
let armError = null;
try {
armed =
(await this.dataManager.setLayerParams?.(
selected.layerId,
{ [selected.optionKey]: selected.targetId },
{ origin: LAYER_RESTORE_ORIGINS.share },
)) === true;
} catch (error) {
armError = error;
}
if (
this._destroyed ||
generation !== this._trackingRestoreGeneration ||
signal?.aborted
) {
if (armed) {
this.dataManager.cancelPendingLayerRestore?.(selected.layerId, {
origin: LAYER_RESTORE_ORIGINS.share,
reason: String(signal?.reason || 'superseded'),
});
}
return {
...probe,
...selected,
status: 'cancelled',
classification: 'cancelled',
reason: String(signal?.reason || 'superseded'),
cleared: false,
};
}
if (!armed) {
const terminal = {
...probe,
...selected,
status: 'source-unavailable',
classification: 'source-unavailable',
reason: String(
armError?.message || armError || 'tracking restore latch rejected',
),
cleared: this._passivelyClearTrackingSelection(selected),
};
this._publishTrackingRestoreStatus(terminal);
return terminal;
}
const deadline = this.now() + selected.expiryWindowMs;
const settle = (terminal) => {
if (this._pendingTrackingContext?.generation !== generation) return;
this._cancelPendingTrackingWatch();
this._pendingTrackingContext = null;
this._publishTrackingRestoreStatus(terminal);
};
const poll = () => {
this._pendingTrackingTimer = null;
if (this._destroyed || generation !== this._trackingRestoreGeneration)
return;
// The layer that owns the latch may have gone away since the last tick
// (disable, teardown, replacement). There is nothing left attempting this
// restore, so abandon it silently rather than announcing a verdict.
if (this.dataManager.isEffectivelyEnabled?.(selected.layerId) === false) {
this._revokePendingTrackingWatch('owner-layer-disabled');
return;
}
if (this._trackingTargetLatched(selected)) {
settle({
...probe,
...selected,
status: 'found',
classification: 'followed',
cleared: false,
});
return;
}
if (this.now() >= deadline) {
// The window really has elapsed — only now does the verdict apply.
const classification =
probe.status === 'missing'
? this._classifyMissingTrackingTarget(selected, absentAtMs)
: 'source-unavailable';
const cleared = this._passivelyClearTrackingSelection(selected);
settle({ ...probe, ...selected, classification, cleared });
return;
}
this._pendingTrackingTimer = this.setTimer(
poll,
PENDING_TRACKING_POLL_MS,
);
this._pendingTrackingTimer?.unref?.();
};
this._cancelPendingTrackingWatch();
const pending = {
...probe,
...selected,
status: 'pending',
classification: 'pending',
cleared: false,
};
const pendingContext = {
generation,
selected,
probe,
signal,
abortHandler: null,
};
if (signal) {
pendingContext.abortHandler = () => {
if (this._pendingTrackingContext?.generation !== generation) return;
this._revokePendingTrackingWatch(signal.reason || 'aborted');
};
signal.addEventListener('abort', pendingContext.abortHandler, {
once: true,
});
}
this._pendingTrackingContext = pendingContext;
this._publishTrackingRestoreStatus(pending);
this._pendingTrackingTimer = this.setTimer(poll, PENDING_TRACKING_POLL_MS);
this._pendingTrackingTimer?.unref?.();
return pending;
}
/**
* Refresh and restore the one shareable tracked target after destination
* camera and ordinary layer restoration have settled.
*/
async restoreShareTrackingSelection({ signal = null } = {}) {
const selected = this._selectedShareTrackingTarget();
if (!selected || this._destroyed)
return { status: 'skipped', reason: 'no-shared-target' };
this._revokePendingTrackingWatch('superseded-by-newer-restore');
const controller = new AbortController();
const combinedSignal = signal
? AbortSignal.any([signal, controller.signal])
: controller.signal;
this._trackingRestoreController = controller;
const generation = ++this._trackingRestoreGeneration;
let result;
try {
result = await this.dataManager.resolveLayerTrackingTarget(
selected.layerId,
selected.targetId,
{ signal: combinedSignal, origin: LAYER_RESTORE_ORIGINS.share },
);
} catch (error) {
result = combinedSignal.aborted
? {
status: 'cancelled',
reason: String(combinedSignal.reason || 'aborted'),
}
: {
status: 'source-unavailable',
reason: String(error?.message || error),
};
}
if (
generation !== this._trackingRestoreGeneration ||
this._destroyed ||
combinedSignal.aborted
) {
if (this._trackingRestoreController === controller)
this._trackingRestoreController = null;
return {
...result,
status: 'cancelled',
reason: String(combinedSignal.reason || 'superseded'),
};
}
if (this._trackingRestoreController === controller)
this._trackingRestoreController = null;
if (result.status === 'found') {
const terminal = {
...result,
...selected,
classification: 'followed',
cleared: false,
};
this._publishTrackingRestoreStatus(terminal);
return terminal;
}
if (['cancelled', 'superseded', 'destroyed'].includes(result.status))
return result;
// A subject that is simply not here YET is not a subject that is gone. Hold
// it on the layer's own deferred-restore latch for its source-specific
// window before any verdict is reached or shown. `unsupported` layers have
// no latch to arm, so they still decide immediately.
if (result.status === 'missing' || result.status === 'source-unavailable') {
return this._beginPendingTrackingRestore(
selected,
result,
combinedSignal,
);
}
const classification =
result.status === 'missing'
? this._classifyMissingTrackingTarget(selected)
: 'source-unavailable';
const cleared = this._passivelyClearTrackingSelection(selected);
const terminal = { ...result, ...selected, classification, cleared };
this._publishTrackingRestoreStatus(terminal);
return terminal;
}
destroy() {
if (this._destroyed) return;
this._destroyed = true;
for (const controller of this._restoreControllers.values())
controller.abort('coordinator-destroyed');
this._restoreControllers.clear();
this._revokePendingTrackingWatch('coordinator-destroyed');
this._trackingRestoreController = null;
this._unsubscribe?.();
this._unsubscribe = null;
this._unsubscribeVisibilityRequests?.();
this._unsubscribeVisibilityRequests = null;
this.shareLinkManager?.setLayerStateProvider?.(null);
this.onDurableStateChange = null;
this.onTrackingRestoreStatus = null;
}
}
+82 -7
View File
@@ -1,9 +1,4 @@
import * as Cesium from 'cesium';
import {
layerTitle,
mapAnalystRecord,
parseGeojsonLines,
} from '../sources/infrastructureData.js';
import { createInfrastructureOverlayEntry } from './infrastructureOverlayEntry.js';
import { isPointerFree } from './inputOwnership.js';
import {
@@ -91,6 +86,78 @@ export function localInfrastructureOverlayCopy(properties, layerId) {
return { title, details };
}
/**
* Map one local infrastructure feature to a JSON-safe analyst record
* (analyst query engine seam). Pure — no Cesium types. Missing/unknown
* fields are null, never NaN/undefined. Layer-specific fields that do not
* apply (river/output on datacenters, capacity on dams) stay null so a
* shared compact payload can copy them without inventing values.
* Names are unclamped — overlay cards shorten for paint; queries need the
* full source string ("Usina Hidrelétrica de Itaipu").
* @param {Object|null|undefined} raw - {id, lat, lon, properties}.
* @param {string} [layerId] Local layer id (`local-datacenters` / `local-dams`).
* @returns {{id: string, name: string|null, lat: number|null, lon: number|null,
* operator: string|null, capacity: string|null, river: string|null,
* output: string|null}}
*/
export function mapAnalystRecord(raw, layerId = '') {
const num = (v) => (Number.isFinite(v) ? v : null);
const text = (v) => {
const t = String(v ?? '').trim();
return t && t !== 'undefined' && t !== 'null' ? t : null;
};
const props =
raw?.properties &&
typeof raw.properties === 'object' &&
!Array.isArray(raw.properties)
? raw.properties
: {};
const tags =
props.tags && typeof props.tags === 'object' && !Array.isArray(props.tags)
? props.tags
: {};
const name =
text(props.name) ||
text(tags.name) ||
text(tags['name:en']) ||
text(tags.official_name) ||
null;
const operator =
text(tags.operator) ||
text(props.operator) ||
text(tags['operator:short']) ||
null;
const capacity =
layerId === 'local-datacenters'
? text(tags['capacity:it_load']) ||
text(tags.it_load) ||
text(tags.capacity) ||
text(props.capacity)
: null;
const river =
layerId === 'local-dams'
? text(tags.associated_river) ||
text(props.associated_river) ||
text(tags.river) ||
text(props.river) ||
text(tags['river:name'])
: null;
const output =
layerId === 'local-dams'
? text(props.output) || text(tags['plant:output:electricity'])
: null;
return {
id: name || text(raw?.id) || layerTitle(layerId),
name,
lat: num(raw?.lat),
lon: num(raw?.lon),
operator,
capacity,
river,
output,
};
}
/**
* Produce one normalized-contract input owned by a local infrastructure layer.
* The host revalidates the authoritative `source` value while normalizing it.
@@ -597,7 +664,11 @@ export function createLocalGeoJsonLayer(
}
const text = await response.text();
if (_destroyed) return;
features = parseGeojsonLines(text);
const lines = text
.split('\n')
.filter((l) => l.trim().length > 0);
features = lines.map((line) => JSON.parse(line));
_cachedFeatures = features;
}
@@ -1289,4 +1360,8 @@ function clampCardLine(value) {
return text.length > 48 ? `${text.slice(0, 45)}...` : text;
}
export { mapAnalystRecord };
function layerTitle(layerId) {
if (layerId === 'local-datacenters') return 'Datacenter';
if (layerId === 'local-dams') return 'Dam';
return 'Feature';
}
+3 -11
View File
@@ -93,16 +93,8 @@ export function nameMilitaryFragment(fragment, names) {
};
}
/**
* Area-ranked spatial thinning of visible label points, including dateline
* views. With `thinned` false every point in the box is kept, up to `cap`.
*/
export function militaryNamesInView(
names,
box,
cap = MILITARY_POINT_CAP,
thinned = true,
) {
/** Area-ranked spatial thinning of visible label points, including dateline views. */
export function militaryNamesInView(names, box, cap = MILITARY_POINT_CAP) {
const width =
box.east >= box.west ? box.east - box.west : 360 + box.east - box.west;
const height = box.north - box.south;
@@ -115,7 +107,7 @@ export function militaryNamesInView(
continue;
count++;
const cell = `${Math.min(31, Math.floor((x / width) * 32))}:${Math.min(15, Math.floor(((record.latitude - box.south) / height) * 16))}`;
if ((thinned && cells.has(cell)) || records.length >= cap) continue;
if (cells.has(cell) || records.length >= cap) continue;
cells.add(cell);
records.push(record);
}
-10
View File
@@ -217,13 +217,3 @@ test('a close view keeps the parent name inside its polygon when the usual label
hole,
);
});
test('queries can take every named site in a box instead of one per display cell', async () => {
const names = await loadMilitaryNames();
const box = { west: -2, south: 50, east: 2, north: 54 };
const thinned = militaryNamesInView(names, box);
const all = militaryNamesInView(names, box, Infinity, false);
assert.equal(all.count, thinned.count);
assert.equal(all.records.length, all.count);
assert.ok(all.records.length > thinned.records.length);
});
+407
View File
@@ -0,0 +1,407 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import {
weatherProxy,
parseRainViewerManifest,
} from '../../server/providers/weather.js';
import { createRainViewerGovernor } from '../../server/providers/rainViewerGovernor.js';
const seconds = 1790215200;
const TIME = new Date(seconds * 1000).toISOString();
const frame = (time = seconds, path = '/v2/radar/47d5f2b2ef80') => ({
time,
path,
});
const manifest = (past = [frame()]) => ({
version: '2.0',
host: 'http://127.0.0.1/evil',
radar: { past, nowcast: [frame(seconds + 600)] },
});
function png(width = 256) {
const bytes = Buffer.alloc(33);
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes);
bytes.writeUInt32BE(13, 8);
bytes.write('IHDR', 12);
bytes.writeUInt32BE(width, 16);
bytes.writeUInt32BE(256, 20);
return new Response(bytes, { headers: { 'Content-Type': 'image/png' } });
}
function install(options = {}) {
let handler;
weatherProxy({ now: () => seconds * 1000, ...options }).configureServer({
middlewares: {
use(_path, fn) {
handler = fn;
},
},
});
return async (url) => {
const res = new EventEmitter();
res.writeHead = (status, headers) =>
Object.assign(res, { status, headers });
res.end = (body) => {
res.body = body;
};
await handler({ url, method: 'GET' }, res);
return res;
};
}
const tile = (extra = {}) =>
'/tile?' +
new URLSearchParams({
product: 'radar-global',
time: TIME,
z: 7,
x: 0,
y: 0,
...extra,
});
const body = (res) => JSON.parse(res.body);
test('RainViewer parses sorted observed frames only, drops malformed frames and bounds the fresh map', () => {
const past = Array.from({ length: 20 }, (_, i) =>
frame(seconds - i * 600, `/v2/radar/frame${i}`),
);
past.push(
null,
{},
frame(0),
frame(-1),
frame('1790215200'),
frame(1.5),
frame(1e30),
frame(seconds, '/v2/radar/../escape'),
frame(seconds, 'https://evil.test'),
frame(seconds, '/v2/radar/' + 'a'.repeat(65)),
frame(seconds, '/v2/radar/abc?x=1'),
);
const parsed = parseRainViewerManifest(JSON.stringify(manifest(past)));
assert.equal(parsed.times.length, 13);
assert.deepEqual(parsed.times, [...parsed.times].sort());
assert.equal(parsed.times.at(-1), TIME);
assert.equal(Object.keys(parsed.framePaths).length, 13);
assert.equal(parsed.framePaths[TIME], '/v2/radar/frame0');
assert.throws(() => parseRainViewerManifest(JSON.stringify(manifest([]))));
assert.throws(() => parseRainViewerManifest('{}'));
});
test('RainViewer pins both URLs, uses PNG validation, caches tiles and refreshes a replaced frame map', async () => {
let clock = seconds * 1000;
let past = [frame()];
const calls = [];
const request = install({
now: () => clock,
fetchImpl: async (url, options) => {
calls.push({ url, options });
return url.includes('weather-maps.json')
? Response.json(manifest(past))
: png();
},
});
const first = body(await request('/manifest?product=radar-global'));
assert.equal(first.title, 'Global radar reflectivity');
assert.equal(first.tilingScheme, 'web-mercator');
assert.equal(first.maxLevel, 7);
assert.equal(first.attribution, 'RainViewer');
assert.equal(first.imageUrl, null);
assert.equal(
calls[0].url,
'https://api.rainviewer.com/public/weather-maps.json',
);
const response = await request(tile());
assert.equal(response.status, 200);
assert.equal(
response.headers['Cache-Control'],
'public, max-age=86400, immutable',
);
assert.equal(
calls[1].url,
'https://tilecache.rainviewer.com/v2/radar/47d5f2b2ef80/256/7/0/0/2/1_1.png',
);
assert.equal(calls[1].options.redirect, 'error');
assert.deepEqual((await request(tile())).body, response.body);
assert.equal(calls.length, 2);
clock += 299_999;
await request('/manifest?product=radar-global');
assert.equal(calls.length, 2);
clock++;
past = [frame(seconds + 600, '/v2/radar/new')];
await request('/manifest?product=radar-global');
const old = await request(tile());
assert.equal(old.status, 400);
assert.equal(body(old).error, 'unknown_weather_time');
assert.equal(
calls.length,
3,
'expired map entries cannot serve even a cached tile',
);
});
test('RainViewer rejects invalid coordinates, sizes, destinations and whole images before upstream', async () => {
let calls = 0;
const request = install({
fetchImpl: async () => {
calls++;
throw new Error();
},
});
for (const coords of [
{ z: 8 },
{ x: 128 },
{ y: 128 },
{ z: 0, x: 1 },
{ z: 0, y: 1 },
...['z', 'x', 'y'].flatMap((key) =>
['1e3', '0x1', '-0', '-1'].map((value) => ({ [key]: value })),
),
{ size: 512 },
{ size: 1024 },
{ size: '0256' },
{ host: 'https://evil.test' },
]) {
assert.equal(
(await request(tile(coords))).status,
400,
JSON.stringify(coords),
);
}
const image = await request('/image?product=radar-global&time=' + TIME);
assert.equal(image.status, 400);
assert.equal(body(image).error, 'whole_image_unsupported');
assert.equal(calls, 0);
});
test('RainViewer metadata retains stale observations and enforces capped reads and 30-second failure backoff', async () => {
let clock = seconds * 1000,
calls = 0;
const request = install({
now: () => clock,
fetchImpl: async () => {
calls++;
if (calls === 1) return Response.json(manifest());
throw new Error('offline');
},
});
await request('/manifest?product=radar-global');
clock += 300_000;
assert.equal(
body(await request('/manifest?product=radar-global')).stale,
true,
);
clock += 29_999;
assert.equal(
body(await request('/manifest?product=radar-global')).latest,
TIME,
);
assert.equal(calls, 2);
clock++;
await request('/manifest?product=radar-global');
assert.equal(calls, 3);
for (const declared of [false, true]) {
const capped = install({
fetchImpl: async () =>
new Response(' '.repeat(256 * 1024 + 1), {
headers: declared ? { 'Content-Length': String(256 * 1024 + 1) } : {},
}),
});
assert.equal(
body(await capped('/manifest?product=radar-global')).unavailable,
true,
);
}
});
test('RainViewer governor is rolling, bounded and supplies the earliest retry time', () => {
const admit = createRainViewerGovernor();
for (let i = 0; i < 80; i++) assert.equal(admit(i * 10), 0);
assert.equal(admit(1000), 59);
assert.equal(admit(59_999), 1);
assert.equal(admit(60_000), 0);
assert.equal(admit(60_000), 1);
assert.equal(admit(120_000), 0);
});
test('RainViewer server governor stops at 80 upstream tiles across times, admits cache hits and resumes after a minute', async () => {
let clock = seconds * 1000,
tiles = 0;
const request = install({
now: () => clock,
fetchImpl: async (url) => {
if (url.includes('weather-maps.json')) return Response.json(manifest());
tiles++;
return png();
},
});
for (let x = 0; x < 80; x++)
assert.equal((await request(tile({ x }))).status, 200);
const blocked = await request(tile({ x: 80 }));
assert.equal(blocked.status, 429);
assert.equal(blocked.headers['Retry-After'], '60');
assert.equal(tiles, 80);
assert.equal((await request(tile())).status, 200);
assert.equal(tiles, 80);
clock += 60_000;
assert.equal((await request(tile({ x: 80 }))).status, 200);
assert.equal(tiles, 81);
});
test('RainViewer rejects non-PNG and wrong PNG dimensions', async () => {
for (const response of [
() => png(512),
() => new Response('bad'),
() => new Response('bad', { headers: { 'Content-Type': 'image/png' } }),
]) {
const request = install({
fetchImpl: async (url) =>
url.includes('weather-maps.json')
? Response.json(manifest())
: response(),
});
assert.equal((await request(tile())).status, 503);
}
});
function fakeGovernorClock() {
let time = 0;
const timers = new Set();
const now = () => time;
const sleep = (ms, _value, { signal }) =>
new Promise((resolve, reject) => {
signal.throwIfAborted();
const timer = {
at: time + ms,
resolve: () => {
signal.removeEventListener('abort', abort);
timers.delete(timer);
resolve();
},
};
const abort = () => {
timers.delete(timer);
reject(signal.reason);
};
signal.addEventListener('abort', abort, { once: true });
timers.add(timer);
});
const flush = async () => {
for (let i = 0; i < 20; i++) await Promise.resolve();
};
return {
now,
sleep,
flush,
async advance(ms) {
await flush();
time += ms;
for (const timer of [...timers]) if (timer.at <= time) timer.resolve();
await flush();
},
};
}
test('RainViewer queues a 100-request burst in FIFO order through the rolling window', async () => {
const governor = createRainViewerGovernor();
const clock = fakeGovernorClock();
const admitted = [];
const requests = Array.from({ length: 100 }, (_, i) =>
governor
.admit(clock.now, { sleep: clock.sleep, maxWaitMs: 60_000 })
.then((retry) => {
assert.equal(retry, 0);
admitted.push(i);
}),
);
await clock.flush();
assert.deepEqual(
admitted,
Array.from({ length: 80 }, (_, i) => i),
);
await clock.advance(59_999);
assert.equal(admitted.length, 80);
await clock.advance(1);
await Promise.all(requests);
assert.deepEqual(
admitted,
Array.from({ length: 100 }, (_, i) => i),
);
});
test('RainViewer bounds the queue and wait budget with retry seconds', async () => {
const governor = createRainViewerGovernor();
const clock = fakeGovernorClock();
for (let i = 0; i < 80; i++) governor(0);
assert.equal(await governor.admit(clock.now, { sleep: clock.sleep }), 60);
await clock.advance(40_000);
const first = governor.admit(clock.now, { sleep: clock.sleep, maxQueued: 1 });
assert.equal(
await governor.admit(clock.now, { sleep: clock.sleep, maxQueued: 1 }),
20,
);
await clock.advance(20_000);
assert.equal(await first, 0);
});
test('RainViewer abort removes a waiter without consuming capacity or blocking its successor', async () => {
const governor = createRainViewerGovernor();
const clock = fakeGovernorClock();
for (let i = 0; i < 80; i++) governor(0);
await clock.advance(40_000);
const controller = new AbortController();
const aborted = governor.admit(clock.now, {
sleep: clock.sleep,
signal: controller.signal,
});
const rejection = assert.rejects(aborted, { name: 'AbortError' });
const next = governor.admit(clock.now, { sleep: clock.sleep });
controller.abort();
await rejection;
await clock.advance(20_000);
assert.equal(await next, 0);
for (let i = 0; i < 79; i++) assert.equal(governor(clock.now()), 0);
assert.equal(governor(clock.now()), 60);
await assert.rejects(
governor.admit(clock.now, { signal: controller.signal }),
{ name: 'AbortError' },
);
});
test('RainViewer queued requests retain their original deadline across window rechecks', async () => {
const governor = createRainViewerGovernor();
const clock = fakeGovernorClock();
for (let i = 0; i < 80; i++) governor(0);
await clock.advance(40_000);
const requests = Array.from({ length: 81 }, () =>
governor.admit(clock.now, { sleep: clock.sleep }),
);
await clock.advance(20_000);
const results = await Promise.all(requests);
assert.deepEqual(results.slice(0, 80), Array(80).fill(0));
assert.equal(results[80], 60);
});
test('RainViewer route waits at the upstream boundary while cache hits and duplicates cost no slots', async () => {
const clock = fakeGovernorClock();
let upstreamTiles = 0;
const request = install({
now: () => seconds * 1000 + clock.now(),
rainViewerSleep: clock.sleep,
fetchImpl: async (url) => {
if (url.includes('weather-maps.json')) return Response.json(manifest());
upstreamTiles++;
return png();
},
});
for (let x = 0; x < 80; x++)
assert.equal((await request(tile({ x }))).status, 200);
await clock.advance(40_000);
const first = request(tile({ x: 80 }));
const duplicate = request(tile({ x: 80 }));
await clock.flush();
assert.equal(upstreamTiles, 80);
assert.equal((await request(tile())).status, 200);
await clock.advance(20_000);
assert.equal((await first).status, 200);
assert.equal((await duplicate).status, 200);
assert.equal(upstreamTiles, 81);
});
-3
View File
@@ -211,9 +211,6 @@ export function shouldShowFirstRun({
if (hasShareState) return false;
const params = new URLSearchParams(location?.search || '');
if (params.get('welcome') === '0') return false;
// An embedded globe never shows the welcome launcher.
if (params.get('embed') === '1' || globalThis.GEV_EMBED_INLINE === true)
return false;
// The demo/support escape hatch outranks both suppressions on purpose.
if (params.get('welcome') === '1') return true;
if (readStored('local', storage, FIRST_RUN_STORAGE_KEY) === 'suppressed')
+1 -5
View File
@@ -318,11 +318,7 @@ export async function initKeySetup({
globalThis.location?.hash?.slice(1) || '',
);
if (next !== null)
globalThis.history?.replaceState?.(
null,
'',
new URL(`#${next}`, globalThis.location.href).href,
);
globalThis.history?.replaceState?.(null, '', `#${next}`);
} catch {
// Continuity is a nicety, never a blocker.
}
-1
View File
@@ -9,7 +9,6 @@ export * from '../../data/cockpitContactDot.js';
export * from '../../data/contactMatch.js';
export * from '../../data/iconOrientation.js';
export * from '../../data/layerState.js';
export * from '../../data/layerStateCoordinator.js';
export * from '../../data/modelVisualAnchor.js';
export * from '../../data/motionModel.js';
export * from '../../data/renderAltitude.js';
+119 -8
View File
@@ -1,12 +1,36 @@
import {
extractStationsArray,
normalizeGbfsBool,
parseStationInformation,
parseStationStatus,
toNonNegativeInteger,
} from '../../sources/gbfsStations.js';
export function createModel({ state: layerState, services, parts, source }) {
/**
* Coerce a GBFS boolean field to a native boolean.
* GBFS feeds are inconsistent — some use booleans, others use 0/1 or strings.
* @param {*} value - Raw field value from GBFS JSON.
* @param {boolean} [fallback=true] - Default when value is null/undefined/unrecognized.
* @returns {boolean}
*/
function normalizeGbfsBool(value, fallback = true) {
if (value == null) return fallback;
if (typeof value === 'boolean') return value;
const num = Number(value);
if (Number.isFinite(num)) return num !== 0;
const text = String(value).trim().toLowerCase();
if (text === 'true' || text === 'yes') return true;
if (text === 'false' || text === 'no') return false;
return fallback;
}
/**
* Parse a value as a non-negative integer, returning fallback on failure.
* @param {*} value - Raw numeric value.
* @param {number|null} [fallback=null] - Returned when value is not a valid non-negative number.
* @returns {number|null}
*/
function toNonNegativeInteger(value, fallback = null) {
const n = Number(value);
if (!Number.isFinite(n) || n < 0) return fallback;
return Math.round(n);
}
/**
* Build a globally unique render key for a station.
* @param {string} cityId - City identifier.
@@ -39,6 +63,93 @@ export function createModel({ state: layerState, services, parts, source }) {
return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
/**
* Extract the stations array from a GBFS JSON payload.
* Handles multiple response shapes: { data: { stations: [...] } },
* { data: [...] }, and nested objects with a stations sub-key.
* @param {Object} payload - Parsed GBFS JSON response.
* @returns {Object[]} Array of raw station objects (may be empty).
*/
function extractStationsArray(payload) {
const data = payload?.data;
if (Array.isArray(data?.stations)) return data.stations;
if (Array.isArray(data)) return data;
// Some feeds nest stations under an additional key (e.g. locale wrappers)
if (data && typeof data === 'object') {
for (const value of Object.values(data)) {
if (Array.isArray(value?.stations)) return value.stations;
}
}
return [];
}
/**
* Parse a GBFS station_information payload into a Map of station metadata.
* Handles field-name variations across different GBFS providers
* (station_id vs id, lat vs latitude, etc.). Stations with missing or
* invalid coordinates are silently skipped.
* @param {Object} payload - Parsed station_information.json response.
* @returns {Map<string, Object>} Map of stationId to station info objects.
*/
function parseStationInformation(payload) {
const stations = extractStationsArray(payload);
const stationMap = new Map();
for (const raw of stations) {
// Accept both GBFS 2.x (station_id) and alternate (id) field names
const stationId = String(raw?.station_id ?? raw?.id ?? '').trim();
const lat = Number(raw?.lat ?? raw?.latitude);
const lon = Number(raw?.lon ?? raw?.longitude);
if (!stationId || !Number.isFinite(lat) || !Number.isFinite(lon))
continue;
stationMap.set(stationId, {
stationId,
name: String(raw?.name || raw?.short_name || '').trim(),
lat,
lon,
capacity: toNonNegativeInteger(raw?.capacity),
isInstalled: normalizeGbfsBool(raw?.is_installed, true),
isRenting: normalizeGbfsBool(raw?.is_renting, true),
isReturning: normalizeGbfsBool(raw?.is_returning, true),
});
}
return stationMap;
}
/**
* Parse a GBFS station_status payload into a Map of real-time availability.
* @param {Object} payload - Parsed station_status.json response.
* @returns {Map<string, Object>} Map of stationId to status objects containing
* bikesAvailable, docksAvailable, and operational flags.
*/
function parseStationStatus(payload) {
const stations = extractStationsArray(payload);
const statusMap = new Map();
for (const raw of stations) {
const stationId = String(raw?.station_id ?? raw?.id ?? '').trim();
if (!stationId) continue;
const bikes = toNonNegativeInteger(raw?.num_bikes_available);
const docks = toNonNegativeInteger(raw?.num_docks_available);
statusMap.set(stationId, {
stationId,
bikesAvailable: bikes,
docksAvailable: docks,
isInstalled: normalizeGbfsBool(raw?.is_installed, true),
isRenting: normalizeGbfsBool(raw?.is_renting, true),
isReturning: normalizeGbfsBool(raw?.is_returning, true),
lastReported: toNonNegativeInteger(raw?.last_reported),
});
}
return statusMap;
}
return {
normalizeGbfsBool,
toNonNegativeInteger,
-13
View File
@@ -51,18 +51,5 @@ export function createCctvSource({
},
getFrameUrl: frameUrlFor,
getMediaUrl: mediaUrlFor,
/** Read one current frame through the registered frame endpoint. */
async getFrame(camera, { signal } = {}) {
signal?.throwIfAborted();
const response = await fetchImpl(frameUrlFor(camera), {
cache: 'no-store',
signal,
});
if (!response.ok) throw new Error('Camera frame HTTP ' + response.status);
const bytes = new Uint8Array(await response.arrayBuffer());
signal?.throwIfAborted();
const type = response.headers.get('content-type') || '';
return { contentType: type.split(';')[0].trim().toLowerCase(), bytes };
},
};
}
-25
View File
@@ -148,28 +148,3 @@ test('camera construction is inert and destruction cancels a pending catalog and
assert.equal(a.getStats().count, 0);
assert.equal(b.getStats().count, 0);
});
test('frames are read through the registered frame endpoint', async () => {
const png = Uint8Array.from([0x89, 0x50, 0x4e, 0x47]);
const requested = [];
const source = createCctvSource({
fetchImpl: async (url, init) => {
requested.push({ url, cache: init.cache });
return new Response(png, {
headers: { 'Content-Type': 'Image/PNG; charset=binary' },
});
},
});
const frame = await source.getFrame(camera);
assert.equal(frame.contentType, 'image/png');
assert.deepEqual([...frame.bytes], [...png]);
assert.equal(
requested[0].url.split('?')[0],
'/api/cctv/frame/pack%2Fcamera%20%3Fx',
);
assert.equal(requested[0].cache, 'no-store');
const failing = createCctvSource({
fetchImpl: async () => new Response(null, { status: 502 }),
});
await assert.rejects(failing.getFrame(camera), /Camera frame HTTP 502/);
});
+8 -14
View File
@@ -101,7 +101,7 @@ export function createInstallationSource({
} = {}) {
let overpassUnavailable = false;
const installationIds = new Map();
async function getNamedSites(box, signal, thinned = true) {
async function getNamedSites(box, signal) {
const names = await waitForSignal(loadNames(), signal).catch((cause) => {
signal?.throwIfAborted();
throw Object.assign(
@@ -112,13 +112,7 @@ export function createInstallationSource({
);
});
signal?.throwIfAborted();
// The map keeps one site per display cell; queries can ask for all.
const { records, count } = militaryNamesInView(
names,
box,
thinned ? undefined : Infinity,
thinned,
);
const { records, count } = militaryNamesInView(names, box);
return {
records,
namedInView: count,
@@ -130,9 +124,9 @@ export function createInstallationSource({
source: 'OpenStreetMap',
};
}
async function getTileSites(box, signal, thinned) {
async function getTileSites(box, signal) {
const zoom = installationTileZoom(box);
if (zoom === null) return getNamedSites(box, signal, thinned);
if (zoom === null) return getNamedSites(box, signal);
let names = null;
const namesJob = waitForSignal(loadNames(), signal)
.then((loaded) => {
@@ -187,7 +181,7 @@ export function createInstallationSource({
mapTiles.clear();
installationIds.clear();
},
async getMappedSites(box, { exact = false, thinned = true, signal } = {}) {
async getMappedSites(box, { exact = false, signal } = {}) {
const { south, west, north, east } = box || {};
if (
![south, west, north, east].every(Number.isFinite) ||
@@ -203,8 +197,8 @@ export function createInstallationSource({
throw new TypeError('A bounded installation viewport is required');
signal?.throwIfAborted();
if (east < west || north - south > 10 || east - west > 10)
return getNamedSites(box, signal, thinned);
if (overpassUnavailable) return getTileSites(box, signal, thinned);
return getNamedSites(box, signal);
if (overpassUnavailable) return getTileSites(box, signal);
const query = new URLSearchParams(
Object.entries({ south, west, north, east }).map(([key, value]) => [
key,
@@ -219,7 +213,7 @@ export function createInstallationSource({
signal?.throwIfAborted();
if (isUnavailableCapability(body)) {
overpassUnavailable = true;
return getTileSites(box, signal, thinned);
return getTileSites(box, signal);
}
if (!response.ok)
throw Object.assign(
+9 -17
View File
@@ -2,24 +2,16 @@
export function createLaunchSource({
fetchImpl = (...args) => globalThis.fetch(...args),
} = {}) {
/** The launch payload, and whether the proxy served it from a stale cache. */
async function getLaunchSnapshot({ signal } = {}) {
signal?.throwIfAborted();
const response = await fetchImpl('/api/launches', { signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload = await response.json();
signal?.throwIfAborted();
if (!Array.isArray(payload) && !Array.isArray(payload?.results))
throw new Error('Malformed launch snapshot');
return {
payload,
stale: response.headers?.get?.('x-gev-cache') === 'STALE-ERROR',
};
}
return {
getLaunchSnapshot,
async getLaunches(options) {
return (await getLaunchSnapshot(options)).payload;
async getLaunches({ signal } = {}) {
signal?.throwIfAborted();
const response = await fetchImpl('/api/launches', { signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload = await response.json();
signal?.throwIfAborted();
if (!Array.isArray(payload) && !Array.isArray(payload?.results))
throw new Error('Malformed launch snapshot');
return payload;
},
async getActiveTle({ signal } = {}) {
signal?.throwIfAborted();
+18 -2
View File
@@ -8,7 +8,6 @@ import {
twoline2satrec,
} from 'satellite.js';
import { findNextSatellitePass } from '../../data/satellitePass.js';
import { parseTleText } from '../../sources/tle.js';
import { ORBIT_PATH_STEPS, ISS_NORAD } from './policy.js';
export function createOrbits({ state: layerState, services, parts, source }) {
@@ -41,7 +40,24 @@ export function createOrbits({ state: layerState, services, parts, source }) {
/**
* Parse TLE text into array of { name, line1, line2 } objects.
*/
const parseTLE = parseTleText;
function parseTLE(text) {
const lines = text
.trim()
.split('\n')
.map((l) => l.trim())
.filter((l) => l.length > 0);
const result = [];
for (let i = 0; i < lines.length - 2; i += 3) {
const name = lines[i];
const line1 = lines[i + 1];
const line2 = lines[i + 2];
if (line1.startsWith('1 ') && line2.startsWith('2 ')) {
result.push({ name, line1, line2 });
}
}
return result;
}
/**
* Propagate satellite position at a given JS Date.
+1 -4
View File
@@ -19,10 +19,7 @@ export function createSatelliteSource({
const response = await fetchImpl(`/api/celestrak/${group}`, { signal });
const text = response.ok ? await response.text() : '';
signal?.throwIfAborted();
// The proxy serves its last copy, marked STALE-ERROR, when CelesTrak
// is down.
const stale = response.headers?.get?.('x-tle-cache') === 'STALE-ERROR';
return { ok: response.ok, status: response.status, text, stale };
return { ok: response.ok, status: response.status, text };
},
};
}
+14 -24
View File
@@ -25,31 +25,21 @@ export function createFlowTileSource({
maxTiles: 16,
});
let partial = false;
/**
* Flow segments for bounds, with whether any covering tile failed to load
* for this request.
*/
async function fetchFlowDetail(bounds, { signal, zoom = 12 } = {}) {
const result = await tiles.fetchBounds(bounds, {
zoom,
signal,
tiles: bounds.coverage?.coarse,
});
partial = result.partial;
const segments = bounds.coverage
? result.tiles.flat()
: result.tiles.flat().flatMap((segment) =>
clipTileLine(segment.coords, bounds).map((coords) => ({
...segment,
coords,
})),
);
return { segments, partial: result.partial === true };
}
return {
fetchFlowDetail,
async fetchFlowForBounds(bounds, options) {
return (await fetchFlowDetail(bounds, options)).segments;
async fetchFlowForBounds(bounds, { signal, zoom = 12 } = {}) {
const result = await tiles.fetchBounds(bounds, {
zoom,
signal,
tiles: bounds.coverage?.coarse,
});
partial = result.partial;
if (bounds.coverage) return result.tiles.flat();
return result.tiles.flat().flatMap((segment) =>
clipTileLine(segment.coords, bounds).map((coords) => ({
...segment,
coords,
})),
);
},
getFlowSessionStats: () => ({ ...tiles.getStats(), partial }),
resetFlowTileCache: () => tiles.clear(),
-15
View File
@@ -5,21 +5,6 @@ export function createTransitSource({
fetchImpl = (...args) => fetch(...args),
} = {}) {
return {
/** Read the public catalog of enabled feeds. */
async getFeeds({ signal } = {}) {
signal?.throwIfAborted();
const response = await fetchImpl('/api/transit/feeds', {
signal,
headers: { Accept: 'application/json' },
});
if (!response.ok)
throw new Error('Transit feeds HTTP ' + response.status);
const body = await response.json();
signal?.throwIfAborted();
if (!Array.isArray(body?.feeds))
throw new Error('Malformed transit feed catalog');
return body.feeds;
},
getHistory(feedId, vehicleId, { signal } = {}) {
signal?.throwIfAborted();
return fetchTransitHistory(feedId, vehicleId, signal, fetchImpl);
-46
View File
@@ -1,46 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createTransitSource } from './source.js';
const json = (body, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
test('the feed catalog is read from the public catalog route', async () => {
const requested = [];
const feeds = [
{
id: 'mbta',
name: 'MBTA',
center: { lat: 42.36, lon: -71.06 },
loadRadiusKm: 60,
},
];
const source = createTransitSource({
fetchImpl: async (url) => {
requested.push(url);
return json({ feeds });
},
});
assert.deepEqual(await source.getFeeds(), feeds);
assert.deepEqual(requested, ['/api/transit/feeds']);
});
test('feed catalog failures are reported', async () => {
const failing = createTransitSource({ fetchImpl: async () => json({}, 503) });
await assert.rejects(failing.getFeeds(), /Transit feeds HTTP 503/);
const malformed = createTransitSource({
fetchImpl: async () => json({ feeds: {} }),
});
await assert.rejects(malformed.getFeeds(), /Malformed transit feed catalog/);
const aborted = new AbortController();
aborted.abort();
await assert.rejects(
createTransitSource({
fetchImpl: async () => json({ feeds: [] }),
}).getFeeds({ signal: aborted.signal }),
(error) => error.name === 'AbortError',
);
});
+67 -15
View File
@@ -18,6 +18,17 @@ const STOPS = [
[70, '#ff00ff'],
[80, '#05ede0'],
];
const RAINVIEWER_STOPS = [
[10, '#cec087'],
[20, '#00a3e0'],
[30, '#005588'],
[35, '#ffee00'],
[40, '#ffaa00'],
[45, '#ff4400'],
[50, '#c10000'],
[55, '#ffaaff'],
[65, '#ffffff'],
];
const LIGHTNING_STOPS = [
['0.1', '#FFFFCC'],
['1', '#FFA400'],
@@ -255,7 +266,9 @@ export function createWeatherLayer({
? 'Lightning density'
: 'Satellite clouds',
icon: radar ? '◉' : lightning ? 'ϟ' : '☁',
source: 'NOAA nowCOAST · OBSERVED',
get source() {
return `${product === 'radar-global' ? 'RainViewer' : 'NOAA nowCOAST'} · OBSERVED`;
},
updateInterval: lightning ? 600_000 : 120_000,
init(nextViewer) {
viewer = nextViewer;
@@ -392,8 +405,9 @@ export function createWeatherLayer({
rendering?.setAlpha(opacity === 'light' ? 0.4 : satellite ? 0.7 : 0.8);
}
if (
satellite &&
['clouds', 'clouds-regional'].includes(params.product) &&
((satellite &&
['clouds', 'clouds-regional'].includes(params.product)) ||
(radar && ['radar', 'radar-global'].includes(params.product))) &&
params.product !== product
) {
product = params.product;
@@ -475,7 +489,7 @@ export function createWeatherLayer({
},
getParams() {
return radar
? { opacity }
? { product, opacity }
: satellite
? { product, opacity, infrared }
: { product, opacity };
@@ -539,12 +553,16 @@ export function createWeatherLayer({
readout: true,
summary: {
label: radar
? 'Rain radar · US'
? product === 'radar-global'
? 'Rain radar · Global'
: 'Rain radar · US'
: lightning
? 'Lightning density · 15 min'
: 'Satellite clouds',
coverage: radar
? 'CONUS'
? product === 'radar-global'
? 'Global · where radars exist'
: 'CONUS'
: lightning
? 'Americas + Pacific'
: product === 'clouds'
@@ -574,6 +592,21 @@ export function createWeatherLayer({
units: radar ? 'dBZ' : lightning ? 'strikes/km²/min ×10³' : '',
},
chips: [
...(radar
? [
['radar', 'US (NOAA)'],
['radar-global', 'Global (RainViewer)'],
].map(([value, label]) => ({
id: value,
label,
active: product === value,
params: { product: value },
title:
value === 'radar'
? 'NOAA MRMS, contiguous US, about 4-minute updates'
: 'RainViewer global composite from national radar networks, past 2 hours',
}))
: []),
...(satellite
? [
['clouds-regional', 'N. America'],
@@ -618,7 +651,9 @@ export function createWeatherLayer({
{
id: 'coverage',
label: radar
? 'View US radar'
? product === 'radar-global'
? 'View coverage'
: 'View US radar'
: lightning
? 'View Americas & Pacific'
: 'View coverage',
@@ -628,7 +663,12 @@ export function createWeatherLayer({
],
legend:
radar || lightning
? (lightning ? LIGHTNING_STOPS : STOPS).map(([label, color]) => ({
? (lightning
? LIGHTNING_STOPS
: product === 'radar-global'
? RAINVIEWER_STOPS
: STOPS
).map(([label, color]) => ({
label: String(label),
color,
blurb: lightning
@@ -638,14 +678,26 @@ export function createWeatherLayer({
: [],
info: hostHidden
? hostStatus
: `${radar ? 'RADAR REFLECTIVITY · dBZ' : lightning ? 'LIGHTNING DENSITY · 15 min accumulation' : product === 'clouds' ? 'GLOBAL INFRARED · hourly' : 'GOES INFRARED · ~5 min'}\n${time ? `${followLatest ? 'Latest observation' : 'History'}: ${utc(time)}\n${lag}${current && !followLatest ? ` · frame ${index + 1}/${times.length}` : ''}${loading ? ' · loading' : ''}` : `Observation: unavailable${loading ? ' · loading' : ''}`}${missing ? `\n${missing}` : ''}${manifest?.stale ? '\nSTALE · cached source metadata' : ''}${error || diagnostic?.error ? '\n' + (error || diagnostic.error) : ''}\n${radar ? 'Contiguous US · gaps ≠ no rain' : lightning ? 'Americas + Pacific · not individual strikes\nColor: strikes/km²/min ×10³' : product === 'clouds' ? '60°S–60°N · typically 2–3 h delayed' : 'North America · infrared imagery'}${outside ? '\nMap center is outside source coverage' : ''}${motion?.matches ? (clock ? '\nReduced motion · history playback unavailable' : '\nReduced motion · manual history available') : ''}`,
infoTitle: lightning
? 'NOAA/NWS 15-minute lightning density derived from Vaisala NLDN/GLD360. Coverage 110°E across the Pacific/Americas to 0°, 25°S–80°N. Not a live strike count, global coverage or a safety warning.'
: radar
? 'NOAA MRMS radar echoes indicate precipitation patterns, not rain rate, a storm warning or a future forecast. Native source approximately 1 km; display is limited to level 6. Frames use exact advertised observation times.'
: 'GOES-19/18 longwave infrared Band 14 regional; NESDIS global longwave mosaic. Clouds only dims everything but bright, cold cloud tops; a brightness filter, not a cloud mask. Coverage and freshness differ by region.',
: `${radar ? 'RADAR REFLECTIVITY · dBZ' : lightning ? 'LIGHTNING DENSITY · 15 min accumulation' : product === 'clouds' ? 'GLOBAL INFRARED · hourly' : 'GOES INFRARED · ~5 min'}\n${time ? `${followLatest ? 'Latest observation' : 'History'}: ${utc(time)}\n${lag}${current && !followLatest ? ` · frame ${index + 1}/${times.length}` : ''}${loading ? ' · loading' : ''}` : `Observation: unavailable${loading ? ' · loading' : ''}`}${missing ? `\n${missing}` : ''}${manifest?.stale ? '\nSTALE · cached source metadata' : ''}${error || diagnostic?.error ? '\n' + (error || diagnostic.error) : ''}\n${radar ? (product === 'radar-global' ? 'Global · where radars exist · gaps ≠ no rain' : 'Contiguous US · gaps ≠ no rain') : lightning ? 'Americas + Pacific · not individual strikes\nColor: strikes/km²/min ×10³' : product === 'clouds' ? '60°S–60°N · typically 2–3 h delayed' : 'North America · infrared imagery'}${outside ? '\nMap center is outside source coverage' : ''}${motion?.matches ? (clock ? '\nReduced motion · history playback unavailable' : '\nReduced motion · manual history available') : ''}`,
infoTitle:
product === 'radar-global'
? 'RainViewer global radar composite (dBZ). Coverage follows national radar networks; a gap does not mean no rain.'
: lightning
? 'NOAA/NWS 15-minute lightning density derived from Vaisala NLDN/GLD360. Coverage 110°E across the Pacific/Americas to 0°, 25°S–80°N. Not a live strike count, global coverage or a safety warning.'
: radar
? 'NOAA MRMS radar echoes indicate precipitation patterns, not rain rate, a storm warning or a future forecast. Native source approximately 1 km; display is limited to level 6. Frames use exact advertised observation times.'
: 'GOES-19/18 longwave infrared Band 14 regional; NESDIS global longwave mosaic. Clouds only dims everything but bright, cold cloud tops; a brightness filter, not a cloud mask. Coverage and freshness differ by region.',
};
controls.summary.settings = [
...(radar
? [
{
id: 'region',
label: 'REGION',
chips: controls.chips.filter(({ params }) => params.product),
},
]
: []),
...(satellite
? [
{
@@ -682,7 +734,7 @@ export function createWeatherLayer({
loading,
error: error || rendering?.getDiagnostics().error || null,
stale: Boolean(manifest?.stale || observationDelayed()),
source: 'NOAA nowCOAST',
source: product === 'radar-global' ? 'RainViewer' : 'NOAA nowCOAST',
observedAt: shownTime(),
};
},
+15
View File
@@ -1,5 +1,6 @@
import { readResponseBytesCapped } from '../../sources/httpBody.js';
import { weatherImageUrl } from './source.js';
import { composeMercatorImage } from './mercatorMosaic.js';
import { infraredAlpha } from './infraredAlpha.js';
export const MAX_MOSAIC_BYTES = 4 * 1024 * 1024;
@@ -76,6 +77,20 @@ export async function acquireWeatherImage(
onFetched = () => {},
},
) {
if (product === 'radar-global') {
const result = await composeMercatorImage({
product,
time,
bbox,
size,
fetchImpl,
createCanvas,
decodeImage,
signal,
});
onFetched();
return result;
}
const response = await fetchImpl(weatherImageUrl(product, time, size, bbox), {
signal,
});
+212
View File
@@ -0,0 +1,212 @@
import { readResponseBytesCapped } from '../../sources/httpBody.js';
import { weatherTileUrl, WEATHER_IMAGE_SIZES } from './source.js';
import { decodeInfraredImage } from './infraredImage.js';
export const MERCATOR_LATITUDE = 85.0511;
export const RAINVIEWER_BOUNDS = Object.freeze({
west: -180,
south: -MERCATOR_LATITUDE,
east: 180,
north: MERCATOR_LATITUDE,
});
const wait = (ms, signal) =>
new Promise((resolve, reject) => {
const timer = setTimeout(resolve, ms);
signal.addEventListener(
'abort',
() => {
clearTimeout(timer);
reject(signal.reason);
},
{ once: true },
);
});
/** Clamp a non-wrapping geographic window to the Mercator domain. */
export function mercatorBbox(bbox = RAINVIEWER_BOUNDS) {
const box = { ...bbox };
if (
!['west', 'south', 'east', 'north'].every((key) =>
Number.isFinite(box[key]),
) ||
box.west < -180 ||
box.east > 180 ||
box.west >= box.east
)
throw new Error('Invalid Mercator window');
box.south = Math.max(-MERCATOR_LATITUDE, box.south);
box.north = Math.min(MERCATOR_LATITUDE, box.north);
if (box.south >= box.north) throw new Error('Invalid Mercator window');
return box;
}
/** Normalized Web Mercator row, increasing southward. */
export function latitudeToMercatorRow(latitude) {
const radians =
(Math.max(-MERCATOR_LATITUDE, Math.min(MERCATOR_LATITUDE, latitude)) *
Math.PI) /
180;
return (1 - Math.asinh(Math.tan(radians)) / Math.PI) / 2;
}
/** Latitude of a normalized Web Mercator row. */
export function mercatorRowToLatitude(row) {
return (Math.atan(Math.sinh(Math.PI * (1 - 2 * row))) * 180) / Math.PI;
}
/** Inclusive XYZ tile range; an exact east/south edge does not add a tile. */
export function mercatorTileRange(bbox, z) {
const b = mercatorBbox(bbox);
const n = 2 ** z;
const edge = (value) => Math.max(0, Math.min(n - 1, value));
const west = edge(Math.floor(((b.west + 180) / 360) * n));
const east = edge(Math.ceil(((b.east + 180) / 360) * n) - 1);
const north = edge(Math.floor(latitudeToMercatorRow(b.north) * n));
const south = edge(Math.ceil(latitudeToMercatorRow(b.south) * n) - 1);
return {
west,
east,
north,
south,
count: (east - west + 1) * (south - north + 1),
};
}
/** Highest permitted zoom that fits both the tile and horizontal pixel budgets. */
export function chooseMercatorZoom(bbox, size, maxTiles = 24) {
const b = mercatorBbox(bbox);
for (let z = 7; z >= 0; z--) {
if (
mercatorTileRange(b, z).count <= maxTiles &&
((b.east - b.west) / 360) * 256 * 2 ** z <= 2 * size.width
)
return z;
}
throw new Error('Mercator image budget too small');
}
/** Source pixel row at the centre of an equirectangular output row. */
export function mercatorSourceRow(row, height, bbox, z, tileNorth) {
const latitude =
bbox.north - ((row + 0.5) / height) * (bbox.north - bbox.south);
return latitudeToMercatorRow(latitude) * 256 * 2 ** z - tileNorth * 256;
}
/** Compose bounded same-origin XYZ tiles and reproject with one strip per row. */
export async function composeMercatorImage({
product = 'radar-global',
time,
bbox = RAINVIEWER_BOUNDS,
size = WEATHER_IMAGE_SIZES['radar-global'],
fetchImpl = (...args) => globalThis.fetch(...args),
createCanvas = () => document.createElement('canvas'),
decodeImage = decodeInfraredImage,
signal = new AbortController().signal,
maxTiles = 24,
concurrency = 6,
sleep = wait,
}) {
signal.throwIfAborted();
if (
product !== 'radar-global' ||
!Number.isInteger(concurrency) ||
concurrency < 1 ||
!Number.isInteger(maxTiles) ||
maxTiles < 1 ||
!Number.isInteger(size.width) ||
!Number.isInteger(size.height) ||
size.width < 1 ||
size.height < 1 ||
size.width > 4096 ||
size.height > 2048
)
throw new Error('Invalid Mercator image options');
const box = mercatorBbox(bbox ?? RAINVIEWER_BOUNDS);
const z = chooseMercatorZoom(box, size, maxTiles);
const range = mercatorTileRange(box, z);
const mosaic = createCanvas();
mosaic.width = (range.east - range.west + 1) * 256;
mosaic.height = (range.south - range.north + 1) * 256;
const context = mosaic.getContext('2d');
const template = weatherTileUrl(product, time);
const tiles = [];
for (let y = range.north; y <= range.south; y++)
for (let x = range.west; x <= range.east; x++) tiles.push({ x, y });
let next = 0,
loaded = 0,
decodeMs = 0;
async function worker() {
while (next < tiles.length && !signal.aborted) {
const { x, y } = tiles[next++];
let image;
try {
const url = template
.replace('{z}', z)
.replace('{x}', x)
.replace('{y}', y);
let response = await fetchImpl(url, { signal });
// The proxy meters RainViewer; wait out a 429 rather than baking a hole
// into a frame that is then cached.
for (let retry = 0; response.status === 429 && retry < 3; retry++) {
await response.body?.cancel();
const seconds = Number(response.headers.get('retry-after'));
await sleep(
Math.min(
15,
Number.isFinite(seconds) && seconds > 0 ? seconds : 2,
) * 1000,
signal,
);
response = await fetchImpl(url, { signal });
}
if (!response.ok) throw new Error('Weather tile unavailable');
const bytes = await readResponseBytesCapped(response, 1024 * 1024);
signal.throwIfAborted();
const started = performance.now();
image = await decodeImage(
new Blob([bytes], { type: 'image/png' }),
signal,
);
signal.throwIfAborted();
if (image.width !== 256 || image.height !== 256)
throw new Error('Invalid weather tile dimensions');
context.drawImage(
image,
(x - range.west) * 256,
(y - range.north) * 256,
);
decodeMs += performance.now() - started;
loaded++;
} catch {
// Missing tiles stay transparent; cancellation is checked after all workers settle.
} finally {
image?.close?.();
}
}
}
await Promise.all(
Array.from({ length: Math.min(concurrency, tiles.length) }, worker),
);
signal.throwIfAborted();
if (!loaded) throw new Error('Weather tiles unavailable');
const texture = createCanvas();
texture.width = size.width;
texture.height = size.height;
const output = texture.getContext('2d');
const scale = 256 * 2 ** z;
const sx = ((box.west + 180) / 360) * scale - range.west * 256;
const sw = ((box.east - box.west) / 360) * scale;
const started = performance.now();
for (let row = 0; row < size.height; row++) {
const sy = Math.max(
0,
Math.min(
mosaic.height - 1,
mercatorSourceRow(row, size.height, box, z, range.north) - 0.5,
),
);
output.drawImage(mosaic, sx, sy, sw, 1, 0, row, size.width, 1);
}
return { texture, decodeMs: decodeMs + performance.now() - started };
}
+244
View File
@@ -0,0 +1,244 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
composeMercatorImage,
mercatorBbox,
mercatorTileRange,
chooseMercatorZoom,
latitudeToMercatorRow,
mercatorRowToLatitude,
mercatorSourceRow,
RAINVIEWER_BOUNDS,
} from './mercatorMosaic.js';
import { acquireWeatherImage } from './infraredImage.js';
import { validateWeatherSnapshot } from './source.js';
const time = '2026-09-23T12:00:00.000Z';
const near = (a, b) => assert.ok(Math.abs(a - b) < 1e-8, `${a} ≈ ${b}`);
function canvas() {
const value = { draws: [] };
value.getContext = () => ({ drawImage: (...args) => value.draws.push(args) });
return value;
}
const options = {
product: 'radar-global',
time,
createCanvas: canvas,
fetchImpl: async () => new Response(new Uint8Array([1])),
decodeImage: async () => ({ width: 256, height: 256 }),
};
test('Mercator maths preserve XYZ edges, latitude direction and zoom budgets', () => {
near(latitudeToMercatorRow(0), 0.5);
near(latitudeToMercatorRow(60), 0.2903996408605086);
near(latitudeToMercatorRow(-60), 0.7096003591394914);
for (const lat of [-85, -60, 0, 60, 85])
near(mercatorRowToLatitude(latitudeToMercatorRow(lat)), lat);
assert.deepEqual(
mercatorBbox({ ...RAINVIEWER_BOUNDS, north: 90, south: -90 }),
RAINVIEWER_BOUNDS,
);
assert.deepEqual(mercatorTileRange(RAINVIEWER_BOUNDS, 1), {
west: 0,
east: 1,
north: 0,
south: 1,
count: 4,
});
assert.deepEqual(
mercatorTileRange({ west: -180, east: 0, north: 60, south: 0 }, 1),
{ west: 0, east: 0, north: 0, south: 0, count: 1 },
);
assert.equal(chooseMercatorZoom(RAINVIEWER_BOUNDS, { width: 2048 }), 2);
assert.equal(chooseMercatorZoom(RAINVIEWER_BOUNDS, { width: 128 }), 0);
const detail = { west: 10, east: 11, south: 40, north: 41 };
assert.equal(chooseMercatorZoom(detail, { width: 4096 }), 7);
assert.throws(() => mercatorBbox({ ...detail, east: -10 }));
});
test('2×2 fake tiles compose north-up and row strips reproject the equator and ±60°', async () => {
const urls = [],
images = [];
const bbox = { west: -180, east: 180, north: 60.5, south: -60.5 };
const { texture } = await composeMercatorImage({
...options,
bbox,
size: { width: 512, height: 121 },
maxTiles: 4,
fetchImpl: async (url) => {
urls.push(url);
return new Response('tile');
},
decodeImage: async () => {
const image = {
width: 256,
height: 256,
close() {
this.closed = true;
},
};
images.push(image);
return image;
},
});
assert.equal(urls.length, 4);
assert.ok(
urls.every(
(url) =>
url.startsWith('/api/weather/tile?product=radar-global&') &&
url.includes('&z=1&'),
),
);
const mosaic = texture.draws[0][0];
assert.equal(mosaic.width, 512);
assert.equal(mosaic.height, 512);
assert.deepEqual(
mosaic.draws.map((draw) => draw.slice(1)).sort(),
[
[0, 0],
[0, 256],
[256, 0],
[256, 256],
].sort(),
);
assert.equal(texture.draws.length, 121);
for (const [row, latitude] of [
[0, 60],
[60, 0],
[120, -60],
]) {
near(
mercatorSourceRow(row, 121, bbox, 1, 0),
latitudeToMercatorRow(latitude) * 512,
);
const [, sx, sy, sw, sh, dx, dy, dw, dh] = texture.draws[row];
near(sy, latitudeToMercatorRow(latitude) * 512 - 0.5);
assert.deepEqual([sx, sw, sh, dx, dy, dw, dh], [0, 512, 1, 0, row, 512, 1]);
}
assert.ok(images.every((image) => image.closed));
});
test('mosaic limits concurrency, tolerates partial failure, releases decoded images and rejects total failure', async () => {
let active = 0,
peak = 0,
calls = 0,
closed = 0;
const result = await composeMercatorImage({
...options,
maxTiles: 4,
concurrency: 2,
fetchImpl: async () => {
active++;
peak = Math.max(peak, active);
const n = ++calls;
await new Promise((resolve) => setImmediate(resolve));
active--;
return n === 1 ? new Response('', { status: 503 }) : new Response('tile');
},
decodeImage: async () => ({
width: 256,
height: 256,
close() {
closed++;
},
}),
});
assert.equal(peak, 2);
assert.equal(closed, 3);
assert.equal(result.texture.draws[0][0].draws.length, 3);
await assert.rejects(
composeMercatorImage({
...options,
fetchImpl: async () => new Response('', { status: 429 }),
sleep: async () => {},
}),
/tiles unavailable/,
);
await assert.rejects(
composeMercatorImage({
...options,
signal: AbortSignal.abort(),
fetchImpl: () => assert.fail('aborted'),
}),
{ name: 'AbortError' },
);
const controller = new AbortController();
closed = 0;
await assert.rejects(
composeMercatorImage({
...options,
signal: controller.signal,
concurrency: 1,
decodeImage: async () => {
controller.abort();
return {
width: 256,
height: 256,
close() {
closed++;
},
};
},
}),
{ name: 'AbortError' },
);
assert.equal(closed, 1);
});
test('whole and detail RainViewer images use composition and strict source metadata', async () => {
for (const bbox of [null, { west: 10, east: 16, south: 40, north: 43 }]) {
const result = await acquireWeatherImage('radar-global', time, {
...options,
bbox,
size: { width: 2048, height: 1024 },
});
assert.equal(result.texture.width, 2048);
assert.equal(result.texture.height, 1024);
}
const snapshot = {
schemaVersion: 1,
product: 'radar-global',
bounds: RAINVIEWER_BOUNDS,
times: [time],
latest: time,
tileSize: 256,
maxLevel: 7,
tilingScheme: 'web-mercator',
};
assert.equal(validateWeatherSnapshot(snapshot, 'radar-global'), snapshot);
for (const patch of [
{ maxLevel: 6 },
{ maxLevel: 8 },
{ tilingScheme: 'geographic' },
{ bounds: { ...RAINVIEWER_BOUNDS, north: 86 } },
])
assert.throws(() =>
validateWeatherSnapshot({ ...snapshot, ...patch }, 'radar-global'),
);
assert.throws(() =>
validateWeatherSnapshot({ ...snapshot, product: 'radar' }, 'radar'),
);
});
test('a metered tile waits out Retry-After instead of leaving a hole', async () => {
const seen = new Map();
const waits = [];
const result = await composeMercatorImage({
...options,
bbox: { west: -180, south: -85.0511, east: 180, north: 85.0511 },
size: { width: 512, height: 256 },
maxTiles: 1,
fetchImpl: async (url) => {
const count = (seen.get(url) ?? 0) + 1;
seen.set(url, count);
return count === 1
? new Response('', { status: 429, headers: { 'Retry-After': '3' } })
: new Response(new Uint8Array([1]));
},
sleep: async (ms) => {
waits.push(ms);
},
});
assert.deepEqual(waits, [3000]);
assert.equal(result.texture.draws[0][0].draws.length, 1);
});
+27 -17
View File
@@ -88,7 +88,10 @@ function createGlobeRendering({
? cesium.Rectangle.intersection(bounds, view)
: bounds;
if (!coverage) return [];
const scheme = new cesium.GeographicTilingScheme();
const scheme =
snapshot.tilingScheme === 'web-mercator'
? new cesium.WebMercatorTilingScheme()
: new cesium.GeographicTilingScheme();
const template = weatherTileUrl(snapshot.product, time, {
size: profile(snapshot.product).tileSize,
});
@@ -248,7 +251,7 @@ function createGlobeRendering({
priority:
snapshot.product === 'lightning'
? 3
: snapshot.product === 'radar'
: ['radar', 'radar-global'].includes(snapshot.product)
? 2
: 1,
product: snapshot.product,
@@ -324,21 +327,26 @@ function createGlobeRendering({
viewer.scene.requestRender();
return;
}
const tilingScheme = new cesium.GeographicTilingScheme(
global
? {
rectangle,
numberOfLevelZeroTilesX: 2,
numberOfLevelZeroTilesY: 1,
}
: undefined,
);
const mercator = snapshot.tilingScheme === 'web-mercator';
const tilingScheme = mercator
? new cesium.WebMercatorTilingScheme()
: new cesium.GeographicTilingScheme(
global
? {
rectangle,
numberOfLevelZeroTilesX: 2,
numberOfLevelZeroTilesY: 1,
}
: undefined,
);
const credit = new cesium.Credit(
snapshot.product === 'lightning'
? 'NOAA/NWS lightning density · derived from Vaisala NLDN/GLD360'
: snapshot.product === 'radar'
? 'NOAA nowCOAST · NWS/OAR MRMS'
: 'NOAA nowCOAST · NESDIS GOES / global satellite partners',
snapshot.product === 'radar-global'
? '<a href="https://www.rainviewer.com">RainViewer</a>'
: snapshot.product === 'lightning'
? 'NOAA/NWS lightning density · derived from Vaisala NLDN/GLD360'
: snapshot.product === 'radar'
? 'NOAA nowCOAST · NWS/OAR MRMS'
: 'NOAA nowCOAST · NESDIS GOES / global satellite partners',
false,
);
const provider = global
@@ -360,7 +368,9 @@ function createGlobeRendering({
rectangle,
tileWidth: nextProfile.tileSize,
tileHeight: nextProfile.tileSize,
maximumLevel: nextProfile.maximumLevel,
maximumLevel: mercator
? Math.min(nextProfile.maximumLevel, 7)
: nextProfile.maximumLevel,
enablePickFeatures: false,
credit,
});
+1
View File
@@ -8,6 +8,7 @@ export const WEATHER_SHELL_HEIGHTS = Object.freeze({
clouds: 5_500,
'clouds-regional': 5_800,
radar: 6_200,
'radar-global': 6_200,
lightning: 6_600,
});
// Decoded canvases per renderer, full-extent and detail images alike: the shown
@@ -126,6 +126,7 @@ test('shell heights stack every product with lightning highest', () => {
clouds: 5_500,
'clouds-regional': 5_800,
radar: 6_200,
'radar-global': 6_200,
lightning: 6_600,
});
assert.equal(WEATHER_SHELL_CACHE_BYTES, 128 * 1024 * 1024);
@@ -1502,3 +1503,37 @@ test('global infrared keeps one full-extent image; detail images halve for small
});
small.shell.clear();
});
test('RainViewer shell composes full and detail textures on one raised surface', async () => {
const view = camera(10, 40, 12, 41);
const h = harness({
product: 'radar-global',
camera: view,
decodeImage: async () => ({ width: 256, height: 256, close() {} }),
});
const global = snapshot('radar-global', {
bounds: { west: -180, south: -85.0511, east: 180, north: 85.0511 },
});
const pending = h.shell.setFrame(global, times[0]);
for (let i = 0; i < 10; i++) {
await flush();
h.render();
}
assert.equal(await pending, true);
for (let i = 0; i < 10; i++) {
await flush();
h.render();
}
assert.equal(h.primitives().length, 1);
assert.equal(h.material().uniforms.image.width, 2048);
assert.equal(h.material().uniforms.detail.width, 4096);
assert.ok(h.fetches.length > 16 && h.fetches.length <= 40);
assert.ok(
h.fetches.every(({ url }) =>
url.startsWith('/api/weather/tile?product=radar-global&'),
),
);
assert.equal(h.shell.getDiagnostics().shell.height, 6200);
h.shell.clear();
assert.equal(h.primitives().length, 0);
});
+11 -19
View File
@@ -2,6 +2,7 @@ import { readResponseJsonCapped } from '../../sources/httpBody.js';
export const WEATHER_PRODUCTS = Object.freeze([
'radar',
'radar-global',
'clouds',
'clouds-regional',
'lightning',
@@ -20,8 +21,8 @@ export function validateWeatherSnapshot(value, product) {
) ||
bounds.west < -180 ||
bounds.east > 180 ||
bounds.south < -90 ||
bounds.north > 90 ||
bounds.south < (product === 'radar-global' ? -85.0511 : -90) ||
bounds.north > (product === 'radar-global' ? 85.0511 : 90) ||
bounds.west >= bounds.east ||
bounds.south >= bounds.north ||
!Array.isArray(times) ||
@@ -37,8 +38,9 @@ export function validateWeatherSnapshot(value, product) {
) ||
value.latest !== times.at(-1) ||
value.tileSize !== 256 ||
value.maxLevel !== 6 ||
value.tilingScheme !== 'geographic'
value.maxLevel !== (product === 'radar-global' ? 7 : 6) ||
value.tilingScheme !==
(product === 'radar-global' ? 'web-mercator' : 'geographic')
)
throw new Error('Malformed weather manifest');
return value;
@@ -46,6 +48,7 @@ export function validateWeatherSnapshot(value, product) {
/** Largest whole-extent image per product; also the proxy default size. */
export const WEATHER_IMAGE_SIZES = Object.freeze({
'radar-global': Object.freeze({ width: 2048, height: 1024 }),
radar: Object.freeze({ width: 4096, height: 2048 }),
'clouds-regional': Object.freeze({ width: 4096, height: 2048 }),
clouds: Object.freeze({ width: 2048, height: 1024 }),
@@ -94,7 +97,10 @@ export function weatherImageUrl(
export function weatherTileUrl(product, time, { size } = {}) {
if (!WEATHER_PRODUCTS.includes(product) || !Number.isFinite(Date.parse(time)))
throw new Error('Invalid weather frame');
if (size !== undefined && ![256, 512, 1024].includes(size))
if (
size !== undefined &&
!(product === 'radar-global' ? [256] : [256, 512, 1024]).includes(size)
)
throw new Error('Invalid weather tile size');
// Construct locally; never accept a manifest-provided host or template.
return `/api/weather/tile?product=${product}&time=${encodeURIComponent(time)}&z={z}&x={x}&y={y}${size === undefined ? '' : `&size=${size}`}`;
@@ -132,19 +138,5 @@ export function createWeatherSource({
signal?.removeEventListener('abort', abort);
}
},
/** Read one image frame through the bounded image route. */
async getImage({ product, time, size, bbox = null, signal } = {}) {
signal?.throwIfAborted();
const response = await fetchImpl(
weatherImageUrl(product, time, size, bbox),
{ signal, redirect: 'error' },
);
if (!response.ok)
throw new Error(`Weather image HTTP ${response.status}`);
const bytes = new Uint8Array(await response.arrayBuffer());
signal?.throwIfAborted();
const type = response.headers.get('content-type') || '';
return { contentType: type.split(';')[0].trim().toLowerCase(), bytes };
},
};
}
-37
View File
@@ -162,40 +162,3 @@ test('detail-window image URLs carry the bbox and omit the default detail size',
/size/,
);
});
test('image frames are read through the bounded image route', async () => {
const png = Uint8Array.from([0x89, 0x50, 0x4e, 0x47]);
const requested = [];
const source = createWeatherSource({
fetchImpl: async (url, init) => {
requested.push({ url, redirect: init.redirect });
return new Response(png, { headers: { 'Content-Type': 'image/PNG' } });
},
});
const frame = await source.getImage({
product: 'radar',
time,
size: { width: 1024, height: 512 },
bbox: { west: -100, south: 30, east: -96, north: 32 },
});
assert.equal(frame.contentType, 'image/png');
assert.deepEqual([...frame.bytes], [...png]);
assert.deepEqual(requested, [
{
url: weatherImageUrl(
'radar',
time,
{ width: 1024, height: 512 },
{ west: -100, south: 30, east: -96, north: 32 },
),
redirect: 'error',
},
]);
const failing = createWeatherSource({
fetchImpl: async () => new Response(null, { status: 409 }),
});
await assert.rejects(
failing.getImage({ product: 'radar', time }),
/Weather image HTTP 409/,
);
});
+166 -2
View File
@@ -1742,7 +1742,7 @@ test('one row steps every registered observation; missing frames hide and latest
});
assert.deepEqual(
radar.layer.getParams(),
{ opacity: 'strong' },
{ product: 'radar', opacity: 'strong' },
'history is never serialized',
);
satellite.layer.disable();
@@ -1889,7 +1889,11 @@ test('observed descriptors keep configuration only and label satellite clouds by
assert.equal(controls.summary.sections, undefined);
assert.deepEqual(
controls.summary.settings.map(({ label }) => label),
id === 'weather-satellite' ? ['REGION', 'IMAGE', 'OPACITY'] : ['OPACITY'],
id === 'weather-satellite'
? ['REGION', 'IMAGE', 'OPACITY']
: id === 'weather-radar'
? ['REGION', 'OPACITY']
: ['OPACITY'],
);
assert.deepEqual(
controls.summary.settings.flatMap(({ chips }) => chips),
@@ -2253,3 +2257,163 @@ for (const product of ['radar', 'clouds-regional', 'lightning']) {
assert.equal(h.viewer.camera.moveEnd.size, 0);
});
}
test('global radar uses Web Mercator provider, linked credit and square-grid prefetch', async () => {
const urls = [];
const h = renderingHarness({
fetchImpl: async (url) => {
urls.push(url);
return mockResponse();
},
});
h.cesium.WebMercatorTilingScheme = Cesium.WebMercatorTilingScheme;
const globalRadar = {
...snapshot,
product: 'radar-global',
tilingScheme: 'web-mercator',
maxLevel: 7,
bounds: { west: -180, south: -85.0511, east: 180, north: 85.0511 },
};
const pending = h.rendering.setFrame(globalRadar, times[0]);
const provider = h.providers[0].options;
assert.ok(provider.tilingScheme instanceof Cesium.WebMercatorTilingScheme);
assert.equal(provider.maximumLevel, 6);
assert.equal(provider.tileWidth, 256);
assert.match(provider.credit.html, /href="https:\/\/www.rainviewer.com"/);
h.settle();
assert.equal(await pending, true);
assert.equal(await h.rendering.prefetch(globalRadar, times[1]), true);
const xyz = urls.map((url) => {
const params = new URL(url, 'https://example.test').searchParams;
return ['z', 'x', 'y'].map((key) => Number(params.get(key)));
});
assert.deepEqual(xyz, [
[0, 0, 0],
[1, 0, 0],
[1, 1, 0],
[1, 0, 1],
[1, 1, 1],
]);
h.rendering.clear();
});
test('radar region switch cancels old work, retains clock target and advertises RainViewer semantics', async () => {
const clock = createWeatherClock();
const products = [];
const h = layerHarness({
clock,
feed: {
getSnapshot: async ({ product }) => {
products.push(product);
return {
...snapshot,
product,
bounds:
product === 'radar-global'
? { west: -180, south: -85.0511, east: 180, north: 85.0511 }
: snapshot.bounds,
};
},
},
});
const initial = h.layer.update();
await flush();
const old = h.stages.at(-1);
h.layer.setParams({ product: 'radar-global' });
await flush();
assert.equal(old.signal.aborted, true);
assert.deepEqual(products, ['radar', 'radar-global']);
h.stages.at(-1).finish();
await flush();
await initial;
assert.equal(h.layer.getParams().product, 'radar-global');
const controls = h.layer.getRowControls();
assert.equal(controls.summary.coverage, 'Global · where radars exist');
assert.equal(controls.summary.maxGapMinutes, 30);
assert.deepEqual(
controls.summary.settings[0].chips.map(({ label }) => label),
['US (NOAA)', 'Global (RainViewer)'],
);
assert.deepEqual(
controls.legend.map(({ color }) => color),
[
'#cec087',
'#00a3e0',
'#005588',
'#ffee00',
'#ffaa00',
'#ff4400',
'#c10000',
'#ffaaff',
'#ffffff',
],
);
assert.match(controls.infoTitle, /gap does not mean no rain/);
assert.equal(h.layer.getStats().source, 'RainViewer');
const history = clock.setTarget(times[1]);
await flush();
h.stages.at(-1).finish();
await history;
assert.equal(h.layer.getDiagnostics().time, times[1]);
h.layer.setParams({ product: 'radar' });
await flush();
h.stages.at(-1).finish();
await flush();
assert.equal(clock.getState().target, times[1]);
assert.equal(h.layer.getRowControls().summary.coverage, 'CONUS');
h.layer.destroy();
clock.destroy();
});
test('global radar coverage status follows the active bounds over land and ocean', async () => {
let location = Cesium.Cartesian3.fromDegrees(0, 51);
let shown = null;
const latest = new Date().toISOString();
const layer = createWeatherLayer({
documentRef: null,
eventTarget: null,
matchMedia: null,
feed: {
getSnapshot: async ({ product }) => ({
product,
times: [latest],
latest,
bounds:
product === 'radar-global'
? { west: -180, south: -85.0511, east: 180, north: 85.0511 }
: snapshot.bounds,
}),
},
createRendering: () => ({
rehome() {},
clear() {
shown = null;
},
setAlpha() {},
async setFrame(_snapshot, time) {
shown = time;
return true;
},
getDiagnostics: () => ({ time: shown }),
}),
});
layer.init({
camera: { pickEllipsoid: () => location },
scene: {
canvas: { clientWidth: 800, clientHeight: 600 },
globe: { ellipsoid: Cesium.Ellipsoid.WGS84 },
},
});
layer.enable();
await layer.update();
assert.equal(
layer.getRowControls().summary.status,
'Map center outside coverage',
);
layer.setParams({ product: 'radar-global' });
await flush();
assert.equal(layer.getRowControls().summary.status, null);
location = Cesium.Cartesian3.fromDegrees(-150, -30);
assert.equal(layer.getRowControls().summary.status, null);
layer.destroy();
});
+1 -1
View File
@@ -61,7 +61,7 @@ function uiBlock(start, end) {
/** A ShareLinkManager over a synthetic hash — enough surface for parseInitialHash. */
function managerForHash(hash) {
globalThis.window = { location: { hash, href: `http://localhost/${hash}` } };
globalThis.history = { replaceState(_s, _t, next) { window.location.hash = new URL(next, window.location.href).hash; } };
globalThis.history = { replaceState(_s, _t, next) { window.location.hash = next; } };
const viewer = {
camera: {
changed: { addEventListener() {} },
+2 -2
View File
@@ -34,8 +34,8 @@ function assertClaimsBefore(block, mutation, label) {
function makeManager(hash = '') {
globalThis.window = { location: { hash, href: `http://localhost/${hash}` } };
globalThis.history = {
replaceState(_state, _title, next) {
window.location.hash = new URL(next, window.location.href).hash;
replaceState(_state, _title, nextHash) {
window.location.hash = nextHash;
},
};
const viewer = {
+11 -10
View File
@@ -9,7 +9,6 @@ import {
decodeLayerStateParams,
encodeLayerStateParams,
} from './data/layerState.js';
import { STYLE_URL_NAMES } from './view/index.js';
/**
* Share Links — URL Hash State Management
@@ -21,8 +20,16 @@ import { STYLE_URL_NAMES } from './view/index.js';
const DEBOUNCE_MS = 500;
const LEGACY_BLOOM_FALLBACK = 50;
// Style name mapping: internal → URL-friendly, shared with views.
const STYLE_TO_URL = STYLE_URL_NAMES;
// Style name mapping: internal → URL-friendly
const STYLE_TO_URL = {
normal: 'normal',
retro: 'crt',
surveillance: 'nvg',
thermal: 'flir',
anime: 'anime',
noir: 'noir',
snow: 'snow',
};
const SHARE_UI_STATE_PARAM = 'ui';
const SHARE_STYLE_PARAMS_PARAM = 'sp';
@@ -529,13 +536,7 @@ export class ShareLinkManager {
if (this._destroyed || this._initialRestorePending) return;
const params = this._buildHashParams();
if (!params) return;
// The hash belongs to this page's address, not the document base the
// app may load under.
history.replaceState(
null,
'',
new URL(`#${params.toString()}`, window.location.href).href,
);
history.replaceState(null, '', `#${params.toString()}`);
}
/** Build a deterministic snapshot without mutating history. */
-24
View File
@@ -1,24 +0,0 @@
/** 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);
},
};
}
-115
View File
@@ -1,115 +0,0 @@
/** Parse GBFS station documents, tolerating field-name variations across providers. */
/**
* Coerce a GBFS boolean field to a native boolean.
* GBFS feeds are inconsistent — some use booleans, others use 0/1 or strings.
* @param {*} value - Raw field value from GBFS JSON.
* @param {boolean} [fallback=true] - Default when value is null/undefined/unrecognized.
* @returns {boolean}
*/
export function normalizeGbfsBool(value, fallback = true) {
if (value == null) return fallback;
if (typeof value === 'boolean') return value;
const num = Number(value);
if (Number.isFinite(num)) return num !== 0;
const text = String(value).trim().toLowerCase();
if (text === 'true' || text === 'yes') return true;
if (text === 'false' || text === 'no') return false;
return fallback;
}
/**
* Parse a value as a non-negative integer, returning fallback on failure.
* @param {*} value - Raw numeric value.
* @param {number|null} [fallback=null] - Returned when value is not a valid non-negative number.
* @returns {number|null}
*/
export function toNonNegativeInteger(value, fallback = null) {
const n = Number(value);
if (!Number.isFinite(n) || n < 0) return fallback;
return Math.round(n);
}
/**
* Extract the stations array from a GBFS JSON payload.
* Handles multiple response shapes: { data: { stations: [...] } },
* { data: [...] }, and nested objects with a stations sub-key.
* @param {Object} payload - Parsed GBFS JSON response.
* @returns {Object[]} Array of raw station objects (may be empty).
*/
export function extractStationsArray(payload) {
const data = payload?.data;
if (Array.isArray(data?.stations)) return data.stations;
if (Array.isArray(data)) return data;
// Some feeds nest stations under an additional key (e.g. locale wrappers)
if (data && typeof data === 'object') {
for (const value of Object.values(data)) {
if (Array.isArray(value?.stations)) return value.stations;
}
}
return [];
}
/**
* Parse a GBFS station_information payload into a Map of station metadata.
* Handles field-name variations across different GBFS providers
* (station_id vs id, lat vs latitude, etc.). Stations with missing or
* invalid coordinates are silently skipped.
* @param {Object} payload - Parsed station_information.json response.
* @returns {Map<string, Object>} Map of stationId to station info objects.
*/
export function parseStationInformation(payload) {
const stations = extractStationsArray(payload);
const stationMap = new Map();
for (const raw of stations) {
// Accept both GBFS 2.x (station_id) and alternate (id) field names
const stationId = String(raw?.station_id ?? raw?.id ?? '').trim();
const lat = Number(raw?.lat ?? raw?.latitude);
const lon = Number(raw?.lon ?? raw?.longitude);
if (!stationId || !Number.isFinite(lat) || !Number.isFinite(lon)) continue;
stationMap.set(stationId, {
stationId,
name: String(raw?.name || raw?.short_name || '').trim(),
lat,
lon,
capacity: toNonNegativeInteger(raw?.capacity),
isInstalled: normalizeGbfsBool(raw?.is_installed, true),
isRenting: normalizeGbfsBool(raw?.is_renting, true),
isReturning: normalizeGbfsBool(raw?.is_returning, true),
});
}
return stationMap;
}
/**
* Parse a GBFS station_status payload into a Map of real-time availability.
* @param {Object} payload - Parsed station_status.json response.
* @returns {Map<string, Object>} Map of stationId to status objects containing
* bikesAvailable, docksAvailable, and operational flags.
*/
export function parseStationStatus(payload) {
const stations = extractStationsArray(payload);
const statusMap = new Map();
for (const raw of stations) {
const stationId = String(raw?.station_id ?? raw?.id ?? '').trim();
if (!stationId) continue;
const bikes = toNonNegativeInteger(raw?.num_bikes_available);
const docks = toNonNegativeInteger(raw?.num_docks_available);
statusMap.set(stationId, {
stationId,
bikesAvailable: bikes,
docksAvailable: docks,
isInstalled: normalizeGbfsBool(raw?.is_installed, true),
isRenting: normalizeGbfsBool(raw?.is_renting, true),
isReturning: normalizeGbfsBool(raw?.is_returning, true),
lastReported: toNonNegativeInteger(raw?.last_reported),
});
}
return statusMap;
}
-163
View File
@@ -1,163 +0,0 @@
/**
* Bundled OpenStreetMap infrastructure layers (datacenters and dams): their
* data files, line-delimited GeoJSON parsing and analyst records.
*/
// Resolved by Vite in builds and relative to this module in other consumers.
export const INFRASTRUCTURE_DATA_URLS = Object.freeze({
'local-datacenters': new URL(
'../data/local_data/datacenters/datacenters.geojsonl',
import.meta.url,
).href,
'local-dams': new URL(
'../data/local_data/dams/dams.geojsonl',
import.meta.url,
).href,
});
/** Parse GeoJSON Lines: one Feature per non-empty line. */
export function parseGeojsonLines(text) {
return String(text)
.split('\n')
.filter((line) => line.trim().length > 0)
.map((line) => JSON.parse(line));
}
/** The singular label for a local infrastructure layer. */
export function layerTitle(layerId) {
if (layerId === 'local-datacenters') return 'Datacenter';
if (layerId === 'local-dams') return 'Dam';
return 'Feature';
}
/**
* Map one local infrastructure feature to a JSON-safe analyst record
* (analyst query engine seam). Pure — no Cesium types. Missing/unknown
* fields are null, never NaN/undefined. Layer-specific fields that do not
* apply (river/output on datacenters, capacity on dams) stay null so a
* shared compact payload can copy them without inventing values.
* Names are unclamped — overlay cards shorten for paint; queries need the
* full source string ("Usina Hidrelétrica de Itaipu").
* @param {Object|null|undefined} raw - {id, lat, lon, properties}.
* @param {string} [layerId] Local layer id (`local-datacenters` / `local-dams`).
* @returns {{id: string, name: string|null, lat: number|null, lon: number|null,
* operator: string|null, capacity: string|null, river: string|null,
* output: string|null}}
*/
export function mapAnalystRecord(raw, layerId = '') {
const num = (v) => (Number.isFinite(v) ? v : null);
const text = (v) => {
const t = String(v ?? '').trim();
return t && t !== 'undefined' && t !== 'null' ? t : null;
};
const props =
raw?.properties &&
typeof raw.properties === 'object' &&
!Array.isArray(raw.properties)
? raw.properties
: {};
const tags =
props.tags && typeof props.tags === 'object' && !Array.isArray(props.tags)
? props.tags
: {};
const name =
text(props.name) ||
text(tags.name) ||
text(tags['name:en']) ||
text(tags.official_name) ||
null;
const operator =
text(tags.operator) ||
text(props.operator) ||
text(tags['operator:short']) ||
null;
const capacity =
layerId === 'local-datacenters'
? text(tags['capacity:it_load']) ||
text(tags.it_load) ||
text(tags.capacity) ||
text(props.capacity)
: null;
const river =
layerId === 'local-dams'
? text(tags.associated_river) ||
text(props.associated_river) ||
text(tags.river) ||
text(props.river) ||
text(tags['river:name'])
: null;
const output =
layerId === 'local-dams'
? text(props.output) || text(tags['plant:output:electricity'])
: null;
return {
id: name || text(raw?.id) || layerTitle(layerId),
name,
lat: num(raw?.lat),
lon: num(raw?.lon),
operator,
capacity,
river,
output,
};
}
/** A representative point for a feature: its point, or its outline's average vertex. */
export function featurePoint(feature) {
const geometry = feature?.geometry;
const outline =
geometry?.type === 'Point'
? [geometry.coordinates]
: geometry?.type === 'LineString'
? geometry.coordinates
: geometry?.type === 'Polygon'
? geometry.coordinates?.[0]
: geometry?.type === 'MultiPolygon'
? geometry.coordinates?.[0]?.[0]
: null;
const points = (outline || []).filter(
(point) => Number.isFinite(point?.[0]) && Number.isFinite(point?.[1]),
);
if (!points.length) return null;
return {
lon: points.reduce((sum, point) => sum + point[0], 0) / points.length,
lat: points.reduce((sum, point) => sum + point[1], 0) / points.length,
};
}
/** Read the bundled layers' analyst records through a supplied transport. */
export function createInfrastructureSource({
fetchImpl = (...args) => globalThis.fetch(...args),
} = {}) {
const cache = new Map();
return {
async getRecords(layerId, { signal } = {}) {
const url = INFRASTRUCTURE_DATA_URLS[layerId];
if (!url) throw new TypeError(`Unknown infrastructure layer: ${layerId}`);
if (!cache.has(layerId)) {
signal?.throwIfAborted();
const response = await fetchImpl(url, { signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const records = parseGeojsonLines(await response.text()).flatMap(
(feature) => {
const point = featurePoint(feature);
return point
? [
mapAnalystRecord(
{
id: feature.properties?.osm_id ?? feature.id,
...point,
properties: feature.properties,
},
layerId,
),
]
: [];
},
);
cache.set(layerId, records);
}
return cache.get(layerId);
},
};
}
-27
View File
@@ -1,27 +0,0 @@
/**
* Parse three-line TLE catalog text into `{ name, line1, line2 }` entries.
* Blocks whose second and third lines are not TLE lines 1 and 2 are skipped.
*/
export function parseTleText(text) {
const lines = String(text)
.trim()
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0);
const result = [];
for (let i = 0; i < lines.length - 2; i += 3) {
const name = lines[i];
const line1 = lines[i + 1];
const line2 = lines[i + 2];
if (line1.startsWith('1 ') && line2.startsWith('2 ')) {
result.push({ name, line1, line2 });
}
}
return result;
}
/** The NORAD catalog number from TLE line 1, or null. */
export function tleCatalogNumber(line1) {
const number = Number.parseInt(String(line1).slice(2, 7), 10);
return Number.isInteger(number) ? number : null;
}
-3
View File
@@ -29,9 +29,6 @@ export function createStandaloneLayerSources() {
military: createAdsbLolSource(),
vessels: createAisStreamSource({
apiUrl: import.meta.env?.VITE_AIS_LIVE_API_URL || '/api/ais-live',
// Resolved against the document's address, which a panel host may
// serve from its own scheme.
origin: () => globalThis.document?.baseURI ?? 'http://localhost',
}),
cctv: createCctvSource(),
radio: createRadioSource(),
-29
View File
@@ -1,29 +0,0 @@
/** The standalone tool catalog for voice, loaded the first time voice needs it. */
let pending = null;
export function loadToolCatalog() {
pending ??= Promise.all([
import('../tools/index.js'),
import('../tools/services.js'),
]).then(
([
{ composeCatalog, coreTools, catalogForSurface },
{ createToolServices },
]) =>
catalogForSurface(
composeCatalog({
tools: coreTools,
services: createToolServices({
fetchImpl: (...args) => globalThis.fetch(...args),
appUrl: new URL(globalThis.document.baseURI).origin,
}),
}),
'voice',
),
);
pending.catch(() => {
pending = null;
});
return pending;
}
+1 -7
View File
@@ -1,22 +1,16 @@
import { createAssetDirectorySource } from '../director/packs/source.js';
import { createApplicationTools } from '../app/tools.js';
import { startStandaloneChrome } from './startupChrome.js';
import { loadToolCatalog } from './toolCatalog.js';
export function createStandaloneTools(options) {
return createApplicationTools({
startChrome: startStandaloneChrome,
sceneDataPacks: {
sources: {
assets: createAssetDirectorySource({
// A panel names the app's address; see src/tools/globePanel.js.
baseUrl: new URL(
'/scene-assets/',
globalThis.GEV_APP_BASE_URL ?? document.baseURI,
).href,
baseUrl: new URL('/scene-assets/', window.location.href).href,
}),
},
},
...options,
voice: { toolCatalog: loadToolCatalog, ...options?.voice },
});
}
-54
View File
@@ -139,60 +139,6 @@ test('OpenSky state and track routes share tokens, retain cache and use regional
assert.equal(JSON.parse(fallback.body).states[0][0], 'abc123');
});
test('OpenSky global snapshot attempts are time-limited, retried once, then fall back', async (t) => {
environment(t, {
OPENSKY_CLIENT_ID: undefined,
OPENSKY_CLIENT_SECRET: undefined,
OPENSKY_AUTH_MODE: 'anon',
OPENSKY_USERNAME: undefined,
OPENSKY_PASSWORD: undefined,
});
t.mock.method(console, 'warn', () => {});
const timedOut = () =>
Promise.reject(
new DOMException(
'The operation was aborted due to timeout',
'TimeoutError',
),
);
const load = async (respond) => {
const attempts = [];
t.mock.method(globalThis, 'fetch', async (url, options) => {
if (url.includes('/states/')) {
assert.ok(options.signal instanceof AbortSignal);
attempts.push(url);
return respond(attempts.length);
}
if (url.includes('/lat/'))
return Response.json({
now: Date.now() / 1000,
ac: [{ hex: 'abc123', lat: 30, lon: -97, alt_baro: 10000 }],
});
throw Error(`Unexpected URL: ${url}`);
});
const fresh = await import(
`../../server/providers/aircraft/opensky.js?timeout=${Math.random()}`
);
const response = await install(fresh.openSkyProxy())(
'/api/opensky',
'?lat=30&lon=-97',
);
return { response, attempts };
};
const recovered = await load((attempt) =>
attempt === 1
? timedOut()
: Response.json({ time: Math.floor(Date.now() / 1000), states: [] }),
);
assert.equal(recovered.attempts.length, 2);
assert.equal(recovered.response.statusCode, 200);
assert.equal(recovered.response.headers['x-flight-source'], undefined);
const stalled = await load(() => timedOut());
assert.equal(stalled.attempts.length, 2);
assert.equal(stalled.response.statusCode, 200);
assert.equal(stalled.response.headers['x-flight-source'], 'adsb.lol');
});
test('military aircraft route preserves fresh cache and stale response after upstream failure', async (t) => {
let now = Date.now();
let calls = 0;
-57
View File
@@ -21,7 +21,6 @@ import { openAiRealtimeProxy } from 'gods-eye-view/server/providers/openai';
import { keySetupEndpoint } from 'gods-eye-view/server/standalone/key-setup';
import { realtimeInstructions } from '../../server/providers/openai/instructions.js';
import { GEV_REALTIME_TOOLS } from '../../server/providers/openai/tools.js';
import { standaloneVoiceTools } from '../../server/standalone/voiceTools.js';
function install(plugin, preview = false) {
const routes = new Map();
@@ -196,40 +195,6 @@ test('Realtime handler preserves tools and default instructions, isolates suppli
assert.equal(sent[0].session.instructions, sent[2].session.instructions);
});
test('Realtime sessions carry supplied tools, and the standalone voice adds the catalog queries', async (t) => {
env(t, 'OPENAI_API_KEY', 'fixture-upstream-secret');
env(t, 'GEV_RATELIMIT_OPENAI_PER_MIN', undefined);
const sent = [];
t.mock.method(globalThis, 'fetch', async (url, options) => {
sent.push(JSON.parse(options.body));
return Response.json({ value: 'fixture-ephemeral' });
});
const tools = standaloneVoiceTools();
const response = await request(
install(openAiRealtimeProxy({ realtime: { tools } })).get(
'/api/realtime/token',
),
);
assert.equal(response.status, 200);
assert.deepEqual(sent.at(-1).session.tools, tools);
const names = tools.map((tool) => tool.name);
assert.equal(new Set(names).size, names.length);
assert.deepEqual(
tools.slice(0, GEV_REALTIME_TOOLS.length),
GEV_REALTIME_TOOLS,
);
assert.ok(names.includes('get_weather'));
assert.ok(names.includes('military_awareness'));
assert.ok(!names.includes('show_in_gods_eye_view'));
assert.ok(!names.includes('aircraft_in_area'));
assert.ok(!names.includes('get_weather_map'));
// The action of the same name answers satellite passes.
assert.equal(
tools.findLast((tool) => tool.name === 'next_satellite_pass'),
GEV_REALTIME_TOOLS.find((tool) => tool.name === 'next_satellite_pass'),
);
});
test('debug logging resolves each supplied application directory independently', async (t) => {
const first = root(t),
second = root(t);
@@ -469,25 +434,3 @@ test('the debug log rotates instead of growing without bound', async (t) => {
assert.ok(live + previous < 64 * 1024 * 1024);
assert.ok(!existsSync(`${file}.2`), 'exactly one generation is retained');
});
test('the voice instructions name only tools the voice session offers', async () => {
const { coreTools } = await import('../tools/index.js');
const tools = new Set(standaloneVoiceTools().map((tool) => tool.name));
const known = new Set([
...coreTools.map((tool) => tool.name),
...GEV_REALTIME_TOOLS.map((tool) => tool.name),
]);
const named = new Set(
realtimeInstructions().match(/\b[a-z]+(?:_[a-z]+)+\b/g),
);
const missing = [...named].filter(
(name) => known.has(name) && !tools.has(name),
);
assert.deepEqual(missing, []);
});
test('the voice instructions send vessels, however heard, to the ships layer', () => {
const text = realtimeInstructions();
assert.match(text, /"vessels" is easily heard as "visuals"/);
assert.match(text, /set_layer_visibility\{layerId:"ais-live-vessels"\}/);
});
-116
View File
@@ -1,116 +0,0 @@
import assert from 'node:assert/strict';
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import * as PANEL_PATHS from '../tools/globePanel.js';
import {
PANEL_BASE,
PANEL_WORKER_PRELUDE_PATH,
panelBuildConfig,
panelBuildPlugin,
workerFilesPrelude,
} from '../../build/panel.js';
test('the panel loads the paths the panel build writes', () => {
assert.equal(PANEL_BASE, PANEL_PATHS.PANEL_BASE);
assert.equal(
PANEL_WORKER_PRELUDE_PATH,
PANEL_PATHS.PANEL_WORKER_PRELUDE_PATH,
);
});
test('the panel build is one script and one stylesheet under /panel/', () => {
const config = panelBuildConfig({
plugins: [],
build: { chunkSizeWarningLimit: 1500 },
});
assert.equal(config.base, PANEL_BASE);
assert.equal(config.build.outDir, 'dist/panel');
assert.equal(config.build.cssCodeSplit, false);
assert.equal(config.build.rollupOptions.output.inlineDynamicImports, true);
assert.equal(config.build.chunkSizeWarningLimit, 1500);
});
test('the dev server serves the panel build and nothing outside it', async () => {
const root = await mkdtemp(join(tmpdir(), 'panel-build-'));
try {
await mkdir(join(root, 'out', 'assets'), { recursive: true });
await writeFile(join(root, 'out', 'index.html'), '<p>panel</p>');
await writeFile(join(root, 'out', 'assets', 'a.js'), 'x');
await writeFile(join(root, 'secret.txt'), 'secret');
let middleware;
panelBuildPlugin({ outDir: 'out' }).configureServer({
config: { root },
middlewares: { use: (fn) => (middleware = fn) },
});
const get = (url) =>
new Promise((resolve) => {
const res = {
statusCode: 200,
headers: {},
setHeader(name, value) {
this.headers[name] = value;
},
end(body) {
resolve({ status: this.statusCode, headers: this.headers, body });
},
};
middleware({ url }, res, () => resolve({ passed: true }));
});
const page = await get('/panel/');
assert.equal(String(page.body), '<p>panel</p>');
assert.equal(page.headers['Content-Type'], 'text/html');
const script = await get('/panel/assets/a.js');
assert.equal(script.headers['Content-Type'], 'text/javascript');
assert.equal((await get('/panel/missing.js')).status, 404);
assert.equal((await get('/panel/%ZZ')).status, 400);
assert.deepEqual(await get('/panel/..%2Fsecret.txt'), { passed: true });
assert.deepEqual(await get('/other'), { passed: true });
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("Cesium's workers get embedded files from memory and other requests from the network", async () => {
const sent = [];
class FakeRequest extends EventTarget {
open(method, url) {
this.url = url;
}
setRequestHeader() {}
send() {
sent.push(this.url);
}
}
const saved = {
XMLHttpRequest: globalThis.XMLHttpRequest,
ProgressEvent: globalThis.ProgressEvent,
};
globalThis.XMLHttpRequest = FakeRequest;
globalThis.ProgressEvent ??= class extends Event {};
try {
new Function(workerFilesPrelude({ 'Assets/heights.json': '{"a":1}' }))();
const embedded = new XMLHttpRequest();
embedded.open(
'GET',
'https://panel.example/panel/cesium/Assets/heights.json?v=1',
);
embedded.setRequestHeader('Accept', 'application/json');
embedded.responseType = 'text';
const loaded = new Promise((resolve) =>
embedded.addEventListener('load', resolve),
);
embedded.send();
await loaded;
assert.equal(embedded.status, 200);
assert.equal(embedded.response, '{"a":1}');
const other = new XMLHttpRequest();
other.open('GET', 'https://tiles.example/1.png');
other.send();
assert.deepEqual(sent, ['https://tiles.example/1.png']);
} finally {
globalThis.XMLHttpRequest = saved.XMLHttpRequest;
globalThis.ProgressEvent = saved.ProgressEvent;
}
});
+4 -62
View File
@@ -51,10 +51,7 @@ test('build helper does not discover environment values or construct local provi
config.define['import.meta.env.GOOGLE_MAPS_API_KEY'],
undefined,
);
assert.deepEqual(
config.plugins.slice(2).map((plugin) => plugin.name),
['embed-framing', 'panel-build'],
);
assert.equal(config.plugins.length, 2);
} finally {
if (before === undefined) delete process.env.GOOGLE_MAPS_API_KEY;
else process.env.GOOGLE_MAPS_API_KEY = before;
@@ -66,15 +63,11 @@ test('root config retains existing named exports and standalone provider order',
assert.equal(compatibility[name], value, name);
const config = standaloneConfig({ mode: 'test' });
assert.deepEqual(
config.plugins.slice(2, -4).map((plugin) => plugin.name),
config.plugins.slice(2, -1).map((plugin) => plugin.name),
providers.localProviderPlugins().map((plugin) => plugin.name),
);
assert.equal(config.plugins.at(-5).name, 'gev-key-setup');
// The local MCP route follows every provider and precedes the API fallback.
assert.equal(config.plugins.at(-4).name, 'local-mcp');
assert.equal(config.plugins.at(-3).name, 'api-not-found');
assert.equal(config.plugins.at(-2).name, 'embed-framing');
assert.equal(config.plugins.at(-1).name, 'panel-build');
assert.equal(config.plugins.at(-2).name, 'gev-key-setup');
assert.equal(config.plugins.at(-1).name, 'api-not-found');
});
test('build export resolves in Node and has no browser fallback', async () => {
@@ -85,54 +78,3 @@ test('build export resolves in Node and has no browser fallback', async () => {
);
assert.deepEqual(pkg.exports['./build/vite'], { node: './build/vite.js' });
});
test('only embed-mode documents may be framed, and only by the allowed ancestors', async () => {
const { embedFramingPlugin, isEmbedDocumentRequest } =
await import('../../build/embed-framing.js');
assert.equal(isEmbedDocumentRequest('/?embed=1'), true);
assert.equal(isEmbedDocumentRequest('/index.html?embed=1#v=2'), true);
assert.equal(isEmbedDocumentRequest('/?embed=0'), false);
assert.equal(isEmbedDocumentRequest('/api/x?embed=1'), false);
assert.equal(isEmbedDocumentRequest('/src/main.js?embed=1'), false);
let middleware;
embedFramingPlugin({ ancestors: 'https://a.example' }).configureServer({
middlewares: { use: (handler) => (middleware = handler) },
});
const response = () => {
const headers = new Map();
return {
headers,
setHeader(name, value) {
headers.set(name.toLowerCase(), value);
return this;
},
};
};
const embedded = response();
middleware({ url: '/?embed=1' }, embedded, () => {});
// The server's protections, written later at send time.
embedded.setHeader('X-Frame-Options', 'DENY');
embedded.setHeader('Content-Security-Policy', "frame-ancestors 'none'");
embedded.setHeader('Content-Type', 'text/html');
assert.deepEqual(Object.fromEntries(embedded.headers), {
'content-security-policy': 'frame-ancestors https://a.example',
'content-type': 'text/html',
});
let open;
embedFramingPlugin({ ancestors: '*' }).configureServer({
middlewares: { use: (handler) => (open = handler) },
});
const anywhere = response();
open({ url: '/?embed=1' }, anywhere, () => {});
anywhere.setHeader('X-Frame-Options', 'DENY');
anywhere.setHeader('Content-Security-Policy', "frame-ancestors 'none'");
assert.deepEqual(Object.fromEntries(anywhere.headers), {});
const normal = response();
middleware({ url: '/' }, normal, () => {});
normal.setHeader('X-Frame-Options', 'DENY');
normal.setHeader('Content-Security-Policy', "frame-ancestors 'none'");
assert.deepEqual(Object.fromEntries(normal.headers), {
'x-frame-options': 'DENY',
'content-security-policy': "frame-ancestors 'none'",
});
});
-357
View File
@@ -1,357 +0,0 @@
/**
* The `area` argument shared by location-scoped tools: a bounding box, a
* center point with radius, or a place name resolved through the `places`
* service.
*/
import { ToolError } from './catalog.js';
const EARTH_RADIUS_KM = 6371.0088;
const MAX_RADIUS_KM = 2000;
/** JSON Schema for the `area` argument. */
export const AREA_SCHEMA = Object.freeze({
type: 'object',
description:
'Where to look. Give exactly one of: place (a name such as "Taiwan" or ' +
'"San Francisco Bay"), bbox ([west, south, east, north] in degrees), or ' +
'lat and lon with radius_km.',
properties: {
place: { type: 'string', minLength: 1, maxLength: 200 },
bbox: {
type: 'array',
items: { type: 'number' },
minItems: 4,
maxItems: 4,
},
lat: { type: 'number', minimum: -90, maximum: 90 },
lon: { type: 'number', minimum: -180, maximum: 180 },
radius_km: { type: 'number', minimum: 0.1, maximum: MAX_RADIUS_KM },
},
additionalProperties: false,
});
/**
* Resolve an `area` argument to `{ label, west, south, east, north, center? }`.
* A box whose west edge exceeds its east edge crosses the antimeridian.
*/
export async function resolveArea(area, { services, signal } = {}) {
const given = ['place', 'bbox', 'lat'].filter((key) => area?.[key] != null);
if (given.length !== 1)
throw new ToolError(
'invalid_arguments',
'area needs exactly one of place, bbox, or lat/lon with radius_km',
);
if (area.bbox) {
const [west, south, east, north] = area.bbox;
if (
[west, east].some((value) => Math.abs(value) > 180) ||
[south, north].some((value) => Math.abs(value) > 90) ||
south >= north
)
throw new ToolError(
'invalid_arguments',
'area.bbox must be [west, south, east, north] with south below north',
);
return { label: 'the requested box', west, south, east, north };
}
if (area.lat != null) {
if (area.lon == null || area.radius_km == null)
throw new ToolError(
'invalid_arguments',
'area with lat also needs lon and radius_km',
);
return circleArea(area.lat, area.lon, area.radius_km);
}
const places = services?.places;
if (!places)
throw new ToolError(
'unsupported',
'Place names cannot be resolved here; give a bbox or lat/lon instead',
);
const place = await places.resolve(area.place, { signal });
if (!place)
throw new ToolError(
'invalid_arguments',
`No place matched "${area.place}"; try a more specific name or a bbox`,
);
return {
label: place.name || area.place,
...place.bounds,
...(place.point ? { point: place.point } : {}),
};
}
/**
* The bounding box of a spherical cap. When the cap reaches a pole it spans
* every longitude; otherwise the longitude span is the exact tangent bound,
* asin(sin(d) / cos(lat)) for angular radius d.
*/
function circleArea(lat, lon, radiusKm) {
const angular = radiusKm / EARTH_RADIUS_KM;
const latSpan = angular * (180 / Math.PI);
const reachesPole = lat + latSpan >= 90 || lat - latSpan <= -90;
const ratio = Math.sin(angular) / Math.cos((lat * Math.PI) / 180);
const lonSpan =
reachesPole || ratio >= 1 ? 180 : Math.asin(ratio) * (180 / Math.PI);
const wrap = (value) => ((((value + 180) % 360) + 360) % 360) - 180;
return {
label: `${radiusKm} km around ${lat.toFixed(3)}, ${lon.toFixed(3)}`,
west: lonSpan >= 180 ? -180 : wrap(lon - lonSpan),
east: lonSpan >= 180 ? 180 : wrap(lon + lonSpan),
south: Math.max(-90, lat - latSpan),
north: Math.min(90, lat + latSpan),
center: { lat, lon, radiusKm },
};
}
/** JSON Schema for a single location: a place name, or lat and lon. */
export const POINT_SCHEMA = Object.freeze({
type: 'object',
description: 'A place name, or lat and lon.',
properties: {
place: { type: 'string', minLength: 1, maxLength: 200 },
lat: { type: 'number', minimum: -90, maximum: 90 },
lon: { type: 'number', minimum: -180, maximum: 180 },
},
additionalProperties: false,
});
/** Resolve a point argument to `{ label, lat, lon }`. */
export async function resolvePoint(point, { services, signal } = {}) {
if (point?.place != null && point.lat == null && point.lon == null) {
const area = await resolveArea(
{ place: point.place },
{ services, signal },
);
return { label: area.label, ...areaCenter(area) };
}
if (point?.place == null && point?.lat != null && point?.lon != null)
return {
label: `${point.lat.toFixed(4)}, ${point.lon.toFixed(4)}`,
lat: point.lat,
lon: point.lon,
};
throw new ToolError(
'invalid_arguments',
'A location needs either place, or lat and lon',
);
}
/** Distance in kilometers from an area's center to its farthest edge point. */
export function areaRadiusKm(area) {
if (area.center) return area.center.radiusKm;
const center = areaCenter(area);
// A box holding the point opposite its center reaches half the globe.
const antipode = {
lat: -center.lat,
lon: center.lon > 0 ? center.lon - 180 : center.lon + 180,
};
if (areaContains(area, antipode)) return Math.PI * EARTH_RADIUS_KM;
// Otherwise the farthest point is on the boundary: sample each edge, and
// include where each parallel edge comes nearest the antipode's longitude.
const width =
area.west <= area.east
? area.east - area.west
: 360 - (area.west - area.east);
const lonAt = (t) => area.west + width * t;
const steps = 64;
const points = [];
for (let step = 0; step <= steps; step += 1) {
const t = step / steps;
const lat = area.south + (area.north - area.south) * t;
points.push(
{ lat, lon: area.west },
{ lat, lon: area.east },
{ lat: area.north, lon: lonAt(t) },
{ lat: area.south, lon: lonAt(t) },
);
}
const offset = (((antipode.lon - area.west) % 360) + 360) % 360;
if (offset <= width)
points.push(
{ lat: area.north, lon: antipode.lon },
{ lat: area.south, lon: antipode.lon },
);
return Math.max(...points.map((point) => distanceKm(center, point)));
}
/** Great-circle distance in kilometers. */
export function distanceKm(a, b) {
const toRad = Math.PI / 180;
const dLat = (b.lat - a.lat) * toRad;
const dLon = (b.lon - a.lon) * toRad;
const h =
Math.sin(dLat / 2) ** 2 +
Math.cos(a.lat * toRad) * Math.cos(b.lat * toRad) * Math.sin(dLon / 2) ** 2;
return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(h)));
}
/**
* The center of a resolved area: a radius area's center, a named place's own
* point, or the middle of a box (handling boxes across the antimeridian).
*/
export function areaCenter(area) {
if (area.center) return { lat: area.center.lat, lon: area.center.lon };
if (area.point) return { lat: area.point.lat, lon: area.point.lon };
const width =
area.west <= area.east
? area.east - area.west
: area.east + 360 - area.west;
const lon = area.west + width / 2;
return {
lat: (area.south + area.north) / 2,
lon: lon > 180 ? lon - 360 : lon,
};
}
/** Whether a point lies inside a resolved area, including radius areas. */
export function areaContains(area, point) {
if (!Number.isFinite(point?.lat) || !Number.isFinite(point?.lon))
return false;
if (point.lat < area.south || point.lat > area.north) return false;
const inLongitude =
area.west <= area.east
? point.lon >= area.west && point.lon <= area.east
: point.lon >= area.west || point.lon <= area.east;
if (!inLongitude) return false;
return area.center
? distanceKm(area.center, point) <= area.center.radiusKm
: true;
}
/** Whether a straight lon/lat segment meets a plain box (Liang–Barsky). */
function segmentMeetsBox(a, b, west, south, east, north) {
const dx = b.lon - a.lon;
const dy = b.lat - a.lat;
let low = 0;
let high = 1;
for (const [p, q] of [
[-dx, a.lon - west],
[dx, east - a.lon],
[-dy, a.lat - south],
[dy, north - a.lat],
]) {
if (p === 0) {
if (q < 0) return false;
} else {
const t = q / p;
if (p < 0) low = Math.max(low, t);
else high = Math.min(high, t);
if (low > high) return false;
}
}
return true;
}
/** Whether a segment meets an area's bounding box, across the antimeridian too. */
function segmentMeetsBounds(a, b, area) {
// A segment longer than half the globe in longitude crosses the antimeridian.
const end =
Math.abs(b.lon - a.lon) > 180
? { lat: b.lat, lon: b.lon + (b.lon < a.lon ? 360 : -360) }
: b;
const boxes =
area.west <= area.east
? [[area.west, area.east]]
: [
[area.west, 180],
[-180, area.east],
];
return boxes.some(([west, east]) =>
[-360, 0, 360].some((shift) =>
segmentMeetsBox(
a,
end,
west + shift,
area.south,
east + shift,
area.north,
),
),
);
}
/** Closest great-circle distance in km from `point` to the segment a–b. */
function segmentDistanceKm(point, a, b) {
const toRad = Math.PI / 180;
const bearing = (from, to) => {
const lat1 = from.lat * toRad;
const lat2 = to.lat * toRad;
const dLon = (to.lon - from.lon) * toRad;
return Math.atan2(
Math.sin(dLon) * Math.cos(lat2),
Math.cos(lat1) * Math.sin(lat2) -
Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLon),
);
};
const toPoint = distanceKm(a, point) / EARTH_RADIUS_KM;
const length = distanceKm(a, b) / EARTH_RADIUS_KM;
const angle = bearing(a, point) - bearing(a, b);
if (length === 0 || Math.cos(angle) <= 0) return distanceKm(a, point);
const cross = Math.asin(Math.sin(toPoint) * Math.sin(angle));
const along = Math.acos(
Math.min(1, Math.cos(toPoint) / Math.max(1e-12, Math.cos(cross))),
);
if (along >= length) return distanceKm(b, point);
return Math.abs(cross) * EARTH_RADIUS_KM;
}
/**
* Whether a line of `[lon, lat]` vertices passes through an area, including
* segments that cross it with no vertex inside.
*/
export function lineTouchesArea(coords, area) {
const points = coords
.map(([lon, lat]) => ({ lat, lon }))
.filter(
(point) => Number.isFinite(point.lat) && Number.isFinite(point.lon),
);
if (points.some((point) => areaContains(area, point))) return true;
for (let index = 1; index < points.length; index += 1) {
const a = points[index - 1];
const b = points[index];
if (!segmentMeetsBounds(a, b, area)) continue;
if (
!area.center ||
segmentDistanceKm(area.center, a, b) <= area.center.radiusKm
)
return true;
}
return false;
}
/** Whether a point lies inside a ring of `[lon, lat]` vertices (ray casting). */
function ringContains(ring, point) {
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {
const [xi, yi] = ring[i];
const [xj, yj] = ring[j];
if (
yi > point.lat !== yj > point.lat &&
point.lon < ((xj - xi) * (point.lat - yi)) / (yj - yi) + xi
)
inside = !inside;
}
return inside;
}
/**
* Whether polygons (each a list of `[lon, lat]` rings, outer ring first)
* overlap an area: a vertex inside it, an edge crossing it, or the area's
* center inside a polygon and outside its holes.
*/
export function polygonsTouchArea(polygons, area) {
const center = areaCenter(area);
return (polygons || []).some((rings) => {
if (!Array.isArray(rings) || !rings.length) return false;
const closed = rings.map((ring) =>
ring.length && ring[0] !== ring.at(-1) ? [...ring, ring[0]] : ring,
);
if (closed.some((ring) => lineTouchesArea(ring, area))) return true;
return (
ringContains(rings[0], center) &&
!rings.slice(1).some((hole) => ringContains(hole, center))
);
});
}
-264
View File
@@ -1,264 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
areaCenter,
areaContains,
areaRadiusKm,
distanceKm,
lineTouchesArea,
polygonsTouchArea,
resolveArea,
} from './area.js';
import { placeFromGeocodeResult } from './places.js';
test('an area needs exactly one form', async () => {
for (const area of [
{},
{ place: 'Oslo', bbox: [0, 0, 1, 1] },
{ lat: 1, lon: 2 },
]) {
await assert.rejects(
resolveArea(area),
(error) => error.code === 'invalid_arguments',
);
}
await assert.rejects(
resolveArea({ bbox: [0, 10, 1, 5] }),
/south below north/,
);
});
test('boxes, including antimeridian boxes, contain the expected points', async () => {
const box = await resolveArea({ bbox: [-10, 40, 10, 50] });
assert.equal(areaContains(box, { lat: 45, lon: 0 }), true);
assert.equal(areaContains(box, { lat: 45, lon: 20 }), false);
const pacific = await resolveArea({ bbox: [170, -20, -170, 0] });
assert.equal(areaContains(pacific, { lat: -10, lon: 179 }), true);
assert.equal(areaContains(pacific, { lat: -10, lon: -175 }), true);
assert.equal(areaContains(pacific, { lat: -10, lon: 0 }), false);
assert.equal(areaContains(box, { lat: Number.NaN, lon: 0 }), false);
});
test('radius areas use great-circle distance', async () => {
const circle = await resolveArea({ lat: 37.77, lon: -122.42, radius_km: 50 });
assert.equal(circle.center.radiusKm, 50);
assert.equal(areaContains(circle, { lat: 37.8, lon: -122.27 }), true);
assert.equal(areaContains(circle, { lat: 38.58, lon: -121.49 }), false);
assert.ok(
Math.abs(distanceKm({ lat: 0, lon: 0 }, { lat: 0, lon: 1 }) - 111.2) < 0.1,
);
// A circle across the antimeridian wraps its box.
const dateline = await resolveArea({ lat: 0, lon: 179.9, radius_km: 100 });
assert.ok(dateline.west > dateline.east);
assert.equal(areaContains(dateline, { lat: 0, lon: -179.9 }), true);
});
test('place names resolve through the places service', async () => {
const places = {
resolve: async (name) =>
name === 'Taiwan'
? {
name: 'Taiwan',
bounds: { west: 119, south: 21, east: 123, north: 26 },
}
: null,
};
const taiwan = await resolveArea(
{ place: 'Taiwan' },
{ services: { places } },
);
assert.deepEqual(taiwan, {
label: 'Taiwan',
west: 119,
south: 21,
east: 123,
north: 26,
});
await assert.rejects(
resolveArea({ place: 'Nowhere' }, { services: { places } }),
/No place matched "Nowhere"/,
);
await assert.rejects(
resolveArea({ place: 'Taiwan' }),
(error) => error.code === 'unsupported',
);
});
test('geocode results become bounded places', () => {
assert.deepEqual(
placeFromGeocodeResult({
formatted_address: 'Oslo, Norway',
geometry: {
location: { lat: 59.9, lng: 10.7 },
viewport: {
southwest: { lat: 59.8, lng: 10.5 },
northeast: { lat: 60, lng: 10.9 },
},
},
}),
{
name: 'Oslo, Norway',
bounds: { west: 10.5, south: 59.8, east: 10.9, north: 60 },
point: { lat: 59.9, lon: 10.7 },
},
);
assert.deepEqual(
placeFromGeocodeResult({ geometry: { location: { lat: 0, lng: 179.9 } } })
.bounds,
{ west: 179.65, south: -0.25, east: 180, north: 0.25 },
);
assert.equal(placeFromGeocodeResult({ geometry: {} }), null);
});
test('a named place centers on its own point, not the middle of its bounds', async () => {
// Tokyo's prefecture bounds reach remote Pacific islands.
const places = {
resolve: async () => ({
name: 'Tokyo, Japan',
bounds: { west: 136.07, south: 20.21, east: 153.99, north: 35.9 },
point: { lat: 35.6769, lon: 139.7639 },
}),
};
const tokyo = await resolveArea({ place: 'Tokyo' }, { services: { places } });
assert.deepEqual(areaCenter(tokyo), { lat: 35.6769, lon: 139.7639 });
assert.equal(areaContains(tokyo, { lat: 27.09, lon: 142.19 }), true);
const box = await resolveArea({ bbox: [0, 0, 10, 10] });
assert.deepEqual(areaCenter(box), { lat: 5, lon: 5 });
});
test('radius areas include every point within the radius, near the poles too', async () => {
const polar = await resolveArea({ lat: 80, lon: 0, radius_km: 1500 });
const across = { lat: 89, lon: 180 };
assert.ok(distanceKm({ lat: 80, lon: 0 }, across) < 1500);
assert.ok(areaContains(polar, across));
assert.equal(polar.west, -180);
assert.equal(polar.east, 180);
assert.equal(polar.north, 90);
// Away from the poles, sample the circle's edge and check each point is kept.
const center = { lat: 60, lon: 20 };
const wide = await resolveArea({ ...center, radius_km: 2000 });
assert.ok(wide.east - wide.west < 360);
for (let bearing = 0; bearing < 360; bearing += 5) {
const d = 1999 / 6371.0088;
const b = (bearing * Math.PI) / 180;
const lat1 = (center.lat * Math.PI) / 180;
const lat2 = Math.asin(
Math.sin(lat1) * Math.cos(d) + Math.cos(lat1) * Math.sin(d) * Math.cos(b),
);
const lon2 =
(center.lon * Math.PI) / 180 +
Math.atan2(
Math.sin(b) * Math.sin(d) * Math.cos(lat1),
Math.cos(d) - Math.sin(lat1) * Math.sin(lat2),
);
const point = { lat: (lat2 * 180) / Math.PI, lon: (lon2 * 180) / Math.PI };
assert.ok(areaContains(wide, point), `bearing ${bearing}`);
}
});
test('lines touch an area when a segment crosses it with no vertex inside', async () => {
const box = await resolveArea({ bbox: [-1, -1, 1, 1] });
assert.ok(
lineTouchesArea(
[
[-2, 0],
[2, 0],
],
box,
),
);
assert.ok(
!lineTouchesArea(
[
[-2, 2],
[2, 2],
],
box,
),
);
const circle = await resolveArea({ lat: 0, lon: 0, radius_km: 50 });
assert.ok(
lineTouchesArea(
[
[-2, 0.3],
[2, 0.3],
],
circle,
),
);
// Inside the circle's box but beyond its radius.
assert.ok(
!lineTouchesArea(
[
[0.3, 0.42],
[0.6, 0.42],
],
circle,
),
);
const dateline = await resolveArea({ bbox: [179, -1, -179, 1] });
assert.ok(
lineTouchesArea(
[
[178, 0],
[-178, 0],
],
dateline,
),
);
const nearDateline = await resolveArea({ bbox: [179.5, -1, 179.9, 1] });
assert.ok(
lineTouchesArea(
[
[179, 0],
[-179, 0],
],
nearDateline,
),
);
assert.ok(
!lineTouchesArea(
[
[170, 0],
[175, 0],
],
nearDateline,
),
);
});
test('polygons touch an area by vertex, crossing edge or enclosing it', async () => {
const box = await resolveArea({ bbox: [-1, -1, 1, 1] });
const square = (half, x = 0) => [
[
[x - half, -half],
[x + half, -half],
[x + half, half],
[x - half, half],
],
];
assert.ok(polygonsTouchArea([square(5)], box), 'encloses the area');
assert.ok(polygonsTouchArea([square(0.5)], box), 'inside the area');
assert.ok(polygonsTouchArea([square(1, 1.5)], box), 'overlaps an edge');
assert.ok(!polygonsTouchArea([square(0.5, 5)], box), 'elsewhere');
const ring = [...square(5), ...square(3)];
assert.ok(!polygonsTouchArea([ring], box), 'the area sits in a hole');
const circle = await resolveArea({ lat: 0, lon: 0, radius_km: 20 });
assert.ok(polygonsTouchArea([square(5)], circle));
});
test('a box radius reaches every point of the box, however large', async () => {
const world = await resolveArea({ bbox: [-180, -90, 180, 90] });
assert.ok(Math.abs(areaRadiusKm(world) - Math.PI * 6371.0088) < 1);
const wide = await resolveArea({ bbox: [-60, -70, 100, 60] });
const radius = areaRadiusKm(wide);
const center = areaCenter(wide);
for (let lat = -70; lat <= 60; lat += 2.5)
for (let lon = -60; lon <= 100; lon += 2.5)
assert.ok(
distanceKm(center, { lat, lon }) <= radius + 0.5,
`${lat},${lon}`,
);
const small = await resolveArea({ bbox: [-97.94, 30.1, -97.56, 30.52] });
assert.ok(areaRadiusKm(small) < 30);
});
-226
View File
@@ -1,226 +0,0 @@
/**
* Tool definitions and their composition into a callable catalog.
*
* A tool declares what it does, its arguments, and which services it reads.
* It knows nothing about the surface that exposes it (MCP, voice) or the
* transport its services use. Applications compose the tools they want with
* the services they supply, plus optional interceptors that wrap every call.
*/
import { assertSupportedSchema, validateValue } from './schema.js';
const TOOL_NAME = /^[a-z][a-z0-9_]{0,63}$/;
const KINDS = new Set(['query', 'action']);
/** Who may call a tool, as MCP Apps names them: the model, the app's panel. */
const VISIBILITY = new Set(['model', 'app']);
/** Error codes a tool may report. Each is safe to show to a model or person. */
export const TOOL_ERROR_CODES = Object.freeze([
'invalid_arguments',
'unavailable',
'unsupported',
'malformed',
'retry_later',
]);
/** An expected failure with a stable code and a message written for the caller. */
export class ToolError extends Error {
constructor(code, message, { retryAfterSeconds = null } = {}) {
if (!TOOL_ERROR_CODES.includes(code))
throw new TypeError(`Unknown tool error code: ${code}`);
super(message);
this.name = 'ToolError';
this.code = code;
this.retryAfterSeconds = retryAfterSeconds;
}
}
// Live sources report failures with these codes (src/sources/live/contract.js).
const SOURCE_ERROR_CODES = {
limited: 'retry_later',
denied: 'unavailable',
unavailable: 'unavailable',
malformed: 'malformed',
unsupported: 'unsupported',
};
/** Translate a source failure into a tool error; other errors pass through. */
export function fromSourceError(error) {
const code =
error?.name === 'LiveSourceError' &&
Object.hasOwn(SOURCE_ERROR_CODES, error.code)
? SOURCE_ERROR_CODES[error.code]
: null;
if (!code) return error;
const retryAfterSeconds =
code === 'retry_later' && Number.isFinite(error.retryAfterMs)
? Math.ceil(error.retryAfterMs / 1000)
: null;
return new ToolError(code, error.message, { retryAfterSeconds });
}
/**
* Validate and freeze a tool definition.
*
* `run(args, { services, signal, tools })` resolves to `{ summary, data }`: a
* short sentence for people and a structured object for programs. It may add
* `images`, each `{ mimeType, data }` with base64 data. `tools` reaches other
* tools through the same catalog: `tools.has(name)` and
* `tools.call(name, args)`.
*/
export function defineTool({
name,
kind = 'query',
title,
description,
inputSchema,
annotations = {},
requires = [],
ui = null,
run,
}) {
if (!TOOL_NAME.test(String(name)))
throw new TypeError(`Invalid tool name: ${name}`);
if (!KINDS.has(kind)) throw new TypeError(`Invalid tool kind: ${kind}`);
if (typeof title !== 'string' || !title)
throw new TypeError(`${name} needs a title`);
if (typeof description !== 'string' || !description)
throw new TypeError(`${name} needs a description`);
assertSupportedSchema(inputSchema, `${name}.inputSchema`);
if (inputSchema.type !== 'object')
throw new TypeError(`${name}.inputSchema must describe an object`);
if (
!Array.isArray(requires) ||
requires.some((key) => typeof key !== 'string')
)
throw new TypeError(`${name}.requires must list service names`);
if (typeof run !== 'function') throw new TypeError(`${name} needs run()`);
if (ui !== null) {
if (
ui?.resourceUri !== undefined &&
!(
typeof ui.resourceUri === 'string' && ui.resourceUri.startsWith('ui://')
)
)
throw new TypeError(`${name}.ui.resourceUri must be a ui:// URI`);
if (
ui?.visibility !== undefined &&
!(
Array.isArray(ui.visibility) &&
ui.visibility.length > 0 &&
ui.visibility.every((who) => VISIBILITY.has(who))
)
)
throw new TypeError(`${name}.ui.visibility must list model and/or app`);
if (ui?.resourceUri === undefined && ui?.visibility === undefined)
throw new TypeError(`${name}.ui needs a resourceUri or a visibility`);
}
return Object.freeze({
name,
kind,
title,
description,
inputSchema: structuredClone(inputSchema),
annotations: Object.freeze({
readOnlyHint: kind === 'query',
...annotations,
}),
requires: Object.freeze([...requires]),
ui: ui
? Object.freeze({
...(ui.resourceUri ? { resourceUri: ui.resourceUri } : {}),
...(ui.visibility
? { visibility: Object.freeze([...ui.visibility]) }
: {}),
})
: null,
run,
});
}
/**
* Compose tools with the services an application supplies.
*
* - Duplicate names fail unless the later tool is listed in `replace`.
* - Tools whose required services are missing are left out.
* - Interceptors wrap every call in order, outermost first, as
* `(call, next) => next(call)` where `call` is `{ tool, args, signal }`,
* plus `parent`, the calling tool's name, when one tool calls another.
*/
export function composeCatalog({
tools,
replace = [],
services = {},
interceptors = [],
}) {
const byName = new Map();
const replaceable = new Set(replace);
for (const tool of tools) {
if (byName.has(tool.name) && !replaceable.has(tool.name))
throw new TypeError(`Duplicate tool name: ${tool.name}`);
byName.set(tool.name, tool);
}
for (const name of replaceable) {
if (!byName.has(name))
throw new TypeError(`Cannot replace unknown tool: ${name}`);
}
const available = [...byName.values()].filter((tool) =>
tool.requires.every((key) => services[key] != null),
);
const index = new Map(available.map((tool) => [tool.name, tool]));
const invoke = (name, args, signal, parent) => {
const tool = index.get(name);
if (!tool)
return Promise.reject(
new ToolError('unsupported', `No tool named ${name} is available`),
);
return chain({ tool, args, signal, ...(parent ? { parent } : {}) });
};
const execute = async ({ tool, args, signal }) => {
const problems = validateValue(tool.inputSchema, args);
if (problems.length)
throw new ToolError('invalid_arguments', problems.join('; '));
let result;
try {
result = await tool.run(args, {
services,
signal,
tools: {
has: (name) => index.has(name),
call: (name, nested = {}) => invoke(name, nested, signal, tool.name),
},
});
} catch (error) {
throw fromSourceError(error);
}
if (!result || typeof result.summary !== 'string' || !result.data)
throw new TypeError(`${tool.name} returned no summary or data`);
if (
result.images !== undefined &&
(!Array.isArray(result.images) ||
!result.images.every(
(item) =>
typeof item?.mimeType === 'string' &&
typeof item?.data === 'string',
))
)
throw new TypeError(`${tool.name} returned malformed images`);
return result;
};
const chain = interceptors.reduceRight(
(next, interceptor) => (call) => interceptor(call, next),
execute,
);
return Object.freeze({
/** Available tool definitions, in composition order. */
list: () => [...available],
/** The available tool with this name, or undefined. */
get: (name) => index.get(name),
/** Validate arguments and run a tool through the interceptors. */
async call(name, args = {}, { signal } = {}) {
return invoke(name, args, signal);
},
});
}
-234
View File
@@ -1,234 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { composeCatalog, defineTool, ToolError } from './catalog.js';
import { validateValue } from './schema.js';
const echo = (name, extra = {}) =>
defineTool({
name,
title: `Echo ${name}`,
description: 'Returns its arguments.',
inputSchema: {
type: 'object',
properties: { word: { type: 'string', maxLength: 5 } },
additionalProperties: false,
},
run: async (args) => ({ summary: name, data: { args } }),
...extra,
});
test('definitions are validated, frozen and read-only by default', () => {
const tool = echo('echo');
assert.equal(Object.isFrozen(tool), true);
assert.equal(tool.kind, 'query');
assert.deepEqual(tool.annotations, { readOnlyHint: true });
assert.equal(echo('act', { kind: 'action' }).annotations.readOnlyHint, false);
assert.throws(() => echo('Bad-Name'), /Invalid tool name/);
assert.throws(() => echo('x', { kind: 'other' }), /Invalid tool kind/);
assert.throws(() => echo('x', { title: '' }), /needs a title/);
assert.throws(
() =>
echo('x', {
inputSchema: {
type: 'object',
properties: { a: { type: 'string', format: 'email' } },
},
}),
/unsupported keyword format/,
);
assert.throws(
() => echo('x', { inputSchema: { type: 'string' } }),
/must describe an object/,
);
});
test('composition rejects duplicates unless replaced, and hides tools without services', () => {
const first = echo('echo');
const second = echo('echo', { description: 'Replacement.' });
assert.throws(
() => composeCatalog({ tools: [first, second] }),
/Duplicate tool name: echo/,
);
assert.throws(
() => composeCatalog({ tools: [first], replace: ['other'] }),
/Cannot replace unknown tool/,
);
const replaced = composeCatalog({
tools: [first, second],
replace: ['echo'],
});
assert.equal(replaced.get('echo').description, 'Replacement.');
const needsFeed = echo('feed', { requires: ['feed'] });
assert.deepEqual(
composeCatalog({ tools: [first, needsFeed] })
.list()
.map((tool) => tool.name),
['echo'],
);
assert.deepEqual(
composeCatalog({ tools: [first, needsFeed], services: { feed: {} } })
.list()
.map((tool) => tool.name),
['echo', 'feed'],
);
});
test('calls validate arguments and pass through interceptors outermost first', async () => {
const order = [];
const catalog = composeCatalog({
tools: [echo('echo')],
interceptors: [
async (call, next) => {
order.push(`outer:${call.tool.name}`);
const result = await next(call);
order.push('outer:done');
return result;
},
(call, next) => {
order.push('inner');
return next({ ...call, args: { word: 'hi' } });
},
],
});
const result = await catalog.call('echo', { word: 'ignored' });
assert.deepEqual(result, { summary: 'echo', data: { args: { word: 'hi' } } });
assert.deepEqual(order, ['outer:echo', 'inner', 'outer:done']);
await assert.rejects(
composeCatalog({ tools: [echo('echo')] }).call('echo', {
word: 'toolong',
extra: 1,
}),
(error) =>
error instanceof ToolError &&
error.code === 'invalid_arguments' &&
/arguments\.word must be at most 5 characters/.test(error.message) &&
/unknown property extra/.test(error.message),
);
await assert.rejects(
composeCatalog({ tools: [] }).call('missing'),
(error) => error.code === 'unsupported',
);
});
test('a tool that returns no summary is a programming error, not a tool error', async () => {
const broken = echo('broken', { run: async () => ({ data: {} }) });
await assert.rejects(
composeCatalog({ tools: [broken] }).call('broken'),
(error) => error instanceof TypeError && !(error instanceof ToolError),
);
});
test('tool errors only use known codes', () => {
assert.throws(() => new ToolError('nope', 'x'), /Unknown tool error code/);
assert.equal(
new ToolError('retry_later', 'x', { retryAfterSeconds: 5 })
.retryAfterSeconds,
5,
);
});
test('only live source errors are translated', async () => {
const { fromSourceError } = await import('./catalog.js');
const plain = new Error('x');
assert.equal(fromSourceError(plain), plain);
const denied = Object.assign(new Error('refused'), {
name: 'LiveSourceError',
code: 'denied',
});
const translated = fromSourceError(denied);
assert.equal(translated.code, 'unavailable');
assert.equal(translated.retryAfterSeconds, null);
const odd = Object.assign(new Error('?'), {
name: 'LiveSourceError',
code: 'toString',
});
assert.equal(fromSourceError(odd), odd);
});
test('patterns are validated and must compile', () => {
const schema = {
type: 'object',
properties: { id: { type: 'string', pattern: '^[a-f]{2}$' } },
};
assert.deepEqual(validateValue(schema, { id: 'ab' }), []);
assert.deepEqual(validateValue(schema, { id: 'xy' }), [
'arguments.id must match ^[a-f]{2}$',
]);
assert.throws(
() =>
echo('x', {
inputSchema: {
type: 'object',
properties: { id: { type: 'string', pattern: '(' } },
},
}),
SyntaxError,
);
});
test('the validator checks every supported keyword', () => {
const schema = {
type: 'object',
required: ['n'],
properties: {
n: { type: 'integer', minimum: 1, maximum: 3 },
list: {
type: 'array',
minItems: 1,
maxItems: 2,
items: { type: 'number' },
},
mode: { type: 'string', enum: ['a', 'b'] },
flag: { type: 'boolean' },
},
};
assert.deepEqual(
validateValue(schema, { n: 2, list: [1], mode: 'a', flag: true }),
[],
);
assert.deepEqual(
validateValue(schema, { n: 1.5, list: [], mode: 'c', flag: 'yes' }),
[
'arguments.n must be an integer',
'arguments.list must have at least 1 items',
'arguments.mode must be one of "a", "b"',
'arguments.flag must be true or false',
],
);
assert.deepEqual(validateValue(schema, {}), ['arguments is missing n']);
assert.deepEqual(validateValue(schema, []), ['arguments must be an object']);
assert.deepEqual(validateValue(schema, { n: Number.NaN }), [
'arguments.n must be a number',
]);
});
test('inherited names are not declared properties, and schema-valued additionalProperties is refused', () => {
const schema = {
type: 'object',
properties: { name: { type: 'string' } },
additionalProperties: false,
};
for (const name of ['constructor', 'toString', '__proto__'])
assert.deepEqual(validateValue(schema, JSON.parse(`{"${name}": 1}`)), [
`arguments has unknown property ${name}`,
]);
assert.deepEqual(validateValue({ ...schema, required: ['toString'] }, {}), [
'arguments is missing toString',
]);
assert.throws(
() =>
defineTool({
name: 'loose',
title: 'Loose',
description: 'Promises checks it cannot make.',
inputSchema: {
type: 'object',
additionalProperties: { type: 'string' },
},
run: async () => ({ summary: '', data: {} }),
}),
/additionalProperties must be true or false/,
);
});
-28
View File
@@ -1,28 +0,0 @@
/** Function-calling adapter for a tool catalog: tool records and results. */
/** Tools as `{ type: 'function', name, description, parameters }` records. */
export function toFunctionTools(tools, { exclude = [] } = {}) {
const skipped = new Set(exclude);
return tools
.filter((tool) => !skipped.has(tool.name))
.map((tool) => ({
type: 'function',
name: tool.name,
description: tool.description,
parameters: structuredClone(tool.inputSchema),
}));
}
/**
* A tool result as one JSON function output. Images are counted rather than
* sent, since function outputs carry text.
*/
export function toFunctionOutput(name, result) {
return {
ok: true,
tool: name,
summary: result.summary,
data: result.data,
...(result.images?.length ? { images_omitted: result.images.length } : {}),
};
}
-50
View File
@@ -1,50 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { coreTools, toFunctionOutput, toFunctionTools } from './index.js';
test('catalog tools become independent function records', () => {
const records = toFunctionTools(coreTools, {
exclude: ['show_in_gods_eye_view'],
});
assert.equal(records.length, coreTools.length - 1);
assert.ok(!records.some((tool) => tool.name === 'show_in_gods_eye_view'));
const weather = records.find((tool) => tool.name === 'get_weather');
const source = coreTools.find((tool) => tool.name === 'get_weather');
assert.deepEqual(Object.keys(weather), [
'type',
'name',
'description',
'parameters',
]);
assert.equal(weather.type, 'function');
assert.equal(weather.description, source.description);
assert.deepEqual(weather.parameters, source.inputSchema);
assert.notEqual(weather.parameters, source.inputSchema);
weather.parameters.properties.location.description = 'changed';
assert.notEqual(
source.inputSchema.properties.location.description,
'changed',
);
assert.ok(records.every((tool) => tool.parameters.type === 'object'));
});
test('results become function outputs without image data', () => {
assert.deepEqual(
toFunctionOutput('get_wind', { summary: 'Calm.', data: { calm: true } }),
{ ok: true, tool: 'get_wind', summary: 'Calm.', data: { calm: true } },
);
assert.deepEqual(
toFunctionOutput('get_weather_map', {
summary: 'Radar.',
data: {},
images: [{ mimeType: 'image/png', data: 'AAAA' }],
}),
{
ok: true,
tool: 'get_weather_map',
summary: 'Radar.',
data: {},
images_omitted: 1,
},
);
});
-128
View File
@@ -1,128 +0,0 @@
/**
* The God's Eye View panel: an MCP Apps view (`io.modelcontextprotocol/ui`) that shows
* God's Eye View inside a conversation. The panel is a small page that runs
* the app's panel build inside itself in embed mode, loading it through the
* MCP server, and sends it each view the show_in_gods_eye_view tool returns;
* see docs/TOOLS.md.
*/
export const GLOBE_PANEL_URI = 'ui://gods-eye-view/globe';
/** Where the app's server serves its panel build (see build/panel.js). */
export const PANEL_BASE = '/panel/';
/** The script the panel runs ahead of Cesium's workers, in the build. */
export const PANEL_WORKER_PRELUDE_PATH = 'cesium/worker-prelude.js';
/**
* Sites the app's browser code loads from directly: map imagery, 3D tiles,
* terrain and fonts. Everything from the app's own server comes through the
* MCP server.
*/
const PROVIDER_ORIGINS = Object.freeze([
'https://tile.googleapis.com',
'https://maps.googleapis.com',
'https://api.cesium.com',
'https://assets.ion.cesium.com',
'https://assets.cesium.com',
'https://dev.virtualearth.net',
'https://ecn.t0.tiles.virtualearth.net',
'https://ecn.t1.tiles.virtualearth.net',
'https://ecn.t2.tiles.virtualearth.net',
'https://ecn.t3.tiles.virtualearth.net',
'https://services.arcgisonline.com',
'https://server.arcgisonline.com',
'https://tile.openstreetmap.org',
'https://tiles.openfreemap.org',
'https://gibs.earthdata.nasa.gov',
'https://fonts.googleapis.com',
'https://fonts.gstatic.com',
]);
export const MCP_APP_MIME_TYPE = 'text/html;profile=mcp-app';
const MCP_APPS_PROTOCOL_VERSION = '2026-01-26';
const PANEL_HEIGHT_PX = 520;
const LOAD_TIMEOUT_MS = 90_000;
/**
* The app's address inside the panel, for code that needs an https address
* (the page's own may use a host's scheme). The panel loads its paths
* through the MCP server; the name never resolves.
*/
const PANEL_APP_BASE_URL = 'https://app.gods-eye-view.invalid/';
/** The panel-only tool the panel loads the app through. */
export const PANEL_REQUEST_TOOL = 'panel_request';
/**
* The panel page: its status line, the Open in God's Eye View button, and
* the panel's script, `runtime`, which loads the app through the MCP server
* and shows each view a tool returns.
*/
function panelHtml(runtime) {
const config = {
appBaseUrl: PANEL_APP_BASE_URL,
loadTimeoutMs: LOAD_TIMEOUT_MS,
panelBase: PANEL_BASE,
panelHeight: PANEL_HEIGHT_PX,
protocolVersion: MCP_APPS_PROTOCOL_VERSION,
toolName: PANEL_REQUEST_TOOL,
workerPreludePath: PANEL_WORKER_PRELUDE_PATH,
};
const script = `(${runtime})(${JSON.stringify(config)});`.replace(
/<\/script/gi,
'<\\/script',
);
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>God's Eye View</title>
<style>
html, body { margin: 0; height: 100%; min-height: ${PANEL_HEIGHT_PX}px; background: #05070a;
color: #b8c4cc; font: 13px/1.4 system-ui, sans-serif; overflow: hidden; }
#status { position: absolute; inset: 0; z-index: 10000; display: flex; align-items: center;
justify-content: center; padding: 0 24px; text-align: center; }
#actions { position: absolute; right: 10px; top: 10px; z-index: 10001; display: flex; gap: 6px; }
#actions button { padding: 6px 10px; border: 1px solid #3a4a55; border-radius: 6px;
background: rgba(5, 7, 10, 0.75); color: #dfe8ee; font: inherit; cursor: pointer; }
#actions button[hidden] { display: none; }
</style>
</head>
<body>
<div id="status">Waiting for a view…</div>
<div id="actions">
<button id="expand" type="button" hidden>Expand</button>
<button id="open" type="button" hidden>Open in God's Eye View</button>
</div>
<script>${script}</script>
</body>
</html>
`;
}
/**
* The panel as an MCP resource. `runtime` is the panel's script, a function
* of its configuration that runs in the panel page; the application supplies
* it (src/app/globePanelRuntime.js), as it is browser code. The security
* policy lets the panel load map imagery, tiles and fonts from their
* providers; everything from the app's own server arrives through the MCP
* server instead.
*/
export function createGlobePanelResource({ runtime }) {
if (typeof runtime !== 'function')
throw new TypeError('The globe panel needs its runtime script');
return Object.freeze({
uri: GLOBE_PANEL_URI,
name: 'globe',
title: "God's Eye View globe",
description: "Live God's Eye View, showing the view a tool returns.",
mimeType: MCP_APP_MIME_TYPE,
text: panelHtml(runtime),
_meta: {
ui: {
csp: {
resourceDomains: [...PROVIDER_ORIGINS],
connectDomains: [...PROVIDER_ORIGINS],
},
prefersBorder: true,
},
},
});
}
-110
View File
@@ -1,110 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
GLOBE_PANEL_URI,
MCP_APP_MIME_TYPE,
createGlobePanelResource,
} from './globePanel.js';
import { panelRuntime } from '../app/globePanelRuntime.js';
import { composeCatalog, coreTools } from './index.js';
test('the globe panel is an MCP Apps resource that loads the app through its server', () => {
const resource = createGlobePanelResource({ runtime: panelRuntime });
assert.equal(resource.uri, GLOBE_PANEL_URI);
assert.equal(resource.mimeType, MCP_APP_MIME_TYPE);
assert.equal(MCP_APP_MIME_TYPE, 'text/html;profile=mcp-app');
const { csp } = resource._meta.ui;
// The panel reaches only map and font providers; the app's own files
// and data come through the MCP server, so no app address is declared.
assert.deepEqual(csp.connectDomains, csp.resourceDomains);
assert.ok(csp.resourceDomains.includes('https://tile.googleapis.com'));
assert.ok(
csp.resourceDomains.every((origin) => origin.startsWith('https://')),
);
assert.equal(csp.frameDomains, undefined);
assert.equal(csp.baseUriDomains, undefined);
assert.match(resource.text, /"toolName":"panel_request"/);
assert.match(resource.text, /"panelBase":"\/panel\/"/);
assert.match(resource.text, /'ui\/initialize'/);
assert.match(resource.text, /'ui\/notifications\/tool-result'/);
assert.match(resource.text, /"protocolVersion":"2026-01-26"/);
// The MCP Apps SDK's initialize parameters; hosts reject anything else.
assert.match(resource.text, /appInfo: \{/);
assert.doesNotMatch(resource.text, /clientInfo/);
// Fullscreen where the host offers it.
assert.match(resource.text, /'ui\/request-display-mode'/);
assert.match(resource.text, /<button id="expand" type="button" hidden>/);
assert.doesNotMatch(resource.text, /<iframe|createElement\('base'\)/);
const script = resource.text.match(/<script>([\s\S]*)<\/script>/)[1];
assert.doesNotThrow(() => new Function(script));
});
test('show_in_gods_eye_view names the panel and shows a view another answer returned', async () => {
const catalog = composeCatalog({
tools: coreTools,
services: { app: { baseUrl: 'http://localhost:5173/' } },
});
assert.deepEqual(catalog.get('show_in_gods_eye_view').ui, {
resourceUri: GLOBE_PANEL_URI,
});
const earlier = {
camera: {
lat: 25,
lon: 121,
altitude_m: 300000,
heading_deg: 0,
pitch_deg: -90,
},
layers: ['ais-live-vessels'],
style: null,
map: null,
follow: null,
annotations: [],
url: 'http://localhost:5173/#v=2',
};
const shown = await catalog.call('show_in_gods_eye_view', {
view: earlier,
layers: ['ais-live-vessels', 'military'],
style: 'thermal',
});
assert.deepEqual(shown.data.view.camera, earlier.camera);
assert.deepEqual(shown.data.view.layers, ['ais-live-vessels', 'military']);
assert.equal(shown.data.view.style, 'thermal');
await assert.rejects(
catalog.call('show_in_gods_eye_view', { view: { camera: { lat: 'x' } } }),
(error) => error.code === 'invalid_arguments',
);
});
test('a view that only follows an aircraft is framed where the aircraft is', async () => {
const { resolveViewArguments } = await import('./queries/share.js');
const tools = {
has: (name) => name === 'find_aircraft',
call: async (name, args) => ({
data: {
rows:
args.icao24 === 'ae1234'
? [{ id: 'ae1234', lat: 32.7, lon: -117.2 }]
: [],
},
}),
};
const { view, label } = await resolveViewArguments(
{ follow: { kind: 'military_aircraft', id: 'ae1234', cockpit: true } },
{ services: {}, tools },
);
assert.deepEqual(
[view.camera.lat, view.camera.lon, view.follow.cockpit],
[32.7, -117.2, true],
);
assert.equal(label, 'military aircraft ae1234');
await assert.rejects(
resolveViewArguments(
{ follow: { kind: 'aircraft', id: 'abc999' } },
{ services: {}, tools },
),
(error) =>
error.code === 'invalid_arguments' &&
/not reported now/.test(error.message),
);
});
-127
View File
@@ -1,127 +0,0 @@
/**
* Tools that answer questions from God's Eye View data, independent of the
* surface that exposes them. See docs/TOOLS.md.
*/
import {
aircraftInArea,
findAircraft,
getAircraftInfo,
getAircraftTrack,
} from './queries/aviation.js';
import { getWeatherMap, getWind } from './queries/atmosphere.js';
import { findSubmarineCables } from './queries/cables.js';
import {
getHudCaption,
militaryAwareness,
situationBrief,
} from './queries/brief.js';
import {
findMilitaryInstallations,
getCyclones,
getFirePerimeters,
getMapFeatures,
getRegionalBrief,
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';
import {
findVessel,
getVesselTrack,
vesselsInArea,
} from './queries/maritime.js';
import {
getBikeShare,
getTrafficFlow,
getTransitVehicles,
} from './queries/mobility.js';
import { placesNearby, planRoute, searchPlaces } from './queries/places.js';
import { panelRequest } from './queries/panelRequest.js';
import { showInGodsEyeView } from './queries/share.js';
import { findAlprCameras } from './queries/surveillance.js';
import {
findCctvCameras,
findRadioStations,
getCctvSnapshot,
} from './queries/media.js';
import {
getRecentLaunches,
nextSatellitePass,
satellitesOverhead,
} from './queries/space.js';
export {
defineTool,
composeCatalog,
ToolError,
TOOL_ERROR_CODES,
} from './catalog.js';
export {
AREA_SCHEMA,
resolveArea,
areaCenter,
areaContains,
distanceKm,
} from './area.js';
export { LIMIT_SCHEMA, DEFAULT_LIMIT, MAX_LIMIT, capRows } from './results.js';
export { toFunctionOutput, toFunctionTools } from './functions.js';
export {
SURFACES,
TOOL_SURFACES,
catalogForSurface,
toolsForSurface,
} from './surfaces.js';
export {
createGeocodePlaceService,
createPlaceSearchService,
createRouteService,
placeFromGeocodeResult,
} from './places.js';
/** Every query Core defines, in a stable order. */
export const coreTools = Object.freeze([
getEarthquakes,
getActiveFires,
getRecentLaunches,
aircraftInArea,
findAircraft,
getAircraftTrack,
getAircraftInfo,
vesselsInArea,
findVessel,
getVesselTrack,
nextSatellitePass,
satellitesOverhead,
findCctvCameras,
getCctvSnapshot,
findAlprCameras,
findRadioStations,
searchPlaces,
placesNearby,
planRoute,
getBikeShare,
getTransitVehicles,
getTrafficFlow,
getWeather,
getWeatherMap,
getWind,
getRecentImagery,
findSubmarineCables,
findInfrastructure,
getBhoteKoshiFlood,
getRegionalBrief,
getCyclones,
getFirePerimeters,
getTerrainHeight,
findMilitaryInstallations,
getMapFeatures,
situationBrief,
militaryAwareness,
getHudCaption,
showInGodsEyeView,
panelRequest,
]);
-166
View File
@@ -1,166 +0,0 @@
import assert from 'node:assert/strict';
import { createServer, request as httpRequest } from 'node:http';
import test from 'node:test';
import { isLocalMcpRequest, localMcpPlugin } from '../../server/mcp/plugin.js';
test('only loopback connections with loopback hosts and origins are local', () => {
const local = { remoteAddress: '127.0.0.1', host: 'localhost:4173' };
assert.equal(isLocalMcpRequest(local), true);
assert.equal(
isLocalMcpRequest({ ...local, remoteAddress: '::1', host: '[::1]:4173' }),
true,
);
assert.equal(
isLocalMcpRequest({ ...local, origin: 'http://127.0.0.1:5173' }),
true,
);
for (const request of [
{ ...local, remoteAddress: '192.168.1.20' },
{ ...local, host: 'attacker.example:4173' },
{ ...local, host: 'localhost.attacker.example' },
{ ...local, host: '' },
{ ...local, origin: 'https://attacker.example' },
{ ...local, origin: 'null' },
{ ...local, origin: 'file:///tmp/page.html' },
])
assert.equal(isLocalMcpRequest(request), false, JSON.stringify(request));
});
test('the /mcp route answers local MCP requests and refuses others', async (t) => {
let middleware;
const created = [];
const plugin = localMcpPlugin({
createServer: ({ apiBase }) => {
created.push(apiBase);
return {
handle: async (message) =>
message.id === undefined
? null
: { jsonrpc: '2.0', id: message.id, result: { apiBase } },
};
},
});
plugin.configureServer({
middlewares: { use: (path, handler) => (middleware = handler) },
});
const http = createServer((req, res) => middleware(req, res));
await new Promise((resolve) => http.listen(0, '127.0.0.1', resolve));
t.after(() => http.close());
const { port } = http.address();
const send = (method, body, headers = {}) =>
new Promise((resolve, reject) => {
const request = httpRequest(
{
host: '127.0.0.1',
port,
path: '/mcp',
method,
headers: {
'Content-Type': 'application/json',
Host: `localhost:${port}`,
...headers,
},
},
(response) => {
let text = '';
response.on('data', (chunk) => (text += chunk));
response.on('end', () =>
resolve({
status: response.statusCode,
json: () => JSON.parse(text),
}),
);
},
);
request.on('error', reject);
request.end(
body === undefined
? undefined
: typeof body === 'string'
? body
: JSON.stringify(body),
);
});
const post = (body, headers) => send('POST', body, headers);
const ok = await post({ jsonrpc: '2.0', id: 1, method: 'ping' });
assert.equal(ok.status, 200);
assert.deepEqual(await ok.json(), {
jsonrpc: '2.0',
id: 1,
result: { apiBase: `http://localhost:${port}` },
});
assert.equal(
(await post({ jsonrpc: '2.0', method: 'notifications/initialized' }))
.status,
202,
);
await post({ jsonrpc: '2.0', id: 2, method: 'ping' });
assert.deepEqual(created, [`http://localhost:${port}`]);
const foreign = await post(
{ jsonrpc: '2.0', id: 3, method: 'ping' },
{ Origin: 'https://attacker.example' },
);
assert.equal(foreign.status, 403);
assert.deepEqual(await foreign.json(), {
error: 'The MCP server only accepts local requests',
});
assert.equal((await post({}, { Host: 'attacker.example' })).status, 403);
assert.equal(
(await post({}, { Host: `localhost.attacker.example:${port}` })).status,
403,
);
assert.equal((await post('x'.repeat(1024 * 1024 + 1))).status, 413);
assert.equal(
(
await fetch(`http://127.0.0.1:${port}/mcp`, {
headers: { Host: `localhost:${port}` },
})
).status,
405,
);
});
test('a client that disconnects cancels its tool call', async (t) => {
let middleware;
let seen;
const started = Promise.withResolvers();
const aborted = Promise.withResolvers();
const plugin = localMcpPlugin({
createServer: () => ({
handle: (message, { signal }) => {
seen = signal;
signal.addEventListener('abort', () => aborted.resolve(), {
once: true,
});
started.resolve();
return new Promise(() => {});
},
}),
});
plugin.configureServer({
middlewares: { use: (path, handler) => (middleware = handler) },
});
const http = createServer((req, res) => middleware(req, res));
await new Promise((resolve) => http.listen(0, '127.0.0.1', resolve));
t.after(() => http.close());
const { port } = http.address();
const request = httpRequest({
host: '127.0.0.1',
port,
path: '/mcp',
method: 'POST',
headers: {
'Content-Type': 'application/json',
Host: `localhost:${port}`,
},
});
request.on('error', () => {});
request.end(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call' }));
await started.promise;
assert.equal(seen.aborted, false);
request.destroy();
await aborted.promise;
assert.equal(seen.aborted, true);
});
-133
View File
@@ -1,133 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { PassThrough } from 'node:stream';
import { createLocalMcpServer } from '../../server/mcp/server.js';
import { createApiFetch } from '../../server/mcp/services.js';
import { parseArgs, serveStdio } from '../../server/mcp/stdio.js';
import { coreTools, toolsForSurface } from './index.js';
const usgs = {
type: 'FeatureCollection',
features: [
{
id: 'us1',
geometry: { type: 'Point', coordinates: [121.5, 24, 10] },
properties: { mag: 5.1, place: 'near Hualien', time: 1767229200000 },
},
],
};
test('relative API requests resolve against the configured base', async () => {
const seen = [];
const apiFetch = createApiFetch({
apiBase: 'http://127.0.0.1:5000',
fetchImpl: async (url) => seen.push(String(url)),
});
await apiFetch('/api/launches');
await apiFetch('https://earthquake.usgs.gov/feed.geojson');
assert.deepEqual(seen, [
'http://127.0.0.1:5000/api/launches',
'https://earthquake.usgs.gov/feed.geojson',
]);
assert.throws(
() => createApiFetch({ apiBase: 'file:///etc' }),
/http\(s\) URL/,
);
});
test('the stdio server answers newline-delimited requests using only its data sources', async () => {
const contacted = [];
const server = createLocalMcpServer({
apiBase: 'http://127.0.0.1:5000',
fetchImpl: async (url) => {
contacted.push(new URL(url).origin);
return Response.json(usgs);
},
});
const input = new PassThrough();
const output = new PassThrough();
let written = '';
output.on('data', (chunk) => (written += chunk));
const served = serveStdio(server, { input, output });
input.write(
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}\n',
);
input.write('{"jsonrpc":"2.0","method":"notifications/initialized"}\n\n');
input.write('not json\n');
input.write('{"jsonrpc":"2.0","id":2,"method":"tools/list"}\n');
input.end(
'{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_earthquakes","arguments":{}}}\n',
);
await served;
const responses = written
.trim()
.split('\n')
.map((line) => JSON.parse(line));
const byId = new Map(responses.map((response) => [response.id, response]));
assert.equal(responses.length, 4);
assert.equal(byId.get(1).result.serverInfo.name, 'gods-eye-view');
assert.equal(byId.get(null).error.code, -32700);
// Every Core tool's services are composed locally.
assert.deepEqual(
byId.get(2).result.tools.map((tool) => tool.name),
toolsForSurface(coreTools, 'mcp').map((tool) => tool.name),
);
assert.equal(
byId.get(3).result.content[0].text,
'1 earthquake of M2.5+ in the last 24 hours worldwide; strongest M5.1 near Hualien.',
);
// The earthquake feed is fetched directly; nothing else was contacted.
assert.deepEqual(contacted, ['https://earthquake.usgs.gov']);
});
test('command-line arguments are strict', () => {
assert.deepEqual(parseArgs([]), {});
assert.deepEqual(parseArgs(['--api-base', 'http://localhost:5173']), {
apiBase: 'http://localhost:5173',
});
assert.throws(() => parseArgs(['--port', '1']), /Unknown argument/);
});
test('the stdio log names methods, tools and resources but never arguments', async () => {
const { describeRequest } = await import('../../server/mcp/stdio.js');
assert.equal(describeRequest({ method: 'tools/list' }), 'tools/list');
assert.equal(
describeRequest({
method: 'tools/call',
params: { name: 'get_weather', arguments: { location: { place: 'x' } } },
}),
'tools/call get_weather',
);
assert.equal(
describeRequest({ method: 'resources/read', params: { uri: 'ui://a/b' } }),
'resources/read ui://a/b',
);
assert.equal(describeRequest({ result: {} }), null);
});
test('a failed tool call is logged with its reason', async () => {
const server = {
handle: async (message) => ({
jsonrpc: '2.0',
id: message.id,
result: { isError: true, content: [{ type: 'text', text: 'bad area' }] },
}),
};
const input = new PassThrough();
const output = new PassThrough();
output.resume();
const logged = [];
const served = serveStdio(server, {
input,
output,
log: (line) => logged.push(line),
});
input.end(
'{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"show_in_gods_eye_view"}}\n',
);
await served;
assert.deepEqual(logged, [
'tools/call show_in_gods_eye_view',
' tools/call show_in_gods_eye_view failed: bad area',
]);
});
-78
View File
@@ -1,78 +0,0 @@
/**
* Stateless Streamable HTTP transport for an MCP server: one JSON-RPC message
* per POST, answered with a JSON body. Uses standard Request and Response so
* any web-standard runtime can mount it. The host owns routing and any access
* control in front of it.
*/
import { MCP_PROTOCOL_VERSIONS, parseErrorResponse } from './protocol.js';
const MAX_BODY_BYTES = 1024 * 1024;
/** Return `(request) => Promise<Response>` for an MCP server. */
export function createMcpHttpHandler(server) {
return async function handleMcpRequest(request) {
if (request.method !== 'POST')
return new Response(null, { status: 405, headers: { Allow: 'POST' } });
const version = request.headers.get('mcp-protocol-version');
if (version && !MCP_PROTOCOL_VERSIONS.includes(version))
return json(400, {
error: `Unsupported MCP protocol version: ${version}`,
});
const type = request.headers.get('content-type') || '';
if (!/^application\/json\b/i.test(type))
return json(415, { error: 'Content-Type must be application/json' });
const declared = Number(request.headers.get('content-length'));
if (declared > MAX_BODY_BYTES)
return json(413, { error: 'Request too large' });
const text = await readCapped(request, MAX_BODY_BYTES);
if (text === null) return json(413, { error: 'Request too large' });
let message;
try {
message = JSON.parse(text);
} catch {
return json(400, parseErrorResponse());
}
const response = await server.handle(message, { signal: request.signal });
return response ? json(200, response) : new Response(null, { status: 202 });
};
}
/**
* Read a request body as text, stopping as soon as it passes `limit` bytes,
* so a body without a Content-Length cannot be buffered without bound.
* Returns null when the body is too large.
*/
async function readCapped(request, limit) {
if (!request.body) return '';
const reader = request.body.getReader();
const chunks = [];
let size = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
size += value.byteLength;
if (size > limit) {
await reader.cancel().catch(() => {});
return null;
}
chunks.push(value);
}
const bytes = new Uint8Array(size);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return new TextDecoder().decode(bytes);
}
function json(status, body) {
return new Response(JSON.stringify(body), {
status,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
},
});
}
-9
View File
@@ -1,9 +0,0 @@
/** Model Context Protocol adapter for a tool catalog. See docs/TOOLS.md. */
export {
createMcpServer,
toMcpTools,
parseErrorResponse,
MCP_PROTOCOL_VERSIONS,
} from './protocol.js';
export { createMcpHttpHandler } from './http.js';
Loaded 100 of 151 files, more files were not shown because too many files have changed in this diff. Show more