mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge pull request #896 from bilawalsidhu/feat/tool-catalog
Add a shared tool catalog and MCP server, with God's Eye View inside Claude and ChatGPT
This commit is contained in:
136 files changed
+16262
-1034
No files matched your search
@@ -186,3 +186,8 @@ 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=
|
||||
@@ -1,5 +1,29 @@
|
||||
# Changelog
|
||||
|
||||
- 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.
|
||||
|
||||
- Public Overpass instances are no longer used by default. Street Traffic
|
||||
roads come from TomTom flow tiles, OpenFreeMap vector tiles, or both, chosen
|
||||
on the layer row (TomTom / OSM / Hybrid) or with `?trafficRoads=`. With a
|
||||
|
||||
@@ -279,6 +279,8 @@ _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.)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* 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
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* 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`;
|
||||
}
|
||||
+10
-1
@@ -1,5 +1,7 @@
|
||||
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({
|
||||
@@ -12,7 +14,13 @@ export function createBrowserViteConfig({
|
||||
command,
|
||||
} = {}) {
|
||||
return {
|
||||
plugins: [cesium(), applicationHtmlPlugin(), ...plugins],
|
||||
plugins: [
|
||||
cesium(),
|
||||
applicationHtmlPlugin(),
|
||||
...plugins,
|
||||
embedFramingPlugin(),
|
||||
panelBuildPlugin(),
|
||||
],
|
||||
...(publicDir === undefined ? {} : { publicDir }),
|
||||
// A production build must not clean the dependency cache a running dev
|
||||
// server is still serving optimized module URLs from.
|
||||
@@ -39,6 +47,7 @@ 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'",
|
||||
|
||||
@@ -1,5 +1,42 @@
|
||||
# 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
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# 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
@@ -0,0 +1,291 @@
|
||||
# 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 |
|
||||
+8
-2
@@ -41,12 +41,14 @@
|
||||
"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"
|
||||
"qa:transit": "node scripts/qa-transit.mjs",
|
||||
"mcp": "node server/mcp/stdio.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@jtarrio/signals": "^0.10.0",
|
||||
@@ -250,6 +252,10 @@
|
||||
"./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"
|
||||
"./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"
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,8 @@ For commercial use, obtain separate permission or exclude both this pack and the
|
||||
derived coordinate dataset in `src/data/bhoteKoshiFloodPath.js` from the source
|
||||
and build. Deleting this directory alone does not remove the compiled river
|
||||
data. Excluding the Nepal scene also requires removing its registrations and
|
||||
imports before building.
|
||||
imports before building, including the `get_bhote_koshi_flood` tool in
|
||||
`src/tools/queries/events.js`.
|
||||
|
||||
Sources:
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/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}`);
|
||||
@@ -37,6 +37,10 @@ const portableExport = (key) =>
|
||||
) ||
|
||||
[
|
||||
'./director',
|
||||
'./tools',
|
||||
'./tools/mcp',
|
||||
'./tools/services',
|
||||
'./view',
|
||||
'./voice/action-schemas',
|
||||
'./voice/session',
|
||||
'./data/lifecycle',
|
||||
|
||||
@@ -864,5 +864,33 @@
|
||||
"src/layers/traffic/motion.test.mjs",
|
||||
"scripts/qa-journey-recorder.mjs",
|
||||
"scripts/qa-alpr-journey.mjs",
|
||||
"scripts/qa-context-arrivals.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"
|
||||
]
|
||||
@@ -10,7 +10,8 @@
|
||||
"src/data/localGeojsonCore.js",
|
||||
"src/data/localGeojsonLod.js",
|
||||
"src/data/inputOwnership.js",
|
||||
"src/data/infrastructureOverlayEntry.js"
|
||||
"src/data/infrastructureOverlayEntry.js",
|
||||
"src/sources/infrastructureData.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -27,7 +28,12 @@
|
||||
"vite-build": {
|
||||
"runtime": "node",
|
||||
"exports": ["./build/vite", "./build/html"],
|
||||
"modules": ["build/vite.js", "build/application-html.js"],
|
||||
"modules": [
|
||||
"build/vite.js",
|
||||
"build/application-html.js",
|
||||
"build/embed-framing.js",
|
||||
"build/panel.js"
|
||||
],
|
||||
"external": ["vite-plugin-cesium"]
|
||||
},
|
||||
"live-providers": {
|
||||
@@ -513,6 +519,7 @@
|
||||
"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",
|
||||
@@ -834,6 +841,7 @@
|
||||
"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",
|
||||
@@ -922,6 +930,7 @@
|
||||
"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",
|
||||
@@ -1079,6 +1088,7 @@
|
||||
"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",
|
||||
@@ -1433,6 +1443,7 @@
|
||||
"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",
|
||||
@@ -1525,6 +1536,7 @@
|
||||
"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",
|
||||
@@ -1659,6 +1671,7 @@
|
||||
"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",
|
||||
@@ -2027,7 +2040,12 @@
|
||||
"src/sources/requestDeadline.js",
|
||||
"src/data/local_data/osm_military_names/names.json",
|
||||
"src/layers/installations/namedMarkers.js",
|
||||
"src/data/infrastructureOverlayEntry.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"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
@@ -2189,6 +2207,7 @@
|
||||
"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",
|
||||
@@ -2323,6 +2342,7 @@
|
||||
"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",
|
||||
@@ -2511,7 +2531,8 @@
|
||||
"src/sources/requestDeadline.js",
|
||||
"src/data/local_data/osm_military_names/names.json",
|
||||
"src/layers/installations/namedMarkers.js",
|
||||
"src/data/infrastructureOverlayEntry.js"
|
||||
"src/data/infrastructureOverlayEntry.js",
|
||||
"src/sources/infrastructureData.js"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
@@ -2605,6 +2626,7 @@
|
||||
"modules": [
|
||||
"src/data/installationFeedback.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/layerStateCoordinator.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/loadingFeedback.js",
|
||||
"src/navigationPolicy.js",
|
||||
@@ -3072,5 +3094,142 @@
|
||||
"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": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/** 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 })],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* 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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,36 @@ 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
|
||||
@@ -107,6 +137,7 @@ 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)}`,
|
||||
},
|
||||
);
|
||||
@@ -470,10 +501,7 @@ export function openSkyProxy() {
|
||||
}
|
||||
}
|
||||
|
||||
let upstream = await fetch(
|
||||
'https://opensky-network.org/api/states/all?extended=1',
|
||||
{ headers },
|
||||
);
|
||||
let { upstream, body } = await fetchOpenSkyStates(headers);
|
||||
// Auto-mode fallback: if OAuth was rejected, retry with Basic credentials
|
||||
if (
|
||||
(upstream.status === 401 || upstream.status === 403) &&
|
||||
@@ -485,15 +513,11 @@ export function openSkyProxy() {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`,
|
||||
};
|
||||
upstream = await fetch(
|
||||
'https://opensky-network.org/api/states/all?extended=1',
|
||||
{ headers: retryHeaders },
|
||||
);
|
||||
({ upstream, body } = await fetchOpenSkyStates(retryHeaders));
|
||||
usedMode = 'basic';
|
||||
reason = 'oauth_rejected_fallback_basic';
|
||||
}
|
||||
|
||||
let body = await upstream.text();
|
||||
const sourceEpochMs = upstream.ok ? openSkySourceEpochMs(body) : null;
|
||||
if (
|
||||
upstream.ok &&
|
||||
|
||||
@@ -143,8 +143,10 @@ 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,
|
||||
|
||||
@@ -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 { normalizeSourceItem } from './normalize.js';
|
||||
import { cameraRegion, normalizeSourceItem } from './normalize.js';
|
||||
import {
|
||||
loadAustinSourcesFromOpenData,
|
||||
loadCaltransSourcesFromOpenData,
|
||||
@@ -143,6 +143,9 @@ 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.
|
||||
@@ -209,8 +212,12 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
|
||||
name,
|
||||
sources: items
|
||||
.filter((item) => item && typeof item === 'object')
|
||||
.map((item) => normalizeSourceItem(item))
|
||||
.map((item) => ({ ...normalizeSourceItem(item), pack: name }))
|
||||
.filter((item) => item.id),
|
||||
available: Number.isFinite(items.available)
|
||||
? items.available
|
||||
: items.length,
|
||||
region: items.region ?? null,
|
||||
});
|
||||
const packs = [
|
||||
...LIVE_PACKS.map((pack, index) =>
|
||||
@@ -234,6 +241,16 @@ 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}`)
|
||||
@@ -244,6 +261,7 @@ 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
|
||||
@@ -257,5 +275,7 @@ 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;
|
||||
}
|
||||
@@ -470,7 +470,31 @@ export function prioritizeSources(cameras, maxCount, anchors) {
|
||||
return a.idx - b.idx;
|
||||
});
|
||||
|
||||
return scored.slice(0, cap).map((entry) => entry.camera);
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -26,8 +26,11 @@ import { firePerimetersProxy } from './firePerimeters.js';
|
||||
import { cycloneProxy } from './cyclones.js';
|
||||
import { windProxy } from './wind.js';
|
||||
|
||||
/** Construct the local provider plugins in their established order. */
|
||||
function localProviderPlugins() {
|
||||
/**
|
||||
* Construct the local provider plugins in their established order.
|
||||
* `realtime` configures the voice session token endpoint.
|
||||
*/
|
||||
function localProviderPlugins({ realtime } = {}) {
|
||||
return [
|
||||
openSkyProxy(),
|
||||
celestrakProxy(),
|
||||
@@ -49,7 +52,7 @@ function localProviderPlugins() {
|
||||
adsbLolProxy(),
|
||||
aisLiveProxy(),
|
||||
trackBackfillProxies(),
|
||||
openAiRealtimeProxy(),
|
||||
openAiRealtimeProxy({ realtime }),
|
||||
googlePlacesContextProxy(),
|
||||
windProxy(),
|
||||
weatherProxy(),
|
||||
|
||||
@@ -19,6 +19,7 @@ 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.',
|
||||
@@ -48,6 +49,7 @@ 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.',
|
||||
|
||||
@@ -20,6 +20,7 @@ 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');
|
||||
@@ -113,7 +114,7 @@ function createRealtimeTokenHandler({
|
||||
output: { voice },
|
||||
},
|
||||
instructions: realtimeInstructions(annotationGuidance),
|
||||
tools: GEV_REALTIME_TOOLS,
|
||||
tools,
|
||||
tool_choice: 'auto',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,3 +2,15 @@ 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)),
|
||||
];
|
||||
}
|
||||
@@ -2,7 +2,9 @@ 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));
|
||||
|
||||
@@ -13,7 +15,11 @@ export default defineConfig(({ command, mode }) => {
|
||||
if (process.env[key] === undefined) process.env[key] = value;
|
||||
}
|
||||
return createBrowserViteConfig({
|
||||
plugins: [...localProviderPlugins(), apiNotFoundPlugin()],
|
||||
plugins: [
|
||||
...localProviderPlugins({ realtime: { tools: standaloneVoiceTools() } }),
|
||||
localMcpPlugin(),
|
||||
apiNotFoundPlugin(),
|
||||
],
|
||||
googleApiKey: process.env.GOOGLE_MAPS_API_KEY,
|
||||
cesiumToken: process.env.CESIUM_ION_TOKEN,
|
||||
host: process.env.HOST,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* 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')),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* 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);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
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/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,661 @@
|
||||
/**
|
||||
* 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}` : '.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
+13
-1
@@ -4,6 +4,7 @@ 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,
|
||||
@@ -118,7 +119,9 @@ 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 = () => {
|
||||
const hidden = document.hidden;
|
||||
// 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();
|
||||
viewer.useDefaultRenderLoop = !hidden;
|
||||
cockpitCloudEffects?.setSuspended?.(hidden);
|
||||
if (!hidden) {
|
||||
@@ -176,5 +179,14 @@ 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 };
|
||||
}
|
||||
@@ -5,6 +5,9 @@ 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,
|
||||
|
||||
@@ -128,6 +128,14 @@ 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];
|
||||
@@ -135,3 +143,20 @@ 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 });
|
||||
});
|
||||
@@ -1,12 +1,8 @@
|
||||
import { createLocalGeoJsonLayer } from './localGeojsonCore.js';
|
||||
import { INFRASTRUCTURE_DATA_URLS } from '../sources/infrastructureData.js';
|
||||
|
||||
// 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;
|
||||
const datacentersUrl = INFRASTRUCTURE_DATA_URLS['local-datacenters'];
|
||||
const damsUrl = INFRASTRUCTURE_DATA_URLS['local-dams'];
|
||||
|
||||
/**
|
||||
* Create fresh datacenter and dam layers without starting or loading them.
|
||||
|
||||
@@ -192,6 +192,7 @@ 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',
|
||||
|
||||
+2
-723
@@ -11,8 +11,6 @@ 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
|
||||
@@ -433,12 +431,6 @@ 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',
|
||||
@@ -740,7 +732,7 @@ export function validateLayerStateAllocations(
|
||||
return true;
|
||||
}
|
||||
|
||||
const REGISTRY_BY_ID = new Map(
|
||||
export const REGISTRY_BY_ID = new Map(
|
||||
LAYER_STATE_REGISTRY.map((entry) => [entry.id, entry]),
|
||||
);
|
||||
const REGISTRY_BY_TOKEN = new Map(
|
||||
@@ -752,7 +744,7 @@ const OPTION_OWNER_IDS = Object.freeze([
|
||||
),
|
||||
]);
|
||||
|
||||
function optionSpecs(ownerId) {
|
||||
export function optionSpecs(ownerId) {
|
||||
return OPTION_GROUPS[ownerId] || [];
|
||||
}
|
||||
|
||||
@@ -1055,716 +1047,3 @@ 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;
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
LAYER_STATE_STORAGE_KEY,
|
||||
LAYER_STATE_TOKEN_ALPHABET,
|
||||
LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
LayerStateCoordinator,
|
||||
REGISTERED_LAYER_IDS,
|
||||
SHARE_TRACKING_RESTORE_POLICIES,
|
||||
createDefaultLayerState,
|
||||
@@ -24,6 +23,7 @@ import {
|
||||
validateLayerStateAllocations,
|
||||
validateLayerStateRegistry,
|
||||
} from './layerState.js';
|
||||
import { LayerStateCoordinator } from './layerStateCoordinator.js';
|
||||
import radioLayer from './radio.js';
|
||||
import { stampInitialShareGesture } from '../navigationPolicy.js';
|
||||
|
||||
|
||||
@@ -0,0 +1,742 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
layerTitle,
|
||||
mapAnalystRecord,
|
||||
parseGeojsonLines,
|
||||
} from '../sources/infrastructureData.js';
|
||||
import { createInfrastructureOverlayEntry } from './infrastructureOverlayEntry.js';
|
||||
import { isPointerFree } from './inputOwnership.js';
|
||||
import {
|
||||
@@ -86,78 +91,6 @@ 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.
|
||||
@@ -664,11 +597,7 @@ export function createLocalGeoJsonLayer(
|
||||
}
|
||||
const text = await response.text();
|
||||
if (_destroyed) return;
|
||||
const lines = text
|
||||
.split('\n')
|
||||
.filter((l) => l.trim().length > 0);
|
||||
|
||||
features = lines.map((line) => JSON.parse(line));
|
||||
features = parseGeojsonLines(text);
|
||||
_cachedFeatures = features;
|
||||
}
|
||||
|
||||
@@ -1360,8 +1289,4 @@ function clampCardLine(value) {
|
||||
return text.length > 48 ? `${text.slice(0, 45)}...` : text;
|
||||
}
|
||||
|
||||
function layerTitle(layerId) {
|
||||
if (layerId === 'local-datacenters') return 'Datacenter';
|
||||
if (layerId === 'local-dams') return 'Dam';
|
||||
return 'Feature';
|
||||
}
|
||||
export { mapAnalystRecord };
|
||||
@@ -93,8 +93,16 @@ export function nameMilitaryFragment(fragment, names) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Area-ranked spatial thinning of visible label points, including dateline views. */
|
||||
export function militaryNamesInView(names, box, cap = MILITARY_POINT_CAP) {
|
||||
/**
|
||||
* 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,
|
||||
) {
|
||||
const width =
|
||||
box.east >= box.west ? box.east - box.west : 360 + box.east - box.west;
|
||||
const height = box.north - box.south;
|
||||
@@ -107,7 +115,7 @@ export function militaryNamesInView(names, box, cap = MILITARY_POINT_CAP) {
|
||||
continue;
|
||||
count++;
|
||||
const cell = `${Math.min(31, Math.floor((x / width) * 32))}:${Math.min(15, Math.floor(((record.latitude - box.south) / height) * 16))}`;
|
||||
if (cells.has(cell) || records.length >= cap) continue;
|
||||
if ((thinned && cells.has(cell)) || records.length >= cap) continue;
|
||||
cells.add(cell);
|
||||
records.push(record);
|
||||
}
|
||||
|
||||
@@ -217,3 +217,13 @@ 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);
|
||||
});
|
||||
@@ -211,6 +211,9 @@ 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')
|
||||
|
||||
+5
-1
@@ -318,7 +318,11 @@ export async function initKeySetup({
|
||||
globalThis.location?.hash?.slice(1) || '',
|
||||
);
|
||||
if (next !== null)
|
||||
globalThis.history?.replaceState?.(null, '', `#${next}`);
|
||||
globalThis.history?.replaceState?.(
|
||||
null,
|
||||
'',
|
||||
new URL(`#${next}`, globalThis.location.href).href,
|
||||
);
|
||||
} catch {
|
||||
// Continuity is a nicety, never a blocker.
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ 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';
|
||||
|
||||
@@ -1,36 +1,12 @@
|
||||
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.
|
||||
@@ -63,93 +39,6 @@ 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,
|
||||
|
||||
@@ -51,5 +51,18 @@ 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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -148,3 +148,28 @@ 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/);
|
||||
});
|
||||
@@ -101,7 +101,7 @@ export function createInstallationSource({
|
||||
} = {}) {
|
||||
let overpassUnavailable = false;
|
||||
const installationIds = new Map();
|
||||
async function getNamedSites(box, signal) {
|
||||
async function getNamedSites(box, signal, thinned = true) {
|
||||
const names = await waitForSignal(loadNames(), signal).catch((cause) => {
|
||||
signal?.throwIfAborted();
|
||||
throw Object.assign(
|
||||
@@ -112,7 +112,13 @@ export function createInstallationSource({
|
||||
);
|
||||
});
|
||||
signal?.throwIfAborted();
|
||||
const { records, count } = militaryNamesInView(names, box);
|
||||
// The map keeps one site per display cell; queries can ask for all.
|
||||
const { records, count } = militaryNamesInView(
|
||||
names,
|
||||
box,
|
||||
thinned ? undefined : Infinity,
|
||||
thinned,
|
||||
);
|
||||
return {
|
||||
records,
|
||||
namedInView: count,
|
||||
@@ -124,9 +130,9 @@ export function createInstallationSource({
|
||||
source: 'OpenStreetMap',
|
||||
};
|
||||
}
|
||||
async function getTileSites(box, signal) {
|
||||
async function getTileSites(box, signal, thinned) {
|
||||
const zoom = installationTileZoom(box);
|
||||
if (zoom === null) return getNamedSites(box, signal);
|
||||
if (zoom === null) return getNamedSites(box, signal, thinned);
|
||||
let names = null;
|
||||
const namesJob = waitForSignal(loadNames(), signal)
|
||||
.then((loaded) => {
|
||||
@@ -181,7 +187,7 @@ export function createInstallationSource({
|
||||
mapTiles.clear();
|
||||
installationIds.clear();
|
||||
},
|
||||
async getMappedSites(box, { exact = false, signal } = {}) {
|
||||
async getMappedSites(box, { exact = false, thinned = true, signal } = {}) {
|
||||
const { south, west, north, east } = box || {};
|
||||
if (
|
||||
![south, west, north, east].every(Number.isFinite) ||
|
||||
@@ -197,8 +203,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);
|
||||
if (overpassUnavailable) return getTileSites(box, signal);
|
||||
return getNamedSites(box, signal, thinned);
|
||||
if (overpassUnavailable) return getTileSites(box, signal, thinned);
|
||||
const query = new URLSearchParams(
|
||||
Object.entries({ south, west, north, east }).map(([key, value]) => [
|
||||
key,
|
||||
@@ -213,7 +219,7 @@ export function createInstallationSource({
|
||||
signal?.throwIfAborted();
|
||||
if (isUnavailableCapability(body)) {
|
||||
overpassUnavailable = true;
|
||||
return getTileSites(box, signal);
|
||||
return getTileSites(box, signal, thinned);
|
||||
}
|
||||
if (!response.ok)
|
||||
throw Object.assign(
|
||||
|
||||
@@ -2,16 +2,24 @@
|
||||
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 {
|
||||
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;
|
||||
getLaunchSnapshot,
|
||||
async getLaunches(options) {
|
||||
return (await getLaunchSnapshot(options)).payload;
|
||||
},
|
||||
async getActiveTle({ signal } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
|
||||
@@ -8,6 +8,7 @@ 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 }) {
|
||||
@@ -40,24 +41,7 @@ export function createOrbits({ state: layerState, services, parts, source }) {
|
||||
/**
|
||||
* Parse TLE text into array of { name, line1, line2 } objects.
|
||||
*/
|
||||
|
||||
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;
|
||||
}
|
||||
const parseTLE = parseTleText;
|
||||
|
||||
/**
|
||||
* Propagate satellite position at a given JS Date.
|
||||
|
||||
@@ -19,7 +19,10 @@ export function createSatelliteSource({
|
||||
const response = await fetchImpl(`/api/celestrak/${group}`, { signal });
|
||||
const text = response.ok ? await response.text() : '';
|
||||
signal?.throwIfAborted();
|
||||
return { ok: response.ok, status: response.status, text };
|
||||
// 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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -25,21 +25,31 @@ 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 {
|
||||
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,
|
||||
})),
|
||||
);
|
||||
fetchFlowDetail,
|
||||
async fetchFlowForBounds(bounds, options) {
|
||||
return (await fetchFlowDetail(bounds, options)).segments;
|
||||
},
|
||||
getFlowSessionStats: () => ({ ...tiles.getStats(), partial }),
|
||||
resetFlowTileCache: () => tiles.clear(),
|
||||
|
||||
@@ -5,6 +5,21 @@ 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);
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
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',
|
||||
);
|
||||
});
|
||||
@@ -132,5 +132,19 @@ 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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -162,3 +162,40 @@ 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/,
|
||||
);
|
||||
});
|
||||
@@ -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 = next; } };
|
||||
globalThis.history = { replaceState(_s, _t, next) { window.location.hash = new URL(next, window.location.href).hash; } };
|
||||
const viewer = {
|
||||
camera: {
|
||||
changed: { addEventListener() {} },
|
||||
|
||||
@@ -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, nextHash) {
|
||||
window.location.hash = nextHash;
|
||||
replaceState(_state, _title, next) {
|
||||
window.location.hash = new URL(next, window.location.href).hash;
|
||||
},
|
||||
};
|
||||
const viewer = {
|
||||
|
||||
+10
-11
@@ -9,6 +9,7 @@ import {
|
||||
decodeLayerStateParams,
|
||||
encodeLayerStateParams,
|
||||
} from './data/layerState.js';
|
||||
import { STYLE_URL_NAMES } from './view/index.js';
|
||||
|
||||
/**
|
||||
* Share Links — URL Hash State Management
|
||||
@@ -20,16 +21,8 @@ import {
|
||||
const DEBOUNCE_MS = 500;
|
||||
const LEGACY_BLOOM_FALLBACK = 50;
|
||||
|
||||
// Style name mapping: internal → URL-friendly
|
||||
const STYLE_TO_URL = {
|
||||
normal: 'normal',
|
||||
retro: 'crt',
|
||||
surveillance: 'nvg',
|
||||
thermal: 'flir',
|
||||
anime: 'anime',
|
||||
noir: 'noir',
|
||||
snow: 'snow',
|
||||
};
|
||||
// Style name mapping: internal → URL-friendly, shared with views.
|
||||
const STYLE_TO_URL = STYLE_URL_NAMES;
|
||||
|
||||
const SHARE_UI_STATE_PARAM = 'ui';
|
||||
const SHARE_STYLE_PARAMS_PARAM = 'sp';
|
||||
@@ -536,7 +529,13 @@ export class ShareLinkManager {
|
||||
if (this._destroyed || this._initialRestorePending) return;
|
||||
const params = this._buildHashParams();
|
||||
if (!params) return;
|
||||
history.replaceState(null, '', `#${params.toString()}`);
|
||||
// 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,
|
||||
);
|
||||
}
|
||||
|
||||
/** Build a deterministic snapshot without mutating history. */
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/** Event packs the app serves under `/events/<id>/`, read once per id. */
|
||||
|
||||
const EVENT_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
|
||||
export function createEventPackSource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
} = {}) {
|
||||
const cache = new Map();
|
||||
return {
|
||||
async getEvent(id, { signal } = {}) {
|
||||
if (!EVENT_ID.test(id)) throw new TypeError(`Invalid event id: ${id}`);
|
||||
if (!cache.has(id)) {
|
||||
signal?.throwIfAborted();
|
||||
const response = await fetchImpl(`/events/${id}/event.json`, {
|
||||
signal,
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new Error(`Event pack ${id} unavailable (${response.status})`);
|
||||
cache.set(id, await response.json());
|
||||
}
|
||||
return cache.get(id);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/** 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;
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -29,6 +29,9 @@ 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(),
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/** 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,16 +1,22 @@
|
||||
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({
|
||||
baseUrl: new URL('/scene-assets/', window.location.href).href,
|
||||
// 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,
|
||||
}),
|
||||
},
|
||||
},
|
||||
...options,
|
||||
voice: { toolCatalog: loadToolCatalog, ...options?.voice },
|
||||
});
|
||||
}
|
||||
@@ -139,6 +139,60 @@ 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;
|
||||
|
||||
@@ -21,6 +21,7 @@ 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();
|
||||
@@ -195,6 +196,40 @@ 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);
|
||||
@@ -434,3 +469,25 @@ 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"\}/);
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
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;
|
||||
}
|
||||
});
|
||||
@@ -51,7 +51,10 @@ test('build helper does not discover environment values or construct local provi
|
||||
config.define['import.meta.env.GOOGLE_MAPS_API_KEY'],
|
||||
undefined,
|
||||
);
|
||||
assert.equal(config.plugins.length, 2);
|
||||
assert.deepEqual(
|
||||
config.plugins.slice(2).map((plugin) => plugin.name),
|
||||
['embed-framing', 'panel-build'],
|
||||
);
|
||||
} finally {
|
||||
if (before === undefined) delete process.env.GOOGLE_MAPS_API_KEY;
|
||||
else process.env.GOOGLE_MAPS_API_KEY = before;
|
||||
@@ -63,11 +66,15 @@ 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, -1).map((plugin) => plugin.name),
|
||||
config.plugins.slice(2, -4).map((plugin) => plugin.name),
|
||||
providers.localProviderPlugins().map((plugin) => plugin.name),
|
||||
);
|
||||
assert.equal(config.plugins.at(-2).name, 'gev-key-setup');
|
||||
assert.equal(config.plugins.at(-1).name, 'api-not-found');
|
||||
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');
|
||||
});
|
||||
|
||||
test('build export resolves in Node and has no browser fallback', async () => {
|
||||
@@ -78,3 +85,54 @@ 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'",
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,357 @@
|
||||
/**
|
||||
* 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))
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* 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);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
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/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
/** 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 } : {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
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,
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* 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,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
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),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* 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,
|
||||
]);
|
||||
@@ -0,0 +1,166 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
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',
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* 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',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/** 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';
|
||||
@@ -0,0 +1,315 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { composeCatalog, defineTool, ToolError } from '../catalog.js';
|
||||
import {
|
||||
createMcpHttpHandler,
|
||||
createMcpServer,
|
||||
MCP_PROTOCOL_VERSIONS,
|
||||
toMcpTools,
|
||||
} from './index.js';
|
||||
|
||||
const tool = (name, run) =>
|
||||
defineTool({
|
||||
name,
|
||||
title: 'Count',
|
||||
description: 'Counts things.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { n: { type: 'integer' } },
|
||||
additionalProperties: false,
|
||||
},
|
||||
run,
|
||||
});
|
||||
const catalog = composeCatalog({
|
||||
tools: [
|
||||
tool('count', async ({ n = 1 }) => ({
|
||||
summary: `${n} things.`,
|
||||
data: { n },
|
||||
})),
|
||||
tool('busy', async () => {
|
||||
throw new ToolError('retry_later', 'Upstream is busy', {
|
||||
retryAfterSeconds: 10,
|
||||
});
|
||||
}),
|
||||
tool('crash', async () => {
|
||||
throw new Error('database password is hunter2');
|
||||
}),
|
||||
],
|
||||
});
|
||||
const server = createMcpServer({
|
||||
catalog,
|
||||
name: 'test',
|
||||
version: '1.0.0',
|
||||
instructions: 'Be brief.',
|
||||
});
|
||||
const request = (method, params, id = 1) =>
|
||||
server.handle({ jsonrpc: '2.0', id, method, params });
|
||||
|
||||
test('initialize negotiates a supported protocol version', async () => {
|
||||
const known = await request('initialize', { protocolVersion: '2025-06-18' });
|
||||
assert.deepEqual(known.result, {
|
||||
protocolVersion: '2025-06-18',
|
||||
capabilities: { tools: { listChanged: false } },
|
||||
serverInfo: { name: 'test', version: '1.0.0' },
|
||||
instructions: 'Be brief.',
|
||||
});
|
||||
const unknown = await request('initialize', {
|
||||
protocolVersion: '1999-01-01',
|
||||
});
|
||||
assert.equal(unknown.result.protocolVersion, MCP_PROTOCOL_VERSIONS[0]);
|
||||
assert.deepEqual((await request('ping')).result, {});
|
||||
});
|
||||
|
||||
test('notifications and client responses get no reply', async () => {
|
||||
assert.equal(
|
||||
await server.handle({
|
||||
jsonrpc: '2.0',
|
||||
method: 'notifications/initialized',
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
await server.handle({ jsonrpc: '2.0', id: 9, result: {} }),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('invalid messages and unknown methods are JSON-RPC errors', async () => {
|
||||
assert.equal(
|
||||
(await server.handle({ id: 1, method: 'ping' })).error.code,
|
||||
-32600,
|
||||
);
|
||||
assert.equal(
|
||||
(await server.handle([{ jsonrpc: '2.0', id: 1, method: 'ping' }])).error
|
||||
.code,
|
||||
-32600,
|
||||
);
|
||||
assert.equal(
|
||||
(await server.handle({ jsonrpc: '2.0', id: 1 })).error.code,
|
||||
-32600,
|
||||
);
|
||||
assert.equal((await request('tools/remove')).error.code, -32601);
|
||||
assert.equal((await request('toString')).error.code, -32601);
|
||||
assert.equal((await request('tools/list', [])).error.code, -32602);
|
||||
assert.equal(
|
||||
(await request('tools/call', { name: 'missing' })).error.code,
|
||||
-32602,
|
||||
);
|
||||
});
|
||||
|
||||
test('tools are listed with titles, schemas and annotations, with surface overrides', async () => {
|
||||
const { tools } = (await request('tools/list')).result;
|
||||
assert.deepEqual(tools[0], {
|
||||
name: 'count',
|
||||
title: 'Count',
|
||||
description: 'Counts things.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { n: { type: 'integer' } },
|
||||
additionalProperties: false,
|
||||
},
|
||||
annotations: { title: 'Count', readOnlyHint: true },
|
||||
});
|
||||
const [custom] = toMcpTools(catalog, {
|
||||
descriptions: { count: { description: 'Counts for this surface.' } },
|
||||
decorate: (definition, source) => ({
|
||||
_meta: { kind: source.kind, seen: definition.name },
|
||||
}),
|
||||
});
|
||||
assert.equal(custom.description, 'Counts for this surface.');
|
||||
assert.deepEqual(custom._meta, { kind: 'query', seen: 'count' });
|
||||
});
|
||||
|
||||
test('tool results carry text and structured content; failures stay generic', async () => {
|
||||
assert.deepEqual(
|
||||
(await request('tools/call', { name: 'count', arguments: { n: 3 } }))
|
||||
.result,
|
||||
{
|
||||
content: [
|
||||
{ type: 'text', text: '3 things.' },
|
||||
{ type: 'text', text: '{"n":3}' },
|
||||
],
|
||||
structuredContent: { n: 3 },
|
||||
isError: false,
|
||||
},
|
||||
);
|
||||
assert.deepEqual((await request('tools/call', { name: 'busy' })).result, {
|
||||
content: [{ type: 'text', text: 'Upstream is busy' }],
|
||||
structuredContent: { error: 'retry_later', retry_after_seconds: 10 },
|
||||
isError: true,
|
||||
});
|
||||
const invalid = (
|
||||
await request('tools/call', { name: 'count', arguments: { n: 'x' } })
|
||||
).result;
|
||||
assert.equal(invalid.isError, true);
|
||||
assert.equal(invalid.structuredContent.error, 'invalid_arguments');
|
||||
const crash = (await request('tools/call', { name: 'crash' })).result;
|
||||
assert.equal(crash.isError, true);
|
||||
assert.equal(JSON.stringify(crash).includes('hunter2'), false);
|
||||
});
|
||||
|
||||
test('the HTTP transport accepts one JSON message per POST', async () => {
|
||||
const handle = createMcpHttpHandler(server);
|
||||
const post = (body, headers = {}) =>
|
||||
handle(
|
||||
new Request('http://localhost/mcp', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
body: typeof body === 'string' ? body : JSON.stringify(body),
|
||||
}),
|
||||
);
|
||||
const ok = await post({ jsonrpc: '2.0', id: 1, method: 'ping' });
|
||||
assert.equal(ok.status, 200);
|
||||
assert.equal(ok.headers.get('content-type'), 'application/json');
|
||||
assert.deepEqual(await ok.json(), { jsonrpc: '2.0', id: 1, result: {} });
|
||||
assert.equal(
|
||||
(await post({ jsonrpc: '2.0', method: 'notifications/initialized' }))
|
||||
.status,
|
||||
202,
|
||||
);
|
||||
const parse = await post('{');
|
||||
assert.equal(parse.status, 400);
|
||||
assert.equal((await parse.json()).error.code, -32700);
|
||||
assert.equal(
|
||||
(await post({}, { 'MCP-Protocol-Version': '1999-01-01' })).status,
|
||||
400,
|
||||
);
|
||||
assert.equal((await post({}, { 'Content-Type': 'text/plain' })).status, 415);
|
||||
assert.equal((await post('x'.repeat(1024 * 1024 + 1))).status, 413);
|
||||
// A streamed body without Content-Length stops being read past the limit.
|
||||
let produced = 0;
|
||||
const chunk = new Uint8Array(64 * 1024).fill(32);
|
||||
const body = new ReadableStream({
|
||||
pull(controller) {
|
||||
if (produced >= 8 * 1024 * 1024) return controller.close();
|
||||
produced += chunk.byteLength;
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
});
|
||||
const streamed = await handle(
|
||||
new Request('http://localhost/mcp', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
duplex: 'half',
|
||||
}),
|
||||
);
|
||||
assert.equal(streamed.status, 413);
|
||||
assert.ok(produced <= 1024 * 1024 + 4 * chunk.byteLength, `read ${produced}`);
|
||||
const get = await handle(new Request('http://localhost/mcp'));
|
||||
assert.equal(get.status, 405);
|
||||
assert.equal(get.headers.get('allow'), 'POST');
|
||||
});
|
||||
|
||||
test('resources are listed and read, and tools name their UI resource', async () => {
|
||||
const shown = defineTool({
|
||||
name: 'show',
|
||||
title: 'Show',
|
||||
description: 'Shows something in a view.',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
ui: { resourceUri: 'ui://fixture/view' },
|
||||
run: async () => ({ summary: 'shown', data: {} }),
|
||||
});
|
||||
const withUi = createMcpServer({
|
||||
catalog: composeCatalog({ tools: [shown] }),
|
||||
name: 'fixture',
|
||||
version: '1',
|
||||
resources: [
|
||||
{
|
||||
uri: 'ui://fixture/view',
|
||||
name: 'view',
|
||||
mimeType: 'text/html;profile=mcp-app',
|
||||
text: '<!doctype html><p>view</p>',
|
||||
_meta: { ui: { csp: { frameDomains: ['https://a.example'] } } },
|
||||
},
|
||||
],
|
||||
});
|
||||
const call = async (method, params) =>
|
||||
(await withUi.handle({ jsonrpc: '2.0', id: 1, method, params })).result ??
|
||||
(await withUi.handle({ jsonrpc: '2.0', id: 1, method, params })).error;
|
||||
assert.deepEqual((await call('initialize', {})).capabilities, {
|
||||
tools: { listChanged: false },
|
||||
resources: { listChanged: false },
|
||||
});
|
||||
assert.deepEqual((await call('tools/list')).tools[0]._meta, {
|
||||
ui: { resourceUri: 'ui://fixture/view' },
|
||||
'ui/resourceUri': 'ui://fixture/view',
|
||||
});
|
||||
assert.deepEqual((await call('resources/list')).resources, [
|
||||
{
|
||||
uri: 'ui://fixture/view',
|
||||
name: 'view',
|
||||
mimeType: 'text/html;profile=mcp-app',
|
||||
_meta: { ui: { csp: { frameDomains: ['https://a.example'] } } },
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(
|
||||
(await call('resources/read', { uri: 'ui://fixture/view' })).contents,
|
||||
[
|
||||
{
|
||||
uri: 'ui://fixture/view',
|
||||
mimeType: 'text/html;profile=mcp-app',
|
||||
text: '<!doctype html><p>view</p>',
|
||||
_meta: { ui: { csp: { frameDomains: ['https://a.example'] } } },
|
||||
},
|
||||
],
|
||||
);
|
||||
assert.equal(
|
||||
(await call('resources/read', { uri: 'ui://fixture/other' })).code,
|
||||
-32002,
|
||||
);
|
||||
// A server without resources does not advertise them.
|
||||
assert.equal(
|
||||
(await server.handle({ jsonrpc: '2.0', id: 1, method: 'initialize' }))
|
||||
.result.capabilities.resources,
|
||||
undefined,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
defineTool({
|
||||
name: 'bad',
|
||||
title: 'Bad',
|
||||
description: 'Points at a web page.',
|
||||
inputSchema: { type: 'object' },
|
||||
ui: { resourceUri: 'https://a.example' },
|
||||
run: async () => ({ summary: '', data: {} }),
|
||||
}),
|
||||
/ui:\/\/ URI/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
defineTool({
|
||||
name: 'bad',
|
||||
title: 'Bad',
|
||||
description: 'Visible to nobody.',
|
||||
inputSchema: { type: 'object' },
|
||||
ui: { visibility: ['user'] },
|
||||
run: async () => ({ summary: '', data: {} }),
|
||||
}),
|
||||
/visibility/,
|
||||
);
|
||||
});
|
||||
|
||||
test('a tool only an app may call says so, and answers it without JSON text', async () => {
|
||||
const loader = defineTool({
|
||||
name: 'load',
|
||||
title: 'Load',
|
||||
description: 'Loads a file for the view.',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
ui: { visibility: ['app'] },
|
||||
run: async () => ({ summary: 'loaded', data: { body: 'abc' } }),
|
||||
});
|
||||
const appServer = createMcpServer({
|
||||
catalog: composeCatalog({ tools: [loader] }),
|
||||
name: 'fixture',
|
||||
version: '1',
|
||||
});
|
||||
const call = async (method, params) =>
|
||||
(await appServer.handle({ jsonrpc: '2.0', id: 1, method, params })).result;
|
||||
assert.deepEqual((await call('tools/list')).tools[0]._meta, {
|
||||
ui: { visibility: ['app'] },
|
||||
});
|
||||
const result = await call('tools/call', { name: 'load', arguments: {} });
|
||||
assert.deepEqual(result.content, [{ type: 'text', text: 'loaded' }]);
|
||||
assert.deepEqual(result.structuredContent, { body: 'abc' });
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Model Context Protocol server for a tool catalog: JSON-RPC 2.0 handling of
|
||||
* initialize, ping, tools/list and tools/call, and resources/list and
|
||||
* resources/read for the resources an application supplies, such as MCP Apps
|
||||
* views (`ui://` resources) that tools name in `_meta.ui.resourceUri`. Transports (HTTP, stdio) pass
|
||||
* parsed messages in and send the returned responses out.
|
||||
*/
|
||||
|
||||
import { ToolError } from '../catalog.js';
|
||||
|
||||
/** Protocol revisions this server implements, newest first. */
|
||||
export const MCP_PROTOCOL_VERSIONS = Object.freeze([
|
||||
'2025-11-25',
|
||||
'2025-06-18',
|
||||
'2025-03-26',
|
||||
]);
|
||||
|
||||
const PARSE_ERROR = -32700;
|
||||
const INVALID_REQUEST = -32600;
|
||||
const METHOD_NOT_FOUND = -32601;
|
||||
const INVALID_PARAMS = -32602;
|
||||
const INTERNAL_ERROR = -32603;
|
||||
const RESOURCE_NOT_FOUND = -32002;
|
||||
|
||||
/**
|
||||
* Describe catalog tools as MCP tool definitions.
|
||||
*
|
||||
* `descriptions` may override a tool's `title` and `description` by name.
|
||||
* `decorate(definition, tool)` may return extra fields to merge into each
|
||||
* definition, such as application metadata.
|
||||
*/
|
||||
export function toMcpTools(catalog, { descriptions = {}, decorate } = {}) {
|
||||
return catalog.list().map((tool) => {
|
||||
const override = descriptions[tool.name] || {};
|
||||
const title = override.title ?? tool.title;
|
||||
const definition = {
|
||||
name: tool.name,
|
||||
title,
|
||||
description: override.description ?? tool.description,
|
||||
inputSchema: tool.inputSchema,
|
||||
annotations: { title, ...tool.annotations },
|
||||
...(tool.ui
|
||||
? {
|
||||
_meta: {
|
||||
ui: { ...tool.ui },
|
||||
// The flat key is the earlier form of the same field, which
|
||||
// some hosts still read; the MCP Apps SDK sends both.
|
||||
...(tool.ui.resourceUri
|
||||
? { 'ui/resourceUri': tool.ui.resourceUri }
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
return decorate
|
||||
? { ...definition, ...decorate(definition, tool) }
|
||||
: definition;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a protocol handler. `handle(message, { signal })` resolves to the
|
||||
* JSON-RPC response, or null for notifications.
|
||||
*/
|
||||
export function createMcpServer({
|
||||
catalog,
|
||||
name,
|
||||
version,
|
||||
instructions,
|
||||
descriptions,
|
||||
decorate,
|
||||
resources = [],
|
||||
}) {
|
||||
const tools = () => toMcpTools(catalog, { descriptions, decorate });
|
||||
const resourceByUri = new Map(
|
||||
resources.map((resource) => [resource.uri, resource]),
|
||||
);
|
||||
const methods = {
|
||||
initialize(params) {
|
||||
const requested = params?.protocolVersion;
|
||||
return {
|
||||
protocolVersion: MCP_PROTOCOL_VERSIONS.includes(requested)
|
||||
? requested
|
||||
: MCP_PROTOCOL_VERSIONS[0],
|
||||
capabilities: {
|
||||
tools: { listChanged: false },
|
||||
...(resources.length ? { resources: { listChanged: false } } : {}),
|
||||
},
|
||||
serverInfo: { name, version },
|
||||
...(instructions ? { instructions } : {}),
|
||||
};
|
||||
},
|
||||
ping: () => ({}),
|
||||
'tools/list': () => ({ tools: tools() }),
|
||||
'resources/list': () => ({
|
||||
resources: resources.map(({ text, ...entry }) => entry),
|
||||
}),
|
||||
'resources/read'(params) {
|
||||
const resource = resourceByUri.get(params?.uri);
|
||||
if (!resource)
|
||||
throw rpcError(RESOURCE_NOT_FOUND, `Unknown resource: ${params?.uri}`);
|
||||
const { uri, mimeType, text, _meta } = resource;
|
||||
return {
|
||||
contents: [{ uri, mimeType, text, ...(_meta ? { _meta } : {}) }],
|
||||
};
|
||||
},
|
||||
async 'tools/call'(params, { signal }) {
|
||||
if (typeof params?.name !== 'string')
|
||||
throw rpcError(INVALID_PARAMS, 'tools/call needs a tool name');
|
||||
if (!catalog.get(params.name))
|
||||
throw rpcError(INVALID_PARAMS, `Unknown tool: ${params.name}`);
|
||||
try {
|
||||
const result = await catalog.call(params.name, params.arguments ?? {}, {
|
||||
signal,
|
||||
});
|
||||
// Only an app reads a tool the model cannot call, and apps read
|
||||
// structuredContent.
|
||||
const appOnly =
|
||||
catalog.get(params.name).ui?.visibility?.includes('model') === false;
|
||||
return {
|
||||
// The data is repeated as JSON text for clients that do not read
|
||||
// structuredContent.
|
||||
content: [
|
||||
{ type: 'text', text: result.summary },
|
||||
...(appOnly
|
||||
? []
|
||||
: [{ type: 'text', text: JSON.stringify(result.data) }]),
|
||||
...(result.images || []).map((item) => ({
|
||||
type: 'image',
|
||||
data: item.data,
|
||||
mimeType: item.mimeType,
|
||||
})),
|
||||
],
|
||||
structuredContent: result.data,
|
||||
isError: false,
|
||||
};
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw error;
|
||||
// Expected failures are reported to the model so it can adjust; other
|
||||
// errors are not described, so internal details never leave the server.
|
||||
const known = error instanceof ToolError;
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: known ? error.message : 'The tool failed; try again later',
|
||||
},
|
||||
],
|
||||
structuredContent: {
|
||||
error: known ? error.code : 'unavailable',
|
||||
...(known && error.retryAfterSeconds != null
|
||||
? { retry_after_seconds: error.retryAfterSeconds }
|
||||
: {}),
|
||||
},
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return Object.freeze({
|
||||
async handle(message, { signal } = {}) {
|
||||
if (!isObject(message) || message.jsonrpc !== '2.0')
|
||||
return failure(
|
||||
message?.id ?? null,
|
||||
INVALID_REQUEST,
|
||||
'Invalid JSON-RPC message',
|
||||
);
|
||||
const isRequest = message.id !== undefined;
|
||||
if (typeof message.method !== 'string')
|
||||
// Responses from the client are accepted and ignored: this server
|
||||
// sends no requests of its own.
|
||||
return isRequest &&
|
||||
message.result === undefined &&
|
||||
message.error === undefined
|
||||
? failure(message.id, INVALID_REQUEST, 'Missing method')
|
||||
: null;
|
||||
if (!isRequest) return null;
|
||||
const method = Object.hasOwn(methods, message.method)
|
||||
? methods[message.method]
|
||||
: null;
|
||||
if (!method)
|
||||
return failure(
|
||||
message.id,
|
||||
METHOD_NOT_FOUND,
|
||||
`Unknown method: ${message.method}`,
|
||||
);
|
||||
if (message.params !== undefined && !isObject(message.params))
|
||||
return failure(message.id, INVALID_PARAMS, 'params must be an object');
|
||||
try {
|
||||
const result = await method(message.params, { signal });
|
||||
return { jsonrpc: '2.0', id: message.id, result };
|
||||
} catch (error) {
|
||||
if (error?.rpcCode)
|
||||
return failure(message.id, error.rpcCode, error.message);
|
||||
return failure(message.id, INTERNAL_ERROR, 'Internal error');
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** A JSON-RPC parse-error response, for transports that cannot parse input. */
|
||||
export function parseErrorResponse() {
|
||||
return failure(null, PARSE_ERROR, 'Parse error');
|
||||
}
|
||||
|
||||
function failure(id, code, message) {
|
||||
return { jsonrpc: '2.0', id, error: { code, message } };
|
||||
}
|
||||
|
||||
function rpcError(code, message) {
|
||||
return Object.assign(new Error(message), { rpcCode: code });
|
||||
}
|
||||
|
||||
function isObject(value) {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Place services backed by the app's own routes. Applications with different
|
||||
* providers can supply any objects with the same contracts:
|
||||
*
|
||||
* - `places.resolve(name, { signal })` turns a name into bounds (`/api/geocode`).
|
||||
* - `placeSearch.search(query, near, { signal })` and
|
||||
* `placeSearch.nearby(near, { signal })` find points of interest
|
||||
* (`/api/google/*`), reporting whether search is configured.
|
||||
* - `routing.route(points, profile, { signal })` plans a route (`/api/route`).
|
||||
*/
|
||||
|
||||
import { ToolError } from './catalog.js';
|
||||
|
||||
const POINT_RADIUS_DEGREES = 0.25;
|
||||
|
||||
/**
|
||||
* Resolve place names to `{ name, bounds: { west, south, east, north }, point }`
|
||||
* or null. `point` is the place's own location, which can lie far from the
|
||||
* middle of its bounds (a region with remote islands, for example).
|
||||
*/
|
||||
export function createGeocodePlaceService({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
cacheSize = 200,
|
||||
cacheMs = 10 * 60_000,
|
||||
timeoutMs = 15_000,
|
||||
now = () => Date.now(),
|
||||
} = {}) {
|
||||
// Recent answers by name, so tools that combine others resolve a place to
|
||||
// the same point once instead of once per section. A pending lookup is
|
||||
// shared by its callers: it has its own deadline, is cancelled when every
|
||||
// caller has given up, and only a successful answer stays cached.
|
||||
const cache = new Map();
|
||||
async function lookup(name, signal) {
|
||||
const query = new URLSearchParams({ q: name });
|
||||
const response = await fetchImpl(`/api/geocode?${query}`, { signal });
|
||||
if (!response.ok) throw new Error(`Geocode HTTP ${response.status}`);
|
||||
const payload = await response.json();
|
||||
return placeFromGeocodeResult(payload?.results?.[0]);
|
||||
}
|
||||
function start(key, name) {
|
||||
const controller = new AbortController();
|
||||
const entry = {
|
||||
at: now(),
|
||||
controller,
|
||||
waiters: 0,
|
||||
settled: false,
|
||||
place: lookup(
|
||||
name,
|
||||
AbortSignal.any([controller.signal, AbortSignal.timeout(timeoutMs)]),
|
||||
),
|
||||
};
|
||||
entry.place.then(
|
||||
() => {
|
||||
entry.settled = true;
|
||||
},
|
||||
() => {
|
||||
entry.settled = true;
|
||||
if (cache.get(key) === entry) cache.delete(key);
|
||||
},
|
||||
);
|
||||
cache.set(key, entry);
|
||||
while (cache.size > cacheSize) cache.delete(cache.keys().next().value);
|
||||
return entry;
|
||||
}
|
||||
function join(key, entry, signal) {
|
||||
if (!signal) {
|
||||
// A caller that cannot cancel keeps the lookup alive.
|
||||
entry.waiters = Infinity;
|
||||
return entry.place;
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
entry.waiters += 1;
|
||||
return new Promise((resolve, reject) => {
|
||||
const leave = () => {
|
||||
entry.waiters -= 1;
|
||||
reject(signal.reason);
|
||||
if (entry.waiters <= 0 && !entry.settled) {
|
||||
entry.controller.abort(signal.reason);
|
||||
if (cache.get(key) === entry) cache.delete(key);
|
||||
}
|
||||
};
|
||||
signal.addEventListener('abort', leave, { once: true });
|
||||
entry.place
|
||||
.then(resolve, reject)
|
||||
.finally(() => signal.removeEventListener('abort', leave));
|
||||
});
|
||||
}
|
||||
return {
|
||||
async resolve(name, { signal } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
const key = name.trim().toLowerCase();
|
||||
let entry = cache.get(key);
|
||||
if (entry && now() - entry.at < cacheMs) {
|
||||
cache.delete(key);
|
||||
cache.set(key, entry);
|
||||
} else entry = start(key, name);
|
||||
return join(key, entry, signal);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Convert one geocode result to a place with bounds, or null. */
|
||||
export function placeFromGeocodeResult(result) {
|
||||
const location = result?.geometry?.location;
|
||||
if (!Number.isFinite(location?.lat) || !Number.isFinite(location?.lng))
|
||||
return null;
|
||||
const viewport = result.geometry.viewport;
|
||||
const bounds =
|
||||
viewport &&
|
||||
[
|
||||
viewport.southwest?.lat,
|
||||
viewport.southwest?.lng,
|
||||
viewport.northeast?.lat,
|
||||
viewport.northeast?.lng,
|
||||
].every(Number.isFinite)
|
||||
? {
|
||||
west: viewport.southwest.lng,
|
||||
south: viewport.southwest.lat,
|
||||
east: viewport.northeast.lng,
|
||||
north: viewport.northeast.lat,
|
||||
}
|
||||
: {
|
||||
west: Math.max(-180, location.lng - POINT_RADIUS_DEGREES),
|
||||
south: Math.max(-90, location.lat - POINT_RADIUS_DEGREES),
|
||||
east: Math.min(180, location.lng + POINT_RADIUS_DEGREES),
|
||||
north: Math.min(90, location.lat + POINT_RADIUS_DEGREES),
|
||||
};
|
||||
return {
|
||||
name: result.formatted_address || null,
|
||||
bounds,
|
||||
point: { lat: location.lat, lon: location.lng },
|
||||
};
|
||||
}
|
||||
|
||||
/** Search points of interest; `configured` is false when the server has no search key. */
|
||||
export function createPlaceSearchService({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
} = {}) {
|
||||
async function read(path, params, signal) {
|
||||
const response = await fetchImpl(`${path}?${new URLSearchParams(params)}`, {
|
||||
signal,
|
||||
});
|
||||
let payload = null;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
/* the status below is authoritative */
|
||||
}
|
||||
if (!response.ok) throw httpFailure(response, 'Place search');
|
||||
return {
|
||||
configured: payload?.configured !== false,
|
||||
places: Array.isArray(payload?.places) ? payload.places : [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
search(query, { latitude, longitude, radiusM }, { signal } = {}) {
|
||||
return read(
|
||||
'/api/google/text-search',
|
||||
{ q: query, lat: latitude, lon: longitude, radiusM },
|
||||
signal,
|
||||
);
|
||||
},
|
||||
nearby({ latitude, longitude, radiusM }, { signal } = {}) {
|
||||
return read(
|
||||
'/api/google/nearby-places',
|
||||
{ lat: latitude, lon: longitude, radiusM },
|
||||
signal,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Plan routes between `[{ lat, lon }]` points; resolves `{ ok, ... }`. */
|
||||
export function createRouteService({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
} = {}) {
|
||||
return {
|
||||
async route(points, profile, { signal } = {}) {
|
||||
const coords = points
|
||||
.map((point) => `${point.lon.toFixed(6)},${point.lat.toFixed(6)}`)
|
||||
.join(';');
|
||||
const response = await fetchImpl(
|
||||
`/api/route?${new URLSearchParams({ profile, coords })}`,
|
||||
{ signal },
|
||||
);
|
||||
let payload = null;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
/* the status below is authoritative */
|
||||
}
|
||||
if (response.status === 429) throw httpFailure(response, 'Routing');
|
||||
if (payload?.ok === false || response.status === 400)
|
||||
return { ok: false, error: payload?.error || 'no route found' };
|
||||
if (!response.ok) throw httpFailure(response, 'Routing');
|
||||
return payload;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function httpFailure(response, label) {
|
||||
if (response.status === 429) {
|
||||
const seconds = Number(response.headers.get('retry-after'));
|
||||
return new ToolError('retry_later', `${label} is rate limited`, {
|
||||
retryAfterSeconds:
|
||||
Number.isFinite(seconds) && seconds > 0 ? seconds : null,
|
||||
});
|
||||
}
|
||||
return new Error(`${label} HTTP ${response.status}`);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createGeocodePlaceService } from './places.js';
|
||||
|
||||
const answer = {
|
||||
results: [
|
||||
{
|
||||
formatted_address: 'Tokyo, Japan',
|
||||
geometry: { location: { lat: 35.68, lng: 139.76 } },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
test('place lookups are cached by name for a while, and failures are not', async () => {
|
||||
let clock = 0;
|
||||
const requests = [];
|
||||
let fail = true;
|
||||
const places = createGeocodePlaceService({
|
||||
now: () => clock,
|
||||
cacheMs: 1000,
|
||||
fetchImpl: async (url) => {
|
||||
requests.push(url);
|
||||
if (fail) return new Response(null, { status: 503 });
|
||||
return Response.json(answer);
|
||||
},
|
||||
});
|
||||
await assert.rejects(places.resolve('Tokyo'), /Geocode HTTP 503/);
|
||||
fail = false;
|
||||
const first = await places.resolve('Tokyo');
|
||||
assert.deepEqual(first.point, { lat: 35.68, lon: 139.76 });
|
||||
assert.equal(await places.resolve(' tokyo '), first);
|
||||
assert.equal(requests.length, 2);
|
||||
clock = 2000;
|
||||
await places.resolve('Tokyo');
|
||||
assert.equal(requests.length, 3);
|
||||
const aborted = new AbortController();
|
||||
aborted.abort();
|
||||
await assert.rejects(places.resolve('Tokyo', { signal: aborted.signal }));
|
||||
});
|
||||
|
||||
test('a lookup every caller abandoned is cancelled and not reused', async () => {
|
||||
const signals = [];
|
||||
const places = createGeocodePlaceService({
|
||||
fetchImpl: (url, { signal }) => {
|
||||
signals.push(signal);
|
||||
if (signals.length === 1)
|
||||
return new Promise((resolve, reject) =>
|
||||
signal.addEventListener('abort', () => reject(signal.reason)),
|
||||
);
|
||||
return Promise.resolve(Response.json(answer));
|
||||
},
|
||||
});
|
||||
const caller = new AbortController();
|
||||
const first = places.resolve('Tokyo', { signal: caller.signal });
|
||||
caller.abort();
|
||||
await assert.rejects(first);
|
||||
assert.equal(signals[0].aborted, true);
|
||||
const again = await places.resolve('Tokyo');
|
||||
assert.deepEqual(again.point, { lat: 35.68, lon: 139.76 });
|
||||
assert.equal(signals.length, 2);
|
||||
});
|
||||
|
||||
test('a shared lookup outlives one cancelled caller, and times out on its own', async () => {
|
||||
let release;
|
||||
const signals = [];
|
||||
const places = createGeocodePlaceService({
|
||||
timeoutMs: 50,
|
||||
fetchImpl: (url, { signal }) => {
|
||||
signals.push(signal);
|
||||
if (url.includes('Slow'))
|
||||
return new Promise((resolve, reject) =>
|
||||
signal.addEventListener('abort', () => reject(signal.reason)),
|
||||
);
|
||||
return new Promise((resolve) => {
|
||||
release = () => resolve(Response.json(answer));
|
||||
});
|
||||
},
|
||||
});
|
||||
const leaving = new AbortController();
|
||||
const gone = places.resolve('Tokyo', { signal: leaving.signal });
|
||||
const staying = places.resolve('Tokyo', {
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
leaving.abort();
|
||||
await assert.rejects(gone);
|
||||
assert.equal(signals[0].aborted, false);
|
||||
release();
|
||||
assert.deepEqual((await staying).point, { lat: 35.68, lon: 139.76 });
|
||||
await assert.rejects(
|
||||
places.resolve('Slow'),
|
||||
(error) => error.name === 'TimeoutError',
|
||||
);
|
||||
await places.resolve('Slow').catch(() => {});
|
||||
assert.equal(signals.length, 3, 'a timed-out lookup is not cached');
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
/** Weather map imagery (radar, satellite, lightning) and wind at a point. */
|
||||
|
||||
import { suggestView } from '../views.js';
|
||||
import { windFrom } from '../../layers/wind/inspection.js';
|
||||
import { sampleWind, windSpeed } from '../../layers/wind/model.js';
|
||||
import { defineTool, ToolError } from '../catalog.js';
|
||||
import {
|
||||
AREA_SCHEMA,
|
||||
POINT_SCHEMA,
|
||||
areaCenter,
|
||||
areaRadiusKm,
|
||||
resolveArea,
|
||||
resolvePoint,
|
||||
} from '../area.js';
|
||||
import { toBase64 } from '../results.js';
|
||||
|
||||
/** Map names offered to callers, and the weather products behind them. */
|
||||
const MAPS = {
|
||||
radar: 'radar',
|
||||
satellite: 'clouds-regional',
|
||||
global_satellite: 'clouds',
|
||||
lightning: 'lightning',
|
||||
};
|
||||
/** The app layer that shows each map. */
|
||||
const MAP_LAYERS = {
|
||||
radar: 'weather-radar',
|
||||
satellite: 'weather-satellite',
|
||||
global_satellite: 'weather-satellite',
|
||||
lightning: 'weather-lightning',
|
||||
};
|
||||
const IMAGE_SIZE = { width: 1024, height: 512 };
|
||||
const SNAP_DEGREES = 0.25;
|
||||
const MIN_HALF_HEIGHT_DEGREES = 0.5;
|
||||
const KM_PER_DEGREE = 111.2;
|
||||
const MAX_IMAGE_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
const snapDown = (value) => Math.floor(value / SNAP_DEGREES) * SNAP_DEGREES;
|
||||
const snapUp = (value) => Math.ceil(value / SNAP_DEGREES) * SNAP_DEGREES;
|
||||
const round = (value, digits) =>
|
||||
Number.isFinite(value) ? Number(value.toFixed(digits)) : null;
|
||||
|
||||
/**
|
||||
* A 2:1 box snapped to 0.25° around the area, moved inside the product's
|
||||
* bounds. Returns null when no such box covers the whole area (it would not
|
||||
* fit, or the area crosses the antimeridian), meaning the whole extent
|
||||
* should be shown instead.
|
||||
*/
|
||||
export function weatherWindow(area, bounds) {
|
||||
if (area.west > area.east) return null;
|
||||
const center = areaCenter(area);
|
||||
const halfHeight = Math.max(
|
||||
MIN_HALF_HEIGHT_DEGREES,
|
||||
areaRadiusKm(area) / KM_PER_DEGREE,
|
||||
);
|
||||
const height = snapUp(halfHeight * 2);
|
||||
const width = height * 2;
|
||||
if (width > bounds.east - bounds.west || height > bounds.north - bounds.south)
|
||||
return null;
|
||||
let west = snapDown(center.lon - width / 2);
|
||||
let south = snapDown(center.lat - height / 2);
|
||||
west = Math.min(
|
||||
Math.max(west, snapUp(bounds.west)),
|
||||
snapDown(bounds.east - width),
|
||||
);
|
||||
south = Math.min(
|
||||
Math.max(south, snapUp(bounds.south)),
|
||||
snapDown(bounds.north - height),
|
||||
);
|
||||
const mapBox = { west, south, east: west + width, north: south + height };
|
||||
const insideProduct =
|
||||
mapBox.west >= bounds.west &&
|
||||
mapBox.east <= bounds.east &&
|
||||
mapBox.south >= bounds.south &&
|
||||
mapBox.north <= bounds.north;
|
||||
// Moving the box inside the product can uncover part of the area; only a
|
||||
// box that still holds the whole area (clipped to the product) will do.
|
||||
const coversArea =
|
||||
mapBox.west <= Math.max(area.west, bounds.west) &&
|
||||
mapBox.east >= Math.min(area.east, bounds.east) &&
|
||||
mapBox.south <= Math.max(area.south, bounds.south) &&
|
||||
mapBox.north >= Math.min(area.north, bounds.north);
|
||||
return insideProduct && coversArea ? mapBox : null;
|
||||
}
|
||||
|
||||
export const getWeatherMap = defineTool({
|
||||
name: 'get_weather_map',
|
||||
title: 'Weather map',
|
||||
description:
|
||||
'The latest NOAA weather map image over an area: precipitation radar ' +
|
||||
'(contiguous US), regional or global infrared satellite clouds, or ' +
|
||||
'lightning density (the Americas). Returns the image itself.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
map: { type: 'string', enum: Object.keys(MAPS) },
|
||||
area: AREA_SCHEMA,
|
||||
},
|
||||
required: ['map'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['weatherMaps'],
|
||||
async run(args, { services, signal }) {
|
||||
const product = MAPS[args.map];
|
||||
const snapshot = await services.weatherMaps.getSnapshot({
|
||||
product,
|
||||
signal,
|
||||
});
|
||||
if (snapshot.unavailable)
|
||||
throw new ToolError(
|
||||
'unavailable',
|
||||
`The ${args.map} map is unavailable right now`,
|
||||
);
|
||||
const area = args.area
|
||||
? await resolveArea(args.area, { services, signal })
|
||||
: null;
|
||||
let mapBox = null;
|
||||
if (area) {
|
||||
const { bounds } = snapshot;
|
||||
const center = areaCenter(area);
|
||||
if (
|
||||
center.lon < bounds.west ||
|
||||
center.lon > bounds.east ||
|
||||
center.lat < bounds.south ||
|
||||
center.lat > bounds.north
|
||||
)
|
||||
throw new ToolError(
|
||||
'invalid_arguments',
|
||||
`${area.label} is outside the ${args.map} map's coverage: ${snapshot.coverage || 'see its bounds'}`,
|
||||
);
|
||||
mapBox = weatherWindow(area, bounds);
|
||||
}
|
||||
const image = await services.weatherMaps.getImage({
|
||||
product,
|
||||
time: snapshot.latest,
|
||||
size: IMAGE_SIZE,
|
||||
bbox: mapBox,
|
||||
signal,
|
||||
});
|
||||
if (!/^image\/(png|jpeg)$/.test(image.contentType))
|
||||
throw new ToolError('unavailable', 'The weather map returned no image');
|
||||
if (image.bytes.byteLength > MAX_IMAGE_BYTES)
|
||||
throw new ToolError('unavailable', 'The weather map image is too large');
|
||||
const where = area ? ` over ${area.label}` : '';
|
||||
return {
|
||||
summary: `${snapshot.title || args.map} at ${snapshot.latest}${where}.`,
|
||||
data: {
|
||||
view: suggestView(
|
||||
services,
|
||||
area
|
||||
? { area, layers: [MAP_LAYERS[args.map]] }
|
||||
: {
|
||||
point: {
|
||||
lat: (snapshot.bounds.south + snapshot.bounds.north) / 2,
|
||||
lon: (snapshot.bounds.west + snapshot.bounds.east) / 2,
|
||||
},
|
||||
altitudeM: 8_000_000,
|
||||
layers: [MAP_LAYERS[args.map]],
|
||||
},
|
||||
),
|
||||
map: args.map,
|
||||
title: snapshot.title ?? null,
|
||||
description: snapshot.description ?? null,
|
||||
coverage: snapshot.coverage ?? null,
|
||||
time: snapshot.latest,
|
||||
box: mapBox ?? snapshot.bounds,
|
||||
source: snapshot.source ?? null,
|
||||
attribution: snapshot.attribution ?? null,
|
||||
},
|
||||
images: [{ mimeType: image.contentType, data: toBase64(image.bytes) }],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const getWind = defineTool({
|
||||
name: 'get_wind',
|
||||
title: 'Wind',
|
||||
description:
|
||||
'Forecast wind 10 m above ground at a location from the GFS or IFS ' +
|
||||
'global model: speed and the direction it blows from.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
location: POINT_SCHEMA,
|
||||
model: { type: 'string', enum: ['gfs', 'ifs'] },
|
||||
},
|
||||
required: ['location'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['wind'],
|
||||
async run(args, { services, signal }) {
|
||||
const point = await resolvePoint(args.location, { services, signal });
|
||||
const model = args.model ?? 'gfs';
|
||||
const snapshot = await services.wind.getSnapshot({ model, signal });
|
||||
if (snapshot.unavailable || !snapshot.u || !snapshot.v)
|
||||
throw new ToolError(
|
||||
'unavailable',
|
||||
'Wind forecasts are unavailable right now',
|
||||
);
|
||||
const vector = sampleWind(
|
||||
{ ...snapshot.grid, u: snapshot.u, v: snapshot.v },
|
||||
point.lon,
|
||||
point.lat,
|
||||
);
|
||||
const speed = windSpeed(vector.u, vector.v);
|
||||
const from = windFrom(vector.u, vector.v);
|
||||
const kph = speed * 3.6;
|
||||
return {
|
||||
summary:
|
||||
`Wind at ${point.label}: ${kph.toFixed(0)} km/h` +
|
||||
(from === 'Calm' ? ', calm.' : ` from the ${from}.`),
|
||||
data: {
|
||||
view: suggestView(services, {
|
||||
point,
|
||||
altitudeM: 2_000_000,
|
||||
layers: ['wind'],
|
||||
}),
|
||||
location: point,
|
||||
speed_mps: round(speed, 1),
|
||||
speed_kph: round(kph, 1),
|
||||
from: from === 'Calm' ? null : from,
|
||||
calm: from === 'Calm',
|
||||
u_mps: round(vector.u, 2),
|
||||
v_mps: round(vector.v, 2),
|
||||
level: snapshot.level ?? '10 m above ground',
|
||||
model,
|
||||
cycle: snapshot.cycle ?? null,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { composeCatalog, coreTools } from '../index.js';
|
||||
import { weatherWindow } from './atmosphere.js';
|
||||
import { resolveArea } from '../area.js';
|
||||
|
||||
const CONUS = { west: -130, south: 20, east: -60, north: 55 };
|
||||
const PNG = Uint8Array.from([0x89, 0x50, 0x4e, 0x47]);
|
||||
const weatherMaps = (snapshot = {}) => ({
|
||||
requests: [],
|
||||
async getSnapshot({ product }) {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
product,
|
||||
title: 'MRMS radar',
|
||||
coverage: 'Contiguous United States',
|
||||
source: 'NOAA nowCOAST',
|
||||
attribution: 'NOAA',
|
||||
bounds: CONUS,
|
||||
times: ['2026-10-01T19:00:00.000Z'],
|
||||
latest: '2026-10-01T19:00:00.000Z',
|
||||
...snapshot,
|
||||
};
|
||||
},
|
||||
async getImage(request) {
|
||||
this.requests.push(request);
|
||||
return { contentType: 'image/png', bytes: PNG };
|
||||
},
|
||||
});
|
||||
|
||||
test('weather windows are 2:1, snapped to 0.25° and kept inside the bounds', () => {
|
||||
const austin = weatherWindow(
|
||||
{
|
||||
center: { lat: 30.27, lon: -97.74, radiusKm: 50 },
|
||||
west: -98.3,
|
||||
east: -97.2,
|
||||
south: 29.8,
|
||||
north: 30.7,
|
||||
},
|
||||
CONUS,
|
||||
);
|
||||
assert.deepEqual(austin, {
|
||||
west: -98.75,
|
||||
south: 29.75,
|
||||
east: -96.75,
|
||||
north: 30.75,
|
||||
});
|
||||
const edge = weatherWindow(
|
||||
{
|
||||
center: { lat: 21, lon: -129, radiusKm: 200 },
|
||||
west: -131,
|
||||
east: -127,
|
||||
south: 19,
|
||||
north: 23,
|
||||
},
|
||||
CONUS,
|
||||
);
|
||||
assert.equal(edge.west, -130);
|
||||
assert.equal(edge.south, 20);
|
||||
assert.equal((edge.east - edge.west) / (edge.north - edge.south), 2);
|
||||
assert.equal(
|
||||
weatherWindow(
|
||||
{ bbox: true, west: -180, east: 180, south: -80, north: 80 },
|
||||
CONUS,
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('weather maps return the latest frame as an image over the area', async () => {
|
||||
const maps = weatherMaps();
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { weatherMaps: maps },
|
||||
});
|
||||
const result = await catalog.call('get_weather_map', {
|
||||
map: 'radar',
|
||||
area: { lat: 30.27, lon: -97.74, radius_km: 50 },
|
||||
});
|
||||
assert.equal(
|
||||
result.summary,
|
||||
'MRMS radar at 2026-10-01T19:00:00.000Z over 50 km around 30.270, -97.740.',
|
||||
);
|
||||
assert.deepEqual(result.images, [
|
||||
{ mimeType: 'image/png', data: 'iVBORw==' },
|
||||
]);
|
||||
assert.deepEqual(maps.requests[0], {
|
||||
product: 'radar',
|
||||
time: '2026-10-01T19:00:00.000Z',
|
||||
size: { width: 1024, height: 512 },
|
||||
bbox: { west: -98.75, south: 29.75, east: -96.75, north: 30.75 },
|
||||
signal: undefined,
|
||||
});
|
||||
const whole = await catalog.call('get_weather_map', { map: 'satellite' });
|
||||
assert.equal(maps.requests[1].product, 'clouds-regional');
|
||||
assert.equal(maps.requests[1].bbox, null);
|
||||
assert.deepEqual(whole.data.box, CONUS);
|
||||
await assert.rejects(
|
||||
catalog.call('get_weather_map', {
|
||||
map: 'radar',
|
||||
area: { lat: 51.5, lon: -0.1, radius_km: 50 },
|
||||
}),
|
||||
/outside the radar map's coverage: Contiguous United States/,
|
||||
);
|
||||
const down = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { weatherMaps: weatherMaps({ unavailable: true }) },
|
||||
});
|
||||
await assert.rejects(
|
||||
down.call('get_weather_map', { map: 'lightning' }),
|
||||
(error) => error.code === 'unavailable',
|
||||
);
|
||||
});
|
||||
|
||||
test('wind is sampled from the model grid at the location', async () => {
|
||||
// A 1° global grid where every cell blows 10 m/s toward the east (from the west).
|
||||
const nx = 360;
|
||||
const ny = 181;
|
||||
const u = new Float32Array(nx * ny).fill(10);
|
||||
const v = new Float32Array(nx * ny).fill(0);
|
||||
const wind = {
|
||||
requested: [],
|
||||
async getSnapshot({ model }) {
|
||||
this.requested.push(model);
|
||||
return {
|
||||
model,
|
||||
cycle: '2026-10-01T12:00:00Z',
|
||||
level: '10 m above ground',
|
||||
grid: { nx, ny, lo1: 0, la1: 90, dx: 1, dy: 1 },
|
||||
u,
|
||||
v,
|
||||
};
|
||||
},
|
||||
};
|
||||
const catalog = composeCatalog({ tools: coreTools, services: { wind } });
|
||||
const result = await catalog.call('get_wind', {
|
||||
location: { lat: 30.27, lon: -97.74 },
|
||||
model: 'ifs',
|
||||
});
|
||||
assert.equal(
|
||||
result.summary,
|
||||
'Wind at 30.2700, -97.7400: 36 km/h from the W.',
|
||||
);
|
||||
assert.equal(result.data.speed_mps, 10);
|
||||
assert.equal(result.data.from, 'W');
|
||||
assert.deepEqual(wind.requested, ['ifs']);
|
||||
u.fill(0);
|
||||
const calm = await catalog.call('get_wind', { location: { lat: 0, lon: 0 } });
|
||||
assert.equal(calm.summary, 'Wind at 0.0000, 0.0000: 0 km/h, calm.');
|
||||
assert.equal(calm.data.calm, true);
|
||||
const down = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { wind: { getSnapshot: async () => ({ unavailable: true }) } },
|
||||
});
|
||||
await assert.rejects(
|
||||
down.call('get_wind', { location: { lat: 0, lon: 0 } }),
|
||||
(error) => error.code === 'unavailable',
|
||||
);
|
||||
});
|
||||
|
||||
test('a weather window that cannot cover the area falls back to the whole map', async () => {
|
||||
const world = { west: -180, south: -90, east: 180, north: 90 };
|
||||
// 100 km around 179.8° crosses the antimeridian.
|
||||
const dateline = await resolveArea({ lat: 0, lon: 179.8, radius_km: 100 });
|
||||
assert.equal(weatherWindow(dateline, world), null);
|
||||
// Near a product edge the window moves inside and still covers the area.
|
||||
const conus = { west: -130, south: 20, east: -60, north: 55 };
|
||||
const edge = await resolveArea({ lat: 40, lon: -129.5, radius_km: 50 });
|
||||
const box = weatherWindow(edge, conus);
|
||||
assert.ok(box);
|
||||
assert.ok(box.west <= Math.max(edge.west, conus.west));
|
||||
assert.ok(box.east >= edge.east);
|
||||
assert.ok(box.south <= edge.south && box.north >= edge.north);
|
||||
});
|
||||
@@ -0,0 +1,387 @@
|
||||
/** Aviation queries over the live aircraft sources (src/sources/live). */
|
||||
|
||||
import { suggestView } from '../views.js';
|
||||
import { defineTool, ToolError } from '../catalog.js';
|
||||
import {
|
||||
AREA_SCHEMA,
|
||||
areaCenter,
|
||||
areaContains,
|
||||
distanceKm,
|
||||
resolveArea,
|
||||
} from '../area.js';
|
||||
import {
|
||||
LIMIT_SCHEMA,
|
||||
capRows,
|
||||
countNoun,
|
||||
isoTime,
|
||||
thinEvenly,
|
||||
} from '../results.js';
|
||||
|
||||
const ICAO24_SCHEMA = Object.freeze({
|
||||
type: 'string',
|
||||
pattern: '^[0-9a-fA-F]{6}$',
|
||||
description: 'ICAO 24-bit address as six hex characters, such as "a1b2c3".',
|
||||
});
|
||||
const CALLSIGN_SCHEMA = Object.freeze({
|
||||
type: 'string',
|
||||
pattern: '^[A-Za-z0-9]{2,8}$',
|
||||
description: 'Flight callsign, such as "UAL123".',
|
||||
});
|
||||
const MILITARY_SCHEMA = Object.freeze({
|
||||
type: 'boolean',
|
||||
description: 'Use the military aircraft feed instead of the general feed.',
|
||||
});
|
||||
const MAX_TRACK_POINTS = 200;
|
||||
|
||||
const round = (value, digits) =>
|
||||
Number.isFinite(value) ? Number(value.toFixed(digits)) : null;
|
||||
|
||||
/** The common row for an aircraft record from any live aircraft source. */
|
||||
function aircraftRow(record, from) {
|
||||
const point = { lat: record.latitude, lon: record.longitude };
|
||||
return {
|
||||
id: record.id,
|
||||
callsign: record.callsign || null,
|
||||
lat: round(record.latitude, 5),
|
||||
lon: round(record.longitude, 5),
|
||||
altitude_m: round(record.baroAltitudeM ?? record.ellipsoidAltitudeM, 0),
|
||||
on_ground: record.onGround === true,
|
||||
speed_mps: round(record.speedMps, 1),
|
||||
course_deg: round(record.courseDeg, 0),
|
||||
vertical_rate_mps: round(record.verticalRateMps, 1),
|
||||
type_code: record.typeCode || null,
|
||||
registration: record.registration || null,
|
||||
operator: record.operator || null,
|
||||
origin_country: record.originCountry || null,
|
||||
last_contact: isoTime(record.contactTimeMs ?? record.positionTimeMs),
|
||||
...(from ? { distance_km: round(distanceKm(from, point), 1) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function snapshotInfo(snapshot) {
|
||||
return {
|
||||
source: snapshot.source ?? null,
|
||||
coverage: snapshot.coverage ?? null,
|
||||
observed_at: isoTime(snapshot.observedAtMs),
|
||||
freshness: snapshot.freshness ?? 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
function feedFor(services, military) {
|
||||
if (!military) return services.aircraft;
|
||||
if (!services.military)
|
||||
throw new ToolError(
|
||||
'unsupported',
|
||||
'The military aircraft feed is not available here',
|
||||
);
|
||||
return services.military;
|
||||
}
|
||||
|
||||
export const aircraftInArea = defineTool({
|
||||
name: 'aircraft_in_area',
|
||||
title: 'Aircraft in an area',
|
||||
description:
|
||||
'Aircraft currently reported inside an area, nearest the center first. ' +
|
||||
'Optionally military aircraft only, airborne only, or within an altitude band.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
area: AREA_SCHEMA,
|
||||
military: MILITARY_SCHEMA,
|
||||
airborne_only: { type: 'boolean' },
|
||||
min_altitude_m: { type: 'number', minimum: -500, maximum: 30000 },
|
||||
max_altitude_m: { type: 'number', minimum: -500, maximum: 30000 },
|
||||
limit: LIMIT_SCHEMA,
|
||||
},
|
||||
required: ['area'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['aircraft'],
|
||||
async run(args, { services, signal }) {
|
||||
const area = await resolveArea(args.area, { services, signal });
|
||||
const center = areaCenter(area);
|
||||
const snapshot = await feedFor(services, args.military).getSnapshot(
|
||||
{ latitude: center.lat, longitude: center.lon },
|
||||
{ signal },
|
||||
);
|
||||
const altitude = (record) =>
|
||||
record.baroAltitudeM ?? record.ellipsoidAltitudeM;
|
||||
const rows = snapshot.records
|
||||
.filter(
|
||||
(record) =>
|
||||
areaContains(area, { lat: record.latitude, lon: record.longitude }) &&
|
||||
!(args.airborne_only && record.onGround) &&
|
||||
(args.min_altitude_m == null ||
|
||||
altitude(record) >= args.min_altitude_m) &&
|
||||
(args.max_altitude_m == null ||
|
||||
altitude(record) <= args.max_altitude_m),
|
||||
)
|
||||
.map((record) => aircraftRow(record, center))
|
||||
.sort((a, b) => a.distance_km - b.distance_km);
|
||||
const kind = args.military ? 'military aircraft' : 'aircraft';
|
||||
// A regional fallback feed covers only part of the world around the
|
||||
// area's center; say so rather than imply a worldwide answer.
|
||||
const regional = /regional/i.test(snapshot.coverage || '');
|
||||
const notes = [
|
||||
...(regional ? [`regional feed: ${snapshot.coverage}`] : []),
|
||||
...(snapshot.freshness === 'stale' ? ['data may be stale'] : []),
|
||||
];
|
||||
return {
|
||||
summary:
|
||||
`${rows.length} ${kind} in ${area.label}` +
|
||||
(notes.length ? ` (${notes.join('; ')}).` : '.'),
|
||||
data: {
|
||||
view: suggestView(services, {
|
||||
area,
|
||||
layers: [args.military ? 'military' : 'flights'],
|
||||
}),
|
||||
...capRows(rows, args.limit),
|
||||
...snapshotInfo(snapshot),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const findAircraft = defineTool({
|
||||
name: 'find_aircraft',
|
||||
title: 'Find an aircraft',
|
||||
description:
|
||||
'Find aircraft currently reported anywhere by callsign, ICAO 24-bit ' +
|
||||
'address or registration. Registrations are known only for aircraft in ' +
|
||||
'the military feed.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
callsign: CALLSIGN_SCHEMA,
|
||||
icao24: ICAO24_SCHEMA,
|
||||
registration: { type: 'string', minLength: 2, maxLength: 12 },
|
||||
limit: LIMIT_SCHEMA,
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['aircraft'],
|
||||
async run(args, { services, signal }) {
|
||||
const given = ['callsign', 'icao24', 'registration'].filter(
|
||||
(key) => args[key] != null,
|
||||
);
|
||||
if (given.length !== 1)
|
||||
throw new ToolError(
|
||||
'invalid_arguments',
|
||||
'Give exactly one of callsign, icao24 or registration',
|
||||
);
|
||||
const [key] = given;
|
||||
const wanted = args[key].trim().toUpperCase();
|
||||
const field = {
|
||||
callsign: 'callsign',
|
||||
icao24: 'id',
|
||||
registration: 'registration',
|
||||
}[key];
|
||||
// Only the military feed reports registrations.
|
||||
const feeds = [
|
||||
...(key === 'registration'
|
||||
? []
|
||||
: [{ name: 'civil', feed: services.aircraft }]),
|
||||
{ name: 'military', feed: services.military },
|
||||
].filter(({ feed }) => feed);
|
||||
if (!feeds.length)
|
||||
throw new ToolError(
|
||||
'unavailable',
|
||||
'The military aircraft feed is not available here',
|
||||
);
|
||||
const settled = await Promise.allSettled(
|
||||
feeds.map(({ feed }) => feed.getSnapshot({}, { signal })),
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
// A search succeeds when any feed answers; missing feeds are named.
|
||||
const unavailable = feeds
|
||||
.filter((_, index) => settled[index].status === 'rejected')
|
||||
.map(({ name }) => name);
|
||||
if (unavailable.length === feeds.length) throw settled[0].reason;
|
||||
const seen = new Set();
|
||||
const matches = settled
|
||||
.flatMap((result, index) =>
|
||||
result.status === 'fulfilled'
|
||||
? result.value.records.map((record) => ({
|
||||
record,
|
||||
feed: feeds[index].name,
|
||||
}))
|
||||
: [],
|
||||
)
|
||||
.filter(
|
||||
({ record }) =>
|
||||
String(record[field] || '')
|
||||
.trim()
|
||||
.toUpperCase() === wanted,
|
||||
)
|
||||
.filter(({ record }) => !seen.has(record.id) && seen.add(record.id));
|
||||
const rows = matches.map(({ record }) => aircraftRow(record));
|
||||
// One match is shown followed; several are not framed.
|
||||
const only = matches.length === 1 ? matches[0] : null;
|
||||
// Each feed that answered, with its own freshness and coverage.
|
||||
const answered = feeds.flatMap(({ name }, index) =>
|
||||
settled[index].status === 'fulfilled'
|
||||
? [{ feed: name, ...snapshotInfo(settled[index].value) }]
|
||||
: [],
|
||||
);
|
||||
const stale = answered
|
||||
.filter((feed) => feed.freshness === 'stale')
|
||||
.map((feed) => feed.feed);
|
||||
return {
|
||||
summary:
|
||||
(rows.length
|
||||
? `Found ${countNoun(rows.length, 'aircraft', 'aircraft')} with ${key} ${wanted}.`
|
||||
: `No aircraft with ${key} ${wanted} is currently reported.`) +
|
||||
(unavailable.length
|
||||
? ` The ${unavailable.join(' and ')} feed did not answer.`
|
||||
: '') +
|
||||
(stale.length
|
||||
? ` The ${stale.join(' and ')} feed data may be stale.`
|
||||
: ''),
|
||||
data: {
|
||||
view: only
|
||||
? suggestView(services, {
|
||||
point: { lat: only.record.latitude, lon: only.record.longitude },
|
||||
follow: {
|
||||
kind:
|
||||
only.feed === 'military' ? 'military_aircraft' : 'aircraft',
|
||||
id: only.record.id,
|
||||
},
|
||||
})
|
||||
: null,
|
||||
...capRows(rows, args.limit),
|
||||
unavailable_feeds: unavailable,
|
||||
stale_feeds: stale,
|
||||
feeds: answered,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const getAircraftTrack = defineTool({
|
||||
name: 'get_aircraft_track',
|
||||
title: 'Aircraft track',
|
||||
description:
|
||||
'Recent positions of one aircraft by ICAO 24-bit address, oldest first. ' +
|
||||
`Long tracks are evenly thinned to at most ${MAX_TRACK_POINTS} points.`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { icao24: ICAO24_SCHEMA, military: MILITARY_SCHEMA },
|
||||
required: ['icao24'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['aircraft'],
|
||||
async run(args, { services, signal }) {
|
||||
const icao24 = args.icao24.toLowerCase();
|
||||
// A track server answers 404 for an aircraft it has no positions for.
|
||||
const track = await feedFor(services, args.military)
|
||||
.getTrack(icao24, { signal })
|
||||
.catch((error) => {
|
||||
if (error?.status === 404) return { records: [] };
|
||||
throw error;
|
||||
});
|
||||
const points = [...track.records].sort(
|
||||
(a, b) => a.observedAtMs - b.observedAtMs,
|
||||
);
|
||||
const kept = thinEvenly(points, MAX_TRACK_POINTS);
|
||||
const first = points[0];
|
||||
const last = points.at(-1);
|
||||
return {
|
||||
summary: points.length
|
||||
? `${countNoun(points.length, 'position')} for ${icao24} from ${isoTime(first.observedAtMs)} to ${isoTime(last.observedAtMs)}.`
|
||||
: `No recent track is available for ${icao24}.`,
|
||||
data: {
|
||||
view: last
|
||||
? suggestView(services, {
|
||||
point: { lat: last.latitude, lon: last.longitude },
|
||||
follow: {
|
||||
kind: args.military ? 'military_aircraft' : 'aircraft',
|
||||
id: icao24,
|
||||
},
|
||||
})
|
||||
: null,
|
||||
icao24,
|
||||
total: points.length,
|
||||
returned: kept.length,
|
||||
thinned: kept.length < points.length,
|
||||
points: kept.map((point) => ({
|
||||
time: isoTime(point.observedAtMs),
|
||||
lat: round(point.latitude, 5),
|
||||
lon: round(point.longitude, 5),
|
||||
altitude_m: round(point.baroAltitudeM, 0),
|
||||
on_ground: point.onGround === true,
|
||||
})),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const getAircraftInfo = defineTool({
|
||||
name: 'get_aircraft_info',
|
||||
title: 'Aircraft type and route',
|
||||
description:
|
||||
'Look up an aircraft type and registration by ICAO 24-bit address, and ' +
|
||||
'a flight route by callsign, from adsbdb. Give either or both.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { icao24: ICAO24_SCHEMA, callsign: CALLSIGN_SCHEMA },
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['aircraft'],
|
||||
async run(args, { services, signal }) {
|
||||
if (args.icao24 == null && args.callsign == null)
|
||||
throw new ToolError('invalid_arguments', 'Give icao24, callsign or both');
|
||||
const lookup = (kind, id) =>
|
||||
id == null
|
||||
? null
|
||||
: services.aircraft.getEnrichment({ kind, id }, { signal });
|
||||
const icao24 = args.icao24?.toLowerCase();
|
||||
const callsign = args.callsign?.toUpperCase();
|
||||
const [type, route] = await Promise.all([
|
||||
lookup('type', icao24),
|
||||
lookup('route', callsign),
|
||||
]);
|
||||
const aircraft = type?.found
|
||||
? {
|
||||
icao24,
|
||||
type_code: type.typeCode ?? null,
|
||||
type_name: type.typeName ?? null,
|
||||
registration: type.registration ?? null,
|
||||
}
|
||||
: null;
|
||||
const airport = (value) =>
|
||||
value
|
||||
? {
|
||||
code: value.code || null,
|
||||
name: value.name || null,
|
||||
lat: value.lat ?? null,
|
||||
lon: value.lon ?? null,
|
||||
}
|
||||
: null;
|
||||
const flight = route?.found
|
||||
? {
|
||||
callsign,
|
||||
airline: route.airline ?? null,
|
||||
origin: airport(route.origin),
|
||||
destination: airport(route.destination),
|
||||
}
|
||||
: null;
|
||||
const parts = [];
|
||||
if (icao24)
|
||||
parts.push(
|
||||
aircraft
|
||||
? `aircraft ${icao24} is ${aircraft.type_name || aircraft.type_code || 'an aircraft of unknown type'}${aircraft.registration ? ` (${aircraft.registration})` : ''}`
|
||||
: `no type is known for ${icao24}`,
|
||||
);
|
||||
if (callsign)
|
||||
parts.push(
|
||||
flight
|
||||
? `flight ${callsign} flies ${flight.origin?.code || flight.origin?.name} to ${flight.destination?.code || flight.destination?.name}${flight.airline ? ` (${flight.airline})` : ''}`
|
||||
: `no route is known for ${callsign}`,
|
||||
);
|
||||
const summary = parts.join('; ');
|
||||
return {
|
||||
summary: `${summary.charAt(0).toUpperCase()}${summary.slice(1)}.`,
|
||||
data: { aircraft, route: flight },
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,390 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { composeCatalog, coreTools } from '../index.js';
|
||||
import { LiveSourceError } from '../../sources/live/contract.js';
|
||||
|
||||
const record = (id, latitude, longitude, extra = {}) => ({
|
||||
id,
|
||||
reference: id,
|
||||
latitude,
|
||||
longitude,
|
||||
callsign: null,
|
||||
originCountry: null,
|
||||
positionTimeMs: Date.UTC(2026, 0, 1),
|
||||
contactTimeMs: Date.UTC(2026, 0, 1, 0, 0, 5),
|
||||
baroAltitudeM: 10000,
|
||||
ellipsoidAltitudeM: null,
|
||||
onGround: false,
|
||||
speedMps: 230.04,
|
||||
courseDeg: 90.4,
|
||||
verticalRateMps: 0,
|
||||
category: null,
|
||||
typeCode: null,
|
||||
registration: null,
|
||||
operator: null,
|
||||
...extra,
|
||||
});
|
||||
const snapshot = (records, extra = {}) => ({
|
||||
records,
|
||||
complete: true,
|
||||
source: 'Test feed',
|
||||
coverage: 'test coverage',
|
||||
observedAtMs: Date.UTC(2026, 0, 1),
|
||||
freshness: 'current',
|
||||
...extra,
|
||||
});
|
||||
const feed = (records, { track = [], enrichment = {}, ...extra } = {}) => {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
async getSnapshot(query) {
|
||||
calls.push(query);
|
||||
return snapshot(records, extra);
|
||||
},
|
||||
async getTrack(reference) {
|
||||
calls.push(reference);
|
||||
return { records: track, complete: false };
|
||||
},
|
||||
async getEnrichment({ kind, id }) {
|
||||
calls.push(`${kind}:${id}`);
|
||||
return enrichment[`${kind}:${id}`] ?? { found: false };
|
||||
},
|
||||
};
|
||||
};
|
||||
const civil = [
|
||||
record('aaa111', 37.7, -122.4, {
|
||||
callsign: 'UAL123',
|
||||
originCountry: 'United States',
|
||||
}),
|
||||
record('bbb222', 37.58, -122.28, { onGround: true, baroAltitudeM: 0 }),
|
||||
record('ccc333', 51.5, -0.1, { callsign: 'BAW1' }),
|
||||
];
|
||||
const military = [
|
||||
record('ae0001', 37.6, -122.3, {
|
||||
callsign: 'RCH123',
|
||||
registration: '07-7175',
|
||||
typeCode: 'C17',
|
||||
}),
|
||||
];
|
||||
const bay = { lat: 37.6, lon: -122.3, radius_km: 60 };
|
||||
|
||||
test('aircraft in an area are filtered, nearest first, with source details', async () => {
|
||||
const aircraft = feed(civil);
|
||||
const catalog = composeCatalog({ tools: coreTools, services: { aircraft } });
|
||||
const result = await catalog.call('aircraft_in_area', { area: bay });
|
||||
assert.equal(result.summary, '2 aircraft in 60 km around 37.600, -122.300.');
|
||||
assert.deepEqual(
|
||||
result.data.rows.map((row) => row.id),
|
||||
['bbb222', 'aaa111'],
|
||||
);
|
||||
assert.deepEqual(aircraft.calls[0], { latitude: 37.6, longitude: -122.3 });
|
||||
assert.deepEqual(result.data.rows[1], {
|
||||
id: 'aaa111',
|
||||
callsign: 'UAL123',
|
||||
lat: 37.7,
|
||||
lon: -122.4,
|
||||
altitude_m: 10000,
|
||||
on_ground: false,
|
||||
speed_mps: 230,
|
||||
course_deg: 90,
|
||||
vertical_rate_mps: 0,
|
||||
type_code: null,
|
||||
registration: null,
|
||||
operator: null,
|
||||
origin_country: 'United States',
|
||||
last_contact: '2026-01-01T00:00:05.000Z',
|
||||
distance_km: 14.2,
|
||||
});
|
||||
assert.equal(result.data.source, 'Test feed');
|
||||
assert.equal(result.data.observed_at, '2026-01-01T00:00:00.000Z');
|
||||
|
||||
const airborne = await catalog.call('aircraft_in_area', {
|
||||
area: bay,
|
||||
airborne_only: true,
|
||||
});
|
||||
assert.deepEqual(
|
||||
airborne.data.rows.map((row) => row.id),
|
||||
['aaa111'],
|
||||
);
|
||||
const low = await catalog.call('aircraft_in_area', {
|
||||
area: bay,
|
||||
max_altitude_m: 100,
|
||||
});
|
||||
assert.deepEqual(
|
||||
low.data.rows.map((row) => row.id),
|
||||
['bbb222'],
|
||||
);
|
||||
});
|
||||
|
||||
test('the military feed is used on request and reported when missing', async () => {
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: {
|
||||
aircraft: feed(civil),
|
||||
military: feed(military, { freshness: 'stale' }),
|
||||
},
|
||||
});
|
||||
const result = await catalog.call('aircraft_in_area', {
|
||||
area: bay,
|
||||
military: true,
|
||||
});
|
||||
assert.equal(
|
||||
result.summary,
|
||||
'1 military aircraft in 60 km around 37.600, -122.300 (data may be stale).',
|
||||
);
|
||||
assert.equal(result.data.rows[0].registration, '07-7175');
|
||||
const civilOnly = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { aircraft: feed(civil) },
|
||||
});
|
||||
await assert.rejects(
|
||||
civilOnly.call('aircraft_in_area', { area: bay, military: true }),
|
||||
(error) => error.code === 'unsupported',
|
||||
);
|
||||
});
|
||||
|
||||
test('aircraft are found by exactly one identifier across feeds', async () => {
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { aircraft: feed(civil), military: feed(military) },
|
||||
});
|
||||
const byCallsign = await catalog.call('find_aircraft', {
|
||||
callsign: 'ual123',
|
||||
});
|
||||
assert.equal(byCallsign.summary, 'Found 1 aircraft with callsign UAL123.');
|
||||
assert.deepEqual(
|
||||
byCallsign.data.rows.map((row) => row.id),
|
||||
['aaa111'],
|
||||
);
|
||||
assert.equal(byCallsign.data.rows[0].distance_km, undefined);
|
||||
const byRegistration = await catalog.call('find_aircraft', {
|
||||
registration: '07-7175',
|
||||
});
|
||||
assert.deepEqual(
|
||||
byRegistration.data.rows.map((row) => row.id),
|
||||
['ae0001'],
|
||||
);
|
||||
const none = await catalog.call('find_aircraft', { icao24: 'ABCDEF' });
|
||||
assert.equal(
|
||||
none.summary,
|
||||
'No aircraft with icao24 ABCDEF is currently reported.',
|
||||
);
|
||||
await assert.rejects(catalog.call('find_aircraft', {}), /exactly one/);
|
||||
await assert.rejects(
|
||||
catalog.call('find_aircraft', { callsign: 'UAL123', icao24: 'aaa111' }),
|
||||
/exactly one/,
|
||||
);
|
||||
await assert.rejects(
|
||||
catalog.call('find_aircraft', { icao24: 'xyz' }),
|
||||
/must match/,
|
||||
);
|
||||
});
|
||||
|
||||
test('tracks are ordered and thinned to 200 points', async () => {
|
||||
const start = Date.UTC(2026, 0, 1);
|
||||
const track = Array.from({ length: 450 }, (_, index) => ({
|
||||
latitude: 37 + index / 1000,
|
||||
longitude: -122,
|
||||
observedAtMs: start + (449 - index) * 1000,
|
||||
baroAltitudeM: 1000,
|
||||
onGround: false,
|
||||
}));
|
||||
const aircraft = feed(civil, { track });
|
||||
const catalog = composeCatalog({ tools: coreTools, services: { aircraft } });
|
||||
const result = await catalog.call('get_aircraft_track', { icao24: 'AAA111' });
|
||||
assert.equal(aircraft.calls[0], 'aaa111');
|
||||
assert.equal(
|
||||
result.summary,
|
||||
'450 positions for aaa111 from 2026-01-01T00:00:00.000Z to 2026-01-01T00:07:29.000Z.',
|
||||
);
|
||||
assert.deepEqual(
|
||||
{
|
||||
total: result.data.total,
|
||||
returned: result.data.returned,
|
||||
thinned: result.data.thinned,
|
||||
},
|
||||
{ total: 450, returned: 200, thinned: true },
|
||||
);
|
||||
assert.equal(result.data.points[0].time, '2026-01-01T00:00:00.000Z');
|
||||
assert.equal(result.data.points.at(-1).time, '2026-01-01T00:07:29.000Z');
|
||||
const empty = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { aircraft: feed(civil) },
|
||||
});
|
||||
assert.equal(
|
||||
(await empty.call('get_aircraft_track', { icao24: 'aaa111' })).summary,
|
||||
'No recent track is available for aaa111.',
|
||||
);
|
||||
const failing = (status) => ({
|
||||
getSnapshot: async () => snapshot([]),
|
||||
getTrack: async () => {
|
||||
throw new LiveSourceError('unavailable', `OpenSky HTTP ${status}`, {
|
||||
status,
|
||||
});
|
||||
},
|
||||
});
|
||||
const unknown = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { aircraft: failing(404) },
|
||||
});
|
||||
assert.equal(
|
||||
(await unknown.call('get_aircraft_track', { icao24: 'abcdef' })).summary,
|
||||
'No recent track is available for abcdef.',
|
||||
);
|
||||
const down = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { aircraft: failing(502) },
|
||||
});
|
||||
await assert.rejects(
|
||||
down.call('get_aircraft_track', { icao24: 'abcdef' }),
|
||||
(error) => error.code === 'unavailable',
|
||||
);
|
||||
});
|
||||
|
||||
test('aircraft info combines type and route lookups', async () => {
|
||||
const aircraft = feed(civil, {
|
||||
enrichment: {
|
||||
'type:aaa111': {
|
||||
found: true,
|
||||
typeCode: 'B738',
|
||||
typeName: 'Boeing 737-800',
|
||||
registration: 'N12345',
|
||||
},
|
||||
'route:UAL123': {
|
||||
found: true,
|
||||
airline: 'United Airlines',
|
||||
origin: { code: 'SFO', name: 'San Francisco', lat: 37.6, lon: -122.4 },
|
||||
destination: { code: 'EWR', name: 'Newark', lat: 40.7, lon: -74.2 },
|
||||
},
|
||||
},
|
||||
});
|
||||
const catalog = composeCatalog({ tools: coreTools, services: { aircraft } });
|
||||
const both = await catalog.call('get_aircraft_info', {
|
||||
icao24: 'AAA111',
|
||||
callsign: 'ual123',
|
||||
});
|
||||
assert.equal(
|
||||
both.summary,
|
||||
'Aircraft aaa111 is Boeing 737-800 (N12345); flight UAL123 flies SFO to EWR (United Airlines).',
|
||||
);
|
||||
assert.equal(both.data.route.destination.name, 'Newark');
|
||||
const unknown = await catalog.call('get_aircraft_info', { callsign: 'XYZ9' });
|
||||
assert.equal(unknown.summary, 'No route is known for XYZ9.');
|
||||
assert.deepEqual(unknown.data, { aircraft: null, route: null });
|
||||
await assert.rejects(
|
||||
catalog.call('get_aircraft_info', {}),
|
||||
/icao24, callsign or both/,
|
||||
);
|
||||
});
|
||||
|
||||
test('live source failures become tool errors with retry guidance', async () => {
|
||||
const limited = {
|
||||
getSnapshot: async () => {
|
||||
throw new LiveSourceError('limited', 'OpenSky rate limited', {
|
||||
retryAfterMs: 45000,
|
||||
});
|
||||
},
|
||||
};
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { aircraft: limited },
|
||||
});
|
||||
await assert.rejects(
|
||||
catalog.call('aircraft_in_area', { area: bay }),
|
||||
(error) =>
|
||||
error.name === 'ToolError' &&
|
||||
error.code === 'retry_later' &&
|
||||
error.retryAfterSeconds === 45 &&
|
||||
error.message === 'OpenSky rate limited',
|
||||
);
|
||||
});
|
||||
|
||||
test('aircraft searches keep the feeds that answered and name the others', async () => {
|
||||
const down = {
|
||||
getSnapshot: async () => {
|
||||
throw new LiveSourceError('unavailable', 'OpenSky HTTP 503');
|
||||
},
|
||||
};
|
||||
const military = feed([
|
||||
record('ae1234', 38, -77, { callsign: 'RCH123', registration: '05-5140' }),
|
||||
]);
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { aircraft: down, military },
|
||||
});
|
||||
const byCallsign = await catalog.call('find_aircraft', {
|
||||
callsign: 'rch123',
|
||||
});
|
||||
assert.equal(
|
||||
byCallsign.summary,
|
||||
'Found 1 aircraft with callsign RCH123. The civil feed did not answer.',
|
||||
);
|
||||
assert.deepEqual(byCallsign.data.unavailable_feeds, ['civil']);
|
||||
const civil = feed([]);
|
||||
const byRegistration = await composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { aircraft: civil, military },
|
||||
}).call('find_aircraft', { registration: '05-5140' });
|
||||
assert.equal(byRegistration.data.rows[0].id, 'ae1234');
|
||||
assert.deepEqual(byRegistration.data.unavailable_feeds, []);
|
||||
assert.equal(civil.calls.length, 0);
|
||||
await assert.rejects(
|
||||
composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { aircraft: down, military: down },
|
||||
}).call('find_aircraft', { callsign: 'RCH123' }),
|
||||
(error) =>
|
||||
error.code === 'unavailable' && error.message === 'OpenSky HTTP 503',
|
||||
);
|
||||
});
|
||||
|
||||
test('a regional fallback feed is named in the answer', async () => {
|
||||
const regional = feed([record('abc123', 37.6, -122.3)], {
|
||||
source: 'adsb.lol',
|
||||
coverage: '250nm regional fallback',
|
||||
});
|
||||
const result = await composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { aircraft: regional },
|
||||
}).call('aircraft_in_area', {
|
||||
area: { lat: 37.6, lon: -122.3, radius_km: 20 },
|
||||
});
|
||||
assert.match(result.summary, /\(regional feed: 250nm regional fallback\)\.$/);
|
||||
});
|
||||
|
||||
test('aircraft searches say when a feed that answered is stale', async () => {
|
||||
const stale = feed([], { freshness: 'stale' });
|
||||
const result = await composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { aircraft: stale, military: feed([]) },
|
||||
}).call('find_aircraft', { callsign: 'UAL1' });
|
||||
assert.equal(
|
||||
result.summary,
|
||||
'No aircraft with callsign UAL1 is currently reported. The civil feed data may be stale.',
|
||||
);
|
||||
assert.deepEqual(result.data.stale_feeds, ['civil']);
|
||||
assert.deepEqual(
|
||||
result.data.feeds.map((entry) => [entry.feed, entry.freshness]),
|
||||
[
|
||||
['civil', 'stale'],
|
||||
['military', 'current'],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('the real OpenSky source marks an hour-old snapshot stale for searches', async () => {
|
||||
const { createOpenSkySource } =
|
||||
await import('../../sources/live/standalone.js');
|
||||
const now = Date.UTC(2026, 0, 1, 12);
|
||||
const opensky = createOpenSkySource({
|
||||
now: () => now,
|
||||
fetchImpl: async () =>
|
||||
Response.json({ time: now / 1000 - 3600, states: [] }),
|
||||
});
|
||||
const result = await composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { aircraft: opensky },
|
||||
}).call('find_aircraft', { callsign: 'UAL1' });
|
||||
assert.deepEqual(result.data.stale_feeds, ['civil']);
|
||||
assert.match(result.summary, /may be stale\.$/);
|
||||
});
|
||||
@@ -0,0 +1,305 @@
|
||||
/** Composite queries that combine other tools' answers for one area. */
|
||||
|
||||
import { suggestView } from '../views.js';
|
||||
import { feedProvenanceEnvelope } from '../../data/layerSnapshot.js';
|
||||
import { defineTool, ToolError } from '../catalog.js';
|
||||
import {
|
||||
AREA_SCHEMA,
|
||||
POINT_SCHEMA,
|
||||
areaCenter,
|
||||
resolveArea,
|
||||
resolvePoint,
|
||||
} from '../area.js';
|
||||
|
||||
const SECTION_LIMIT = 5;
|
||||
const AWARENESS_LIMIT = 10;
|
||||
const AWARENESS_RADIUS_KM = 250;
|
||||
|
||||
/** Brief sections: the tool each one calls and the arguments it passes. */
|
||||
const SECTIONS = [
|
||||
{
|
||||
key: 'weather',
|
||||
label: 'Weather',
|
||||
tool: 'get_weather',
|
||||
args: (area, center) => ({
|
||||
location: area.argument.place
|
||||
? { place: area.argument.place }
|
||||
: { lat: center.lat, lon: center.lon },
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: 'earthquakes',
|
||||
layer: 'earthquakes',
|
||||
label: 'Earthquakes',
|
||||
tool: 'get_earthquakes',
|
||||
args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }),
|
||||
},
|
||||
{
|
||||
key: 'fires',
|
||||
layer: 'local-firms',
|
||||
label: 'Active fires',
|
||||
tool: 'get_active_fires',
|
||||
args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }),
|
||||
},
|
||||
{
|
||||
key: 'aircraft',
|
||||
layer: 'flights',
|
||||
label: 'Aircraft',
|
||||
tool: 'aircraft_in_area',
|
||||
args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }),
|
||||
},
|
||||
{
|
||||
key: 'vessels',
|
||||
layer: 'ais-live-vessels',
|
||||
label: 'Ships',
|
||||
tool: 'vessels_in_area',
|
||||
args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }),
|
||||
},
|
||||
{
|
||||
key: 'cyclones',
|
||||
layer: 'weather-cyclones',
|
||||
label: 'Tropical cyclones',
|
||||
tool: 'get_cyclones',
|
||||
args: (area) => ({ area: area.argument }),
|
||||
},
|
||||
];
|
||||
|
||||
/** Military contacts around a point, as the app's military awareness panel. */
|
||||
const AWARENESS_SECTIONS = [
|
||||
{
|
||||
key: 'military_aircraft',
|
||||
tool: 'aircraft_in_area',
|
||||
args: (area) => ({
|
||||
area: area.argument,
|
||||
military: true,
|
||||
limit: AWARENESS_LIMIT,
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: 'aircraft',
|
||||
tool: 'aircraft_in_area',
|
||||
args: (area) => ({ area: area.argument, limit: AWARENESS_LIMIT }),
|
||||
},
|
||||
{
|
||||
key: 'vessels',
|
||||
tool: 'vessels_in_area',
|
||||
args: (area) => ({ area: area.argument, limit: AWARENESS_LIMIT }),
|
||||
},
|
||||
{
|
||||
key: 'installations',
|
||||
tool: 'find_military_installations',
|
||||
args: (area) => ({ area: area.argument, limit: AWARENESS_LIMIT }),
|
||||
},
|
||||
];
|
||||
|
||||
/** A section's feed state for the caption, from what its result reported. */
|
||||
function sectionFeedState(section) {
|
||||
if (section.unavailable) return 'unavailable';
|
||||
const data = section.data || {};
|
||||
if (data.freshness === 'stale' || data.stale === true) return 'stale';
|
||||
// Answered, but some of the feeds or satellites behind it did not.
|
||||
if (data.missing_sources?.length || data.unavailable_feeds?.length)
|
||||
return 'degraded';
|
||||
return 'nominal';
|
||||
}
|
||||
|
||||
/**
|
||||
* The resolved area, plus the argument sections receive. A named place is
|
||||
* passed by name, so sections resolve it to the same point and label as a
|
||||
* direct query does (place lookups are cached).
|
||||
*/
|
||||
function sectionArea(resolved, original) {
|
||||
return {
|
||||
...resolved,
|
||||
argument: original?.place
|
||||
? { place: original.place }
|
||||
: resolved.center
|
||||
? {
|
||||
lat: resolved.center.lat,
|
||||
lon: resolved.center.lon,
|
||||
radius_km: resolved.center.radiusKm,
|
||||
}
|
||||
: {
|
||||
bbox: [
|
||||
resolved.west,
|
||||
resolved.south,
|
||||
resolved.east,
|
||||
resolved.north,
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run every section whose tool the catalog offers, through the catalog so
|
||||
* replaced tools and interceptors apply. A failing section is reported as
|
||||
* unavailable instead of failing the whole answer.
|
||||
*/
|
||||
async function runSections(all, area, { tools, signal }) {
|
||||
const center = areaCenter(area);
|
||||
const sections = all.filter(({ tool }) => tools.has(tool));
|
||||
const results = await Promise.allSettled(
|
||||
sections.map(({ tool, args: build }) =>
|
||||
tools.call(tool, build(area, center)),
|
||||
),
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
const answers = {};
|
||||
const lines = [];
|
||||
sections.forEach(({ key }, index) => {
|
||||
const result = results[index];
|
||||
if (result.status === 'fulfilled') {
|
||||
answers[key] = { summary: result.value.summary, data: result.value.data };
|
||||
lines.push(result.value.summary);
|
||||
} else {
|
||||
const known = result.reason instanceof ToolError;
|
||||
answers[key] = {
|
||||
unavailable: true,
|
||||
reason: known ? result.reason.message : 'unavailable right now',
|
||||
};
|
||||
}
|
||||
});
|
||||
return { center, answers, lines };
|
||||
}
|
||||
|
||||
async function buildBrief(args, { services, signal, tools }) {
|
||||
// Sections receive the resolved box so a place name is looked up once.
|
||||
const area = sectionArea(
|
||||
await resolveArea(args.area, { services, signal }),
|
||||
args.area,
|
||||
);
|
||||
const { center, answers, lines } = await runSections(SECTIONS, area, {
|
||||
tools,
|
||||
signal,
|
||||
});
|
||||
return { area, center, brief: answers, lines };
|
||||
}
|
||||
|
||||
export const situationBrief = defineTool({
|
||||
name: 'situation_brief',
|
||||
title: 'Situation brief',
|
||||
description:
|
||||
'One overview of an area: current weather, recent earthquakes, active ' +
|
||||
'fires, aircraft overhead, ships and tropical cyclones, each summarized with ' +
|
||||
'its top items. Sections that are unavailable are marked as such.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { area: AREA_SCHEMA },
|
||||
required: ['area'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['weather'],
|
||||
async run(args, context) {
|
||||
const { area, brief, lines } = await buildBrief(args, context);
|
||||
return {
|
||||
summary: `Situation in ${area.label}: ${lines.join(' ')}`,
|
||||
data: {
|
||||
view: suggestView(context.services, {
|
||||
area,
|
||||
layers: SECTIONS.filter(({ key }) => brief[key])
|
||||
.map(({ layer }) => layer)
|
||||
.filter(Boolean),
|
||||
}),
|
||||
area: area.label,
|
||||
sections: brief,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const getHudCaption = defineTool({
|
||||
name: 'get_hud_caption',
|
||||
title: 'Heads-up display caption',
|
||||
description:
|
||||
"The short heads-up display caption God's Eye View would show for an " +
|
||||
'area, written by the app from the same overview situation_brief gives.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { area: AREA_SCHEMA },
|
||||
required: ['area'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['weather', 'summary'],
|
||||
async run(args, context) {
|
||||
const { area, center, brief, lines } = await buildBrief(args, context);
|
||||
// The app's caption context: place labels and each section as a layer
|
||||
// with the feed state its result reported, failures included.
|
||||
const layers = SECTIONS.filter(({ key }) => brief[key]).map(
|
||||
({ key, label }) => ({
|
||||
id: key,
|
||||
name: label,
|
||||
enabled: true,
|
||||
feedState: sectionFeedState(brief[key]),
|
||||
}),
|
||||
);
|
||||
const response = await context.services.summary.summarize(
|
||||
{
|
||||
placeLabels: [area.label],
|
||||
location: area.label,
|
||||
center,
|
||||
observations: lines,
|
||||
enabledLayerLabels: layers.map((layer) => layer.name),
|
||||
enabledLayers: layers.map(({ id, name, feedState }) => ({
|
||||
id,
|
||||
name,
|
||||
feedState,
|
||||
})),
|
||||
feedProvenance: feedProvenanceEnvelope(layers),
|
||||
},
|
||||
{ signal: context.signal },
|
||||
);
|
||||
const caption = response?.data?.summary;
|
||||
if (!response?.ok || typeof caption !== 'string' || !caption)
|
||||
throw new ToolError('unavailable', 'The caption service did not answer');
|
||||
return {
|
||||
summary: caption,
|
||||
data: { area: area.label, caption },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const militaryAwareness = defineTool({
|
||||
name: 'military_awareness',
|
||||
title: 'Military awareness',
|
||||
description:
|
||||
'Military contacts around a location, as the military awareness panel ' +
|
||||
'shows them: military aircraft, other aircraft, ships and mapped ' +
|
||||
`military installations within ${AWARENESS_RADIUS_KM} km by default, ` +
|
||||
'each nearest first. Sections that are unavailable are marked as such.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
location: POINT_SCHEMA,
|
||||
radius_km: { type: 'number', minimum: 1, maximum: AWARENESS_RADIUS_KM },
|
||||
},
|
||||
required: ['location'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['military'],
|
||||
async run(args, { services, signal, tools }) {
|
||||
const point = await resolvePoint(args.location, { services, signal });
|
||||
const radiusKm = args.radius_km ?? AWARENESS_RADIUS_KM;
|
||||
const area = sectionArea(
|
||||
await resolveArea(
|
||||
{ lat: point.lat, lon: point.lon, radius_km: radiusKm },
|
||||
{ services, signal },
|
||||
),
|
||||
);
|
||||
const { answers, lines } = await runSections(AWARENESS_SECTIONS, area, {
|
||||
tools,
|
||||
signal,
|
||||
});
|
||||
return {
|
||||
summary: `Military awareness within ${radiusKm} km of ${point.label}: ${lines.join(' ')}`,
|
||||
data: {
|
||||
view: suggestView(services, {
|
||||
area,
|
||||
layers: ['military', 'ais-live-vessels', 'military-installations'],
|
||||
}),
|
||||
location: point,
|
||||
radius_km: radiusKm,
|
||||
sections: answers,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
/** Submarine cables and their landing points. */
|
||||
|
||||
import { suggestView } from '../views.js';
|
||||
import { defineTool, ToolError } from '../catalog.js';
|
||||
import {
|
||||
AREA_SCHEMA,
|
||||
areaContains,
|
||||
lineTouchesArea,
|
||||
resolveArea,
|
||||
} from '../area.js';
|
||||
import { LIMIT_SCHEMA, capRows, countNoun } from '../results.js';
|
||||
|
||||
const ATTRIBUTION = '© TeleGeography — submarinecablemap.com (CC BY-NC-SA 3.0)';
|
||||
|
||||
function cableLines(feature) {
|
||||
const geometry = feature?.geometry;
|
||||
if (geometry?.type === 'LineString') return [geometry.coordinates];
|
||||
if (geometry?.type === 'MultiLineString') return geometry.coordinates;
|
||||
return [];
|
||||
}
|
||||
|
||||
export const findSubmarineCables = defineTool({
|
||||
name: 'find_submarine_cables',
|
||||
title: 'Submarine cables',
|
||||
description:
|
||||
'Undersea telecommunication cables and landing points from the ' +
|
||||
'TeleGeography map: those passing through or landing in an area, ' +
|
||||
'and/or those whose name matches.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
area: AREA_SCHEMA,
|
||||
name: { type: 'string', minLength: 2, maxLength: 80 },
|
||||
limit: LIMIT_SCHEMA,
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['cables'],
|
||||
async run(args, { services, signal }) {
|
||||
if (!args.area && !args.name)
|
||||
throw new ToolError('invalid_arguments', 'Give an area, a name or both');
|
||||
const area = args.area
|
||||
? await resolveArea(args.area, { services, signal })
|
||||
: null;
|
||||
const wanted = args.name?.toLowerCase();
|
||||
const { cables, landingPoints } = await services.cables.fetch(signal);
|
||||
const landingRows = (landingPoints?.features || [])
|
||||
.map((feature) => ({
|
||||
id: feature.properties?.id ?? null,
|
||||
name: feature.properties?.name ?? null,
|
||||
lon: feature.geometry?.coordinates?.[0],
|
||||
lat: feature.geometry?.coordinates?.[1],
|
||||
}))
|
||||
.filter(
|
||||
(point) =>
|
||||
point.name &&
|
||||
Number.isFinite(point.lat) &&
|
||||
(!area || areaContains(area, point)),
|
||||
);
|
||||
const rows = (cables?.features || [])
|
||||
.filter((feature) => {
|
||||
const name = String(feature.properties?.name || '');
|
||||
if (!name || (wanted && !name.toLowerCase().includes(wanted)))
|
||||
return false;
|
||||
return (
|
||||
!area ||
|
||||
cableLines(feature).some((line) => lineTouchesArea(line, area))
|
||||
);
|
||||
})
|
||||
.map((feature) => ({
|
||||
id: feature.properties.id ?? null,
|
||||
name: feature.properties.name,
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const where = area ? ` in ${area.label}` : '';
|
||||
const what = wanted ? ` matching "${args.name}"` : '';
|
||||
return {
|
||||
summary:
|
||||
`${countNoun(rows.length, 'submarine cable')}${what}${where}` +
|
||||
(area
|
||||
? `, with ${countNoun(landingRows.length, 'landing point')}.`
|
||||
: '.'),
|
||||
data: {
|
||||
view: suggestView(
|
||||
services,
|
||||
area
|
||||
? { area, layers: ['telegeography-submarine-cables'] }
|
||||
: {
|
||||
point: { lat: 20, lon: 0 },
|
||||
altitudeM: 15_000_000,
|
||||
layers: ['telegeography-submarine-cables'],
|
||||
},
|
||||
),
|
||||
...capRows(rows, args.limit),
|
||||
landing_points: area ? capRows(landingRows, args.limit) : null,
|
||||
attribution: ATTRIBUTION,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { composeCatalog, coreTools } from '../index.js';
|
||||
import { createApiFetch } from '../../../server/mcp/services.js';
|
||||
import { createBundledCableSource } from '../../layers/submarineCables/bundledSource.js';
|
||||
|
||||
const cables = {
|
||||
async fetch() {
|
||||
return {
|
||||
cables: {
|
||||
features: [
|
||||
{
|
||||
properties: { id: 'marea', name: 'MAREA' },
|
||||
geometry: {
|
||||
type: 'MultiLineString',
|
||||
coordinates: [
|
||||
[
|
||||
[-76, 36.8],
|
||||
[-40, 40],
|
||||
[-2.9, 43.4],
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
properties: { id: 'unity', name: 'Unity' },
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: [
|
||||
[-118, 34],
|
||||
[139, 35],
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
landingPoints: {
|
||||
features: [
|
||||
{
|
||||
properties: { id: 'vb', name: 'Virginia Beach, VA, United States' },
|
||||
geometry: { type: 'Point', coordinates: [-75.98, 36.85] },
|
||||
},
|
||||
{
|
||||
properties: { id: 'bilbao', name: 'Bilbao, Spain' },
|
||||
geometry: { type: 'Point', coordinates: [-2.93, 43.26] },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
test('cables are found by area and name, with landing points and attribution', async () => {
|
||||
const catalog = composeCatalog({ tools: coreTools, services: { cables } });
|
||||
const virginia = await catalog.call('find_submarine_cables', {
|
||||
area: { lat: 36.85, lon: -75.98, radius_km: 50 },
|
||||
});
|
||||
assert.equal(
|
||||
virginia.summary,
|
||||
'1 submarine cable in 50 km around 36.850, -75.980, with 1 landing point.',
|
||||
);
|
||||
assert.deepEqual(virginia.data.rows, [{ id: 'marea', name: 'MAREA' }]);
|
||||
assert.equal(
|
||||
virginia.data.landing_points.rows[0].name,
|
||||
'Virginia Beach, VA, United States',
|
||||
);
|
||||
assert.match(virginia.data.attribution, /TeleGeography.*CC BY-NC-SA 3\.0/);
|
||||
const named = await catalog.call('find_submarine_cables', { name: 'uni' });
|
||||
assert.equal(named.summary, '1 submarine cable matching "uni".');
|
||||
assert.equal(named.data.landing_points, null);
|
||||
await assert.rejects(
|
||||
catalog.call('find_submarine_cables', {}),
|
||||
/area, a name or both/,
|
||||
);
|
||||
// Areas a cable crosses between vertices, including at the antimeridian.
|
||||
const atlantic = await catalog.call('find_submarine_cables', {
|
||||
area: { bbox: [-30, 40, -20, 43] },
|
||||
});
|
||||
assert.deepEqual(
|
||||
atlantic.data.rows.map((row) => row.id),
|
||||
['marea'],
|
||||
);
|
||||
const pacific = await catalog.call('find_submarine_cables', {
|
||||
area: { bbox: [179, 34, -179, 35.5] },
|
||||
});
|
||||
assert.deepEqual(
|
||||
pacific.data.rows.map((row) => row.id),
|
||||
['unity'],
|
||||
);
|
||||
});
|
||||
|
||||
test('the local fetch serves bundled data files and nothing outside them', async () => {
|
||||
const fetchImpl = createApiFetch({
|
||||
apiBase: 'http://127.0.0.1:1',
|
||||
fetchImpl: async () => assert.fail('no network'),
|
||||
});
|
||||
const { cables: collection, landingPoints } = await createBundledCableSource({
|
||||
fetchImpl,
|
||||
}).fetch();
|
||||
assert.ok(collection.features.length > 500);
|
||||
assert.ok(landingPoints.features.length > 1000);
|
||||
const outside = await fetchImpl(
|
||||
new URL('../../../package.json', import.meta.url).href,
|
||||
);
|
||||
assert.equal(outside.status, 404);
|
||||
const missing = await fetchImpl(
|
||||
new URL('../../data/local_data/missing.json', import.meta.url).href,
|
||||
);
|
||||
assert.equal(missing.status, 404);
|
||||
});
|
||||
@@ -0,0 +1,521 @@
|
||||
/**
|
||||
* Environment queries: weather, regional briefs, cyclones, fire perimeters,
|
||||
* terrain height, mapped military sites and map features.
|
||||
*/
|
||||
|
||||
import { suggestView } from '../views.js';
|
||||
import { defineTool, ToolError } from '../catalog.js';
|
||||
import {
|
||||
AREA_SCHEMA,
|
||||
POINT_SCHEMA,
|
||||
areaCenter,
|
||||
areaContains,
|
||||
distanceKm,
|
||||
polygonsTouchArea,
|
||||
resolveArea,
|
||||
resolvePoint,
|
||||
} from '../area.js';
|
||||
import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.js';
|
||||
|
||||
const MAX_TERRAIN_POINTS = 20;
|
||||
const MAX_SITE_BOX_DEGREES = 10;
|
||||
const FEATURE_KINDS = {
|
||||
administrative_areas: 'getAdministrativeAreas',
|
||||
named_places: 'getEnclosingAreas',
|
||||
monuments: 'getMonuments',
|
||||
};
|
||||
|
||||
const round = (value, digits) =>
|
||||
Number.isFinite(value) ? Number(value.toFixed(digits)) : null;
|
||||
|
||||
// WMO weather interpretation codes used by the weather provider.
|
||||
const WEATHER_CODES = {
|
||||
0: 'clear sky',
|
||||
1: 'mainly clear',
|
||||
2: 'partly cloudy',
|
||||
3: 'overcast',
|
||||
45: 'fog',
|
||||
48: 'depositing rime fog',
|
||||
51: 'light drizzle',
|
||||
53: 'drizzle',
|
||||
55: 'dense drizzle',
|
||||
56: 'light freezing drizzle',
|
||||
57: 'freezing drizzle',
|
||||
61: 'light rain',
|
||||
63: 'rain',
|
||||
65: 'heavy rain',
|
||||
66: 'light freezing rain',
|
||||
67: 'freezing rain',
|
||||
71: 'light snow',
|
||||
73: 'snow',
|
||||
75: 'heavy snow',
|
||||
77: 'snow grains',
|
||||
80: 'light rain showers',
|
||||
81: 'rain showers',
|
||||
82: 'violent rain showers',
|
||||
85: 'light snow showers',
|
||||
86: 'snow showers',
|
||||
95: 'thunderstorm',
|
||||
96: 'thunderstorm with light hail',
|
||||
99: 'thunderstorm with heavy hail',
|
||||
};
|
||||
|
||||
function weatherRow(weather) {
|
||||
if (!weather) return null;
|
||||
return {
|
||||
observed_at: weather.observedAt ?? null,
|
||||
conditions: WEATHER_CODES[weather.weatherCode] ?? null,
|
||||
weather_code: weather.weatherCode ?? null,
|
||||
temperature_c: weather.temperatureC ?? null,
|
||||
feels_like_c: weather.apparentTemperatureC ?? null,
|
||||
precipitation_mm: weather.precipitationMm ?? null,
|
||||
cloud_cover_pct: weather.cloudCoverPct ?? null,
|
||||
wind_kph: weather.windKph ?? null,
|
||||
wind_direction_deg: weather.windDirectionDeg ?? null,
|
||||
visibility_m: weather.visibilityM ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function describeWeather(row) {
|
||||
if (!row) return 'no current weather';
|
||||
const parts = [
|
||||
row.conditions,
|
||||
Number.isFinite(row.temperature_c) && `${Math.round(row.temperature_c)}°C`,
|
||||
Number.isFinite(row.wind_kph) && `wind ${Math.round(row.wind_kph)} km/h`,
|
||||
].filter(Boolean);
|
||||
return parts.join(', ') || 'conditions unknown';
|
||||
}
|
||||
|
||||
export const getWeather = defineTool({
|
||||
name: 'get_weather',
|
||||
title: 'Current weather',
|
||||
description:
|
||||
'Current weather at a location: conditions, temperature, feels-like, ' +
|
||||
'precipitation, cloud cover, wind and visibility.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { location: POINT_SCHEMA },
|
||||
required: ['location'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['weather'],
|
||||
async run(args, { services, signal }) {
|
||||
const point = await resolvePoint(args.location, { services, signal });
|
||||
const payload = await services.weather.getConditions(point.lat, point.lon, {
|
||||
signal,
|
||||
});
|
||||
const row = weatherRow(payload?.weather);
|
||||
// The proxy answers from its cache, marked stale, when a refresh fails.
|
||||
const stale = payload?.status === 'stale';
|
||||
return {
|
||||
summary:
|
||||
`Weather at ${point.label}: ${describeWeather(row)}` +
|
||||
(stale ? ' (data may be stale).' : '.'),
|
||||
data: {
|
||||
location: point,
|
||||
weather: row,
|
||||
stale,
|
||||
retrieved_at: payload?.retrievedAt ?? null,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const getRegionalBrief = defineTool({
|
||||
name: 'get_regional_brief',
|
||||
title: 'Regional brief',
|
||||
description:
|
||||
'What and where a location is, its current weather, and recent news ' +
|
||||
'headlines for the region.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { location: POINT_SCHEMA },
|
||||
required: ['location'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['regional'],
|
||||
async run(args, { services, signal }) {
|
||||
const point = await resolvePoint(args.location, { services, signal });
|
||||
const brief = await services.regional.getBrief(point.lat, point.lon, {
|
||||
signal,
|
||||
});
|
||||
const place = brief?.place ?? null;
|
||||
const articles = (brief?.articles || []).slice(0, 10).map((article) => ({
|
||||
title: article.title ?? null,
|
||||
url: article.url ?? null,
|
||||
source: article.domain ?? null,
|
||||
published_at: article.publishedAt ?? null,
|
||||
}));
|
||||
const weather = weatherRow(brief?.weather);
|
||||
const label = place?.label || point.label;
|
||||
return {
|
||||
summary: `${label}: ${describeWeather(weather)}; ${countNoun(articles.length, 'recent headline')}.`,
|
||||
data: {
|
||||
location: point,
|
||||
place: place && {
|
||||
label: place.label ?? null,
|
||||
locality: place.locality ?? null,
|
||||
region: place.region ?? null,
|
||||
country: place.country ?? null,
|
||||
kind: place.kind ?? null,
|
||||
},
|
||||
weather,
|
||||
news_source: brief?.newsSource ?? null,
|
||||
articles,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const getCyclones = defineTool({
|
||||
name: 'get_cyclones',
|
||||
title: 'Tropical cyclones',
|
||||
description:
|
||||
'Active tropical cyclones from the NOAA National Hurricane Center and ' +
|
||||
'Central Pacific Hurricane Center, optionally limited to an area.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { area: AREA_SCHEMA },
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['cyclones'],
|
||||
async run(args, { services, signal }) {
|
||||
const area = args.area
|
||||
? await resolveArea(args.area, { services, signal })
|
||||
: null;
|
||||
const snapshot = await services.cyclones.getSnapshot({ signal });
|
||||
if (snapshot.unavailable)
|
||||
throw new ToolError(
|
||||
'unavailable',
|
||||
'Cyclone advisories are unavailable right now',
|
||||
);
|
||||
const storms = snapshot.storms
|
||||
.filter(
|
||||
(storm) =>
|
||||
!area ||
|
||||
areaContains(area, {
|
||||
lat: storm.position.latitude,
|
||||
lon: storm.position.longitude,
|
||||
}),
|
||||
)
|
||||
.map((storm) => ({
|
||||
id: storm.id,
|
||||
name: storm.name,
|
||||
classification: storm.classification,
|
||||
basin: storm.basin,
|
||||
lat: storm.position.latitude,
|
||||
lon: storm.position.longitude,
|
||||
position_at: storm.positionAt,
|
||||
wind_kt: storm.windKt,
|
||||
pressure_hpa: storm.pressureHpa,
|
||||
movement: storm.movement ?? null,
|
||||
advisory: storm.advisoryNumber,
|
||||
issued_at: storm.issuedAt,
|
||||
advisory_url: storm.advisoryUrl,
|
||||
}))
|
||||
.sort((a, b) => (b.wind_kt ?? 0) - (a.wind_kt ?? 0));
|
||||
const where = area ? ` in ${area.label}` : '';
|
||||
return {
|
||||
summary:
|
||||
`${countNoun(storms.length, 'active tropical cyclone')}${where}` +
|
||||
(storms[0]
|
||||
? `; strongest ${storms[0].name} at ${storms[0].wind_kt} kt.`
|
||||
: '.'),
|
||||
data: {
|
||||
view: suggestView(
|
||||
services,
|
||||
area
|
||||
? { area, layers: ['weather-cyclones'] }
|
||||
: {
|
||||
point: { lat: 20, lon: 0 },
|
||||
altitudeM: 15_000_000,
|
||||
layers: ['weather-cyclones'],
|
||||
},
|
||||
),
|
||||
storms,
|
||||
coverage: snapshot.coverage ?? null,
|
||||
source: snapshot.source ?? null,
|
||||
attribution: snapshot.attribution ?? null,
|
||||
stale: snapshot.stale === true,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const getFirePerimeters = defineTool({
|
||||
name: 'get_fire_perimeters',
|
||||
title: 'Wildfire perimeters',
|
||||
description:
|
||||
'Mapped wildfire perimeters from the US WFIGS service in an area, ' +
|
||||
'largest first, with size, containment, cause and personnel.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { area: AREA_SCHEMA, limit: LIMIT_SCHEMA },
|
||||
required: ['area'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['perimeters'],
|
||||
async run(args, { services, signal }) {
|
||||
const area = await resolveArea(args.area, { services, signal });
|
||||
const rows = (await services.perimeters.getSnapshot({ signal }))
|
||||
.filter((fire) => polygonsTouchArea(fire.polygons, area))
|
||||
.map((fire) => ({ fire, point: polygonCenter(fire.polygons) }))
|
||||
.filter(({ point }) => point)
|
||||
.map(({ fire, point }) => ({
|
||||
id: fire.stableId,
|
||||
name: fire.name,
|
||||
acres: round(fire.acres, 0),
|
||||
contained_pct: fire.containedPct,
|
||||
state: fire.state,
|
||||
county: fire.county,
|
||||
category: fire.category,
|
||||
cause: fire.cause,
|
||||
behavior: fire.behavior,
|
||||
personnel: fire.personnel,
|
||||
discovered: isoTime(fire.discoveredTime),
|
||||
updated: isoTime(fire.updatedTime),
|
||||
lat: round(point.lat, 5),
|
||||
lon: round(point.lon, 5),
|
||||
}))
|
||||
.sort((a, b) => (b.acres ?? 0) - (a.acres ?? 0));
|
||||
return {
|
||||
summary: `${countNoun(rows.length, 'mapped wildfire perimeter')} in ${area.label}.`,
|
||||
data: {
|
||||
view: suggestView(services, { area, layers: ['fire-perimeters'] }),
|
||||
...capRows(rows, args.limit),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/** The average vertex of a perimeter's outer rings, enough to place it in an area. */
|
||||
function polygonCenter(polygons) {
|
||||
let lat = 0;
|
||||
let lon = 0;
|
||||
let count = 0;
|
||||
for (const polygon of polygons || []) {
|
||||
for (const [x, y] of polygon?.[0] || []) {
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
|
||||
lon += x;
|
||||
lat += y;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count ? { lat: lat / count, lon: lon / count } : null;
|
||||
}
|
||||
|
||||
export const getTerrainHeight = defineTool({
|
||||
name: 'get_terrain_height',
|
||||
title: 'Terrain height',
|
||||
description:
|
||||
'Ground elevation above sea level at up to 20 points, with the geoid ' +
|
||||
'and ellipsoid heights.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
points: {
|
||||
type: 'array',
|
||||
minItems: 1,
|
||||
maxItems: MAX_TERRAIN_POINTS,
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
lat: { type: 'number', minimum: -90, maximum: 90 },
|
||||
lon: { type: 'number', minimum: -180, maximum: 180 },
|
||||
},
|
||||
required: ['lat', 'lon'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['points'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['terrain'],
|
||||
async run(args, { services, signal }) {
|
||||
const results = await services.terrain.getHeights(args.points, { signal });
|
||||
if (!Array.isArray(results) || results.length !== args.points.length)
|
||||
throw new ToolError('malformed', 'Terrain heights were incomplete');
|
||||
const rows = args.points.map((point, index) => ({
|
||||
lat: point.lat,
|
||||
lon: point.lon,
|
||||
elevation_m: round(results[index]?.elevation, 1),
|
||||
geoid_m: round(results[index]?.geoid, 1),
|
||||
ellipsoid_m: round(results[index]?.ellipsoid, 1),
|
||||
}));
|
||||
const first = rows[0];
|
||||
return {
|
||||
summary:
|
||||
rows.length === 1
|
||||
? `Ground elevation is ${first.elevation_m ?? 'unknown'} m at ${first.lat}, ${first.lon}.`
|
||||
: `Ground elevation at ${countNoun(rows.length, 'point')}.`,
|
||||
data: { points: rows },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const NAMES_WAIT_MS = 5000;
|
||||
|
||||
/**
|
||||
* The value `promise` resolves to within `ms`, or null when it takes longer
|
||||
* or fails. Rejects only when `signal` aborts.
|
||||
*/
|
||||
function settleWithin(promise, ms, signal) {
|
||||
signal?.throwIfAborted();
|
||||
return new Promise((resolve, reject) => {
|
||||
const done = (value) => {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener('abort', abort);
|
||||
resolve(value);
|
||||
};
|
||||
const abort = () => {
|
||||
clearTimeout(timer);
|
||||
reject(signal.reason);
|
||||
};
|
||||
const timer = setTimeout(() => done(null), ms);
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
Promise.resolve(promise).then(
|
||||
(value) => done(value ?? null),
|
||||
() => done(null),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export const findMilitaryInstallations = defineTool({
|
||||
name: 'find_military_installations',
|
||||
title: 'Military installations',
|
||||
description:
|
||||
'Military installations mapped in OpenStreetMap within an area of at ' +
|
||||
`most ${MAX_SITE_BOX_DEGREES}° on each side, nearest the center first.`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { area: AREA_SCHEMA, limit: LIMIT_SCHEMA },
|
||||
required: ['area'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['installations'],
|
||||
async run(args, { services, signal }) {
|
||||
const area = await resolveArea(args.area, { services, signal });
|
||||
if (
|
||||
area.west > area.east ||
|
||||
area.north - area.south > MAX_SITE_BOX_DEGREES ||
|
||||
area.east - area.west > MAX_SITE_BOX_DEGREES
|
||||
)
|
||||
throw new ToolError(
|
||||
'invalid_arguments',
|
||||
`The area must be at most ${MAX_SITE_BOX_DEGREES}° on each side and not cross the antimeridian`,
|
||||
);
|
||||
const center = areaCenter(area);
|
||||
let result = await services.installations.getMappedSites(area, {
|
||||
exact: true,
|
||||
thinned: false,
|
||||
signal,
|
||||
});
|
||||
// Tile results can arrive before the name pack; wait briefly for the
|
||||
// named version rather than answer with generic names.
|
||||
let namesPending = false;
|
||||
if (result.enrichment) {
|
||||
const named = await settleWithin(
|
||||
result.enrichment,
|
||||
NAMES_WAIT_MS,
|
||||
signal,
|
||||
);
|
||||
if (named) result = named;
|
||||
else namesPending = true;
|
||||
}
|
||||
// A saturated source returned only part of the mapped sites.
|
||||
const complete = !result.saturated;
|
||||
const rows = (result.records || [])
|
||||
.map((site) => ({
|
||||
site,
|
||||
point: { lat: site.latitude, lon: site.longitude },
|
||||
}))
|
||||
.filter(({ point }) => areaContains(area, point))
|
||||
.map(({ site, point }) => ({
|
||||
id: site.id,
|
||||
name: site.name ?? null,
|
||||
kind: site.kind ?? null,
|
||||
class: site.class ?? null,
|
||||
lat: round(point.lat, 5),
|
||||
lon: round(point.lon, 5),
|
||||
distance_km: round(distanceKm(center, point), 1),
|
||||
}))
|
||||
.sort((a, b) => a.distance_km - b.distance_km);
|
||||
const notes = [
|
||||
...(complete ? [] : ['partial: the source returned only some sites']),
|
||||
...(namesPending ? ['site names are still loading'] : []),
|
||||
];
|
||||
return {
|
||||
summary:
|
||||
`${countNoun(rows.length, 'mapped military installation')} in ${area.label}` +
|
||||
(notes.length ? ` (${notes.join('; ')}).` : '.'),
|
||||
data: {
|
||||
view: suggestView(services, {
|
||||
area,
|
||||
layers: ['military-installations'],
|
||||
}),
|
||||
...capRows(rows, args.limit),
|
||||
complete,
|
||||
names_pending: namesPending,
|
||||
source: result.source ?? null,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const getMapFeatures = defineTool({
|
||||
name: 'get_map_features',
|
||||
title: 'Map features',
|
||||
description:
|
||||
'Named OpenStreetMap features at a location: the administrative areas ' +
|
||||
'containing it, named parks, land uses and water nearby, or monuments ' +
|
||||
'and memorials within 2.5 km.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
location: POINT_SCHEMA,
|
||||
kind: { type: 'string', enum: Object.keys(FEATURE_KINDS) },
|
||||
limit: LIMIT_SCHEMA,
|
||||
},
|
||||
required: ['location', 'kind'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['features'],
|
||||
async run(args, { services, signal }) {
|
||||
const point = await resolvePoint(args.location, { services, signal });
|
||||
const features = await services.features[FEATURE_KINDS[args.kind]](
|
||||
{ lat: point.lat, lon: point.lon },
|
||||
{ signal },
|
||||
);
|
||||
if (features?.unavailable)
|
||||
throw new ToolError(
|
||||
'unavailable',
|
||||
'Map feature lookups need an Overpass server configured for this app',
|
||||
);
|
||||
if (!Array.isArray(features))
|
||||
throw new ToolError(
|
||||
'retry_later',
|
||||
'The map feature service did not answer',
|
||||
);
|
||||
const rows = features
|
||||
.map((feature) => ({
|
||||
id: feature.id,
|
||||
name:
|
||||
feature.names?.english ||
|
||||
feature.names?.primary ||
|
||||
feature.names?.official ||
|
||||
null,
|
||||
category: feature.category,
|
||||
admin_level: feature.level === 99 ? null : feature.level,
|
||||
lat: round(feature.point?.lat, 5),
|
||||
lon: round(feature.point?.lon, 5),
|
||||
}))
|
||||
.filter((row) => row.name)
|
||||
.sort((a, b) => (a.admin_level ?? 99) - (b.admin_level ?? 99));
|
||||
const kind = args.kind.replace('_', ' ');
|
||||
return {
|
||||
summary: `${countNoun(rows.length, 'named feature')} (${kind}) at ${point.label}.`,
|
||||
data: { location: point, ...capRows(rows, args.limit) },
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,782 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
catalogForSurface,
|
||||
composeCatalog,
|
||||
coreTools,
|
||||
defineTool,
|
||||
ToolError,
|
||||
} from '../index.js';
|
||||
|
||||
const conditions = {
|
||||
observedAt: '2026-10-01T19:15:00.000Z',
|
||||
temperatureC: 26.3,
|
||||
apparentTemperatureC: 32,
|
||||
precipitationMm: 0,
|
||||
cloudCoverPct: 100,
|
||||
windKph: 5.4,
|
||||
windDirectionDeg: 318,
|
||||
visibilityM: 15400,
|
||||
weatherCode: 3,
|
||||
};
|
||||
const weather = {
|
||||
calls: [],
|
||||
async getConditions(latitude, longitude) {
|
||||
this.calls.push([latitude, longitude]);
|
||||
return { status: 'ready', weather: conditions };
|
||||
},
|
||||
};
|
||||
const places = {
|
||||
resolve: async (name) =>
|
||||
name === 'Austin'
|
||||
? {
|
||||
name: 'Austin, Texas',
|
||||
bounds: { west: -97.94, south: 30.1, east: -97.56, north: 30.52 },
|
||||
}
|
||||
: null,
|
||||
};
|
||||
const catalogWith = (services) =>
|
||||
composeCatalog({ tools: coreTools, services });
|
||||
|
||||
test('weather describes current conditions at a point or place', async () => {
|
||||
const catalog = catalogWith({ weather, places });
|
||||
const result = await catalog.call('get_weather', {
|
||||
location: { place: 'Austin' },
|
||||
});
|
||||
assert.equal(
|
||||
result.summary,
|
||||
'Weather at Austin, Texas: overcast, 26°C, wind 5 km/h.',
|
||||
);
|
||||
assert.deepEqual(result.data.weather, {
|
||||
observed_at: '2026-10-01T19:15:00.000Z',
|
||||
conditions: 'overcast',
|
||||
weather_code: 3,
|
||||
temperature_c: 26.3,
|
||||
feels_like_c: 32,
|
||||
precipitation_mm: 0,
|
||||
cloud_cover_pct: 100,
|
||||
wind_kph: 5.4,
|
||||
wind_direction_deg: 318,
|
||||
visibility_m: 15400,
|
||||
});
|
||||
const [latitude, longitude] = weather.calls.at(-1);
|
||||
assert.ok(Math.abs(latitude - 30.31) < 1e-9 && longitude === -97.75);
|
||||
});
|
||||
|
||||
test('the regional brief returns place, weather and at most ten headlines', async () => {
|
||||
const regional = {
|
||||
async getBrief() {
|
||||
return {
|
||||
place: {
|
||||
label: 'Edwards Plateau',
|
||||
locality: null,
|
||||
region: 'Edwards Plateau',
|
||||
country: null,
|
||||
kind: 'natural',
|
||||
},
|
||||
weather: conditions,
|
||||
newsSource: 'Google News RSS',
|
||||
articles: Array.from({ length: 12 }, (_, index) => ({
|
||||
title: `Story ${index}`,
|
||||
url: `https://news.example/${index}`,
|
||||
domain: 'news.example',
|
||||
publishedAt: '2026-10-01T00:00:00Z',
|
||||
})),
|
||||
};
|
||||
},
|
||||
};
|
||||
const result = await catalogWith({ regional }).call('get_regional_brief', {
|
||||
location: { lat: 30.27, lon: -97.74 },
|
||||
});
|
||||
assert.equal(
|
||||
result.summary,
|
||||
'Edwards Plateau: overcast, 26°C, wind 5 km/h; 10 recent headlines.',
|
||||
);
|
||||
assert.equal(result.data.articles.length, 10);
|
||||
assert.deepEqual(result.data.articles[0], {
|
||||
title: 'Story 0',
|
||||
url: 'https://news.example/0',
|
||||
source: 'news.example',
|
||||
published_at: '2026-10-01T00:00:00Z',
|
||||
});
|
||||
});
|
||||
|
||||
const storm = (id, name, latitude, longitude, windKt) => ({
|
||||
id,
|
||||
name,
|
||||
classification: 'Hurricane',
|
||||
basin: 'Atlantic',
|
||||
position: { latitude, longitude },
|
||||
positionAt: '2026-10-01T18:00:00.000Z',
|
||||
advisoryNumber: '12',
|
||||
issuedAt: '2026-10-01T15:00:00.000Z',
|
||||
windKt,
|
||||
pressureHpa: 980,
|
||||
movement: { towardDeg: 300, speedKt: 10 },
|
||||
advisoryUrl: 'https://www.nhc.noaa.gov/text/X.shtml',
|
||||
});
|
||||
|
||||
test('cyclones are listed strongest first and can be limited to an area', async () => {
|
||||
const cyclones = {
|
||||
async getSnapshot() {
|
||||
return {
|
||||
source: 'NOAA NHC / CPHC',
|
||||
coverage: 'Atlantic and eastern/central North Pacific',
|
||||
stale: false,
|
||||
unavailable: false,
|
||||
storms: [
|
||||
storm('a', 'Alpha', 25, -70, 65),
|
||||
storm('b', 'Beta', 15, -110, 120),
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
const all = await catalogWith({ cyclones }).call('get_cyclones', {});
|
||||
assert.equal(
|
||||
all.summary,
|
||||
'2 active tropical cyclones; strongest Beta at 120 kt.',
|
||||
);
|
||||
assert.deepEqual(
|
||||
all.data.storms.map((row) => row.id),
|
||||
['b', 'a'],
|
||||
);
|
||||
const atlantic = await catalogWith({ cyclones }).call('get_cyclones', {
|
||||
area: { bbox: [-100, 0, -10, 50] },
|
||||
});
|
||||
assert.equal(
|
||||
atlantic.summary,
|
||||
'1 active tropical cyclone in the requested box; strongest Alpha at 65 kt.',
|
||||
);
|
||||
const down = { getSnapshot: async () => ({ unavailable: true, storms: [] }) };
|
||||
await assert.rejects(
|
||||
catalogWith({ cyclones: down }).call('get_cyclones', {}),
|
||||
(error) => error.code === 'unavailable',
|
||||
);
|
||||
});
|
||||
|
||||
test('fire perimeters are placed by their outline and sorted by size', async () => {
|
||||
const ring = (lon, lat) => [
|
||||
[
|
||||
[lon, lat],
|
||||
[lon + 0.1, lat],
|
||||
[lon + 0.1, lat + 0.1],
|
||||
[lon, lat + 0.1],
|
||||
],
|
||||
];
|
||||
const perimeters = {
|
||||
async getSnapshot() {
|
||||
return [
|
||||
{
|
||||
stableId: 'small',
|
||||
name: 'Small Fire',
|
||||
acres: 120.4,
|
||||
containedPct: 80,
|
||||
state: 'US-CA',
|
||||
county: 'Kern',
|
||||
category: 'WF',
|
||||
cause: 'Human',
|
||||
behavior: null,
|
||||
personnel: 20,
|
||||
discoveredTime: Date.UTC(2026, 8, 1),
|
||||
updatedTime: null,
|
||||
polygons: [ring(-118.5, 35.2)],
|
||||
},
|
||||
{
|
||||
stableId: 'big',
|
||||
name: 'Big Fire',
|
||||
acres: 5000,
|
||||
containedPct: 10,
|
||||
polygons: [ring(-118.8, 35.4)],
|
||||
},
|
||||
{
|
||||
stableId: 'far',
|
||||
name: 'Far Fire',
|
||||
acres: 9000,
|
||||
polygons: [ring(-80, 30)],
|
||||
},
|
||||
{ stableId: 'empty', name: 'No Outline', acres: 1, polygons: [] },
|
||||
];
|
||||
},
|
||||
};
|
||||
const result = await catalogWith({ perimeters }).call('get_fire_perimeters', {
|
||||
area: { bbox: [-120, 34, -117, 36] },
|
||||
});
|
||||
assert.equal(
|
||||
result.summary,
|
||||
'2 mapped wildfire perimeters in the requested box.',
|
||||
);
|
||||
assert.deepEqual(
|
||||
result.data.rows.map((row) => row.id),
|
||||
['big', 'small'],
|
||||
);
|
||||
assert.deepEqual(result.data.rows[1], {
|
||||
id: 'small',
|
||||
name: 'Small Fire',
|
||||
acres: 120,
|
||||
contained_pct: 80,
|
||||
state: 'US-CA',
|
||||
county: 'Kern',
|
||||
category: 'WF',
|
||||
cause: 'Human',
|
||||
behavior: null,
|
||||
personnel: 20,
|
||||
discovered: '2026-09-01T00:00:00.000Z',
|
||||
updated: null,
|
||||
lat: 35.25,
|
||||
lon: -118.45,
|
||||
});
|
||||
});
|
||||
|
||||
test('terrain heights are returned in request order', async () => {
|
||||
const terrain = {
|
||||
async getHeights(points) {
|
||||
return points.map((point) => ({
|
||||
...point,
|
||||
elevation: point.lat * 100,
|
||||
geoid: -30.04,
|
||||
ellipsoid: point.lat * 100 - 30.04,
|
||||
}));
|
||||
},
|
||||
};
|
||||
const one = await catalogWith({ terrain }).call('get_terrain_height', {
|
||||
points: [{ lat: 27.9881, lon: 86.925 }],
|
||||
});
|
||||
assert.equal(one.summary, 'Ground elevation is 2798.8 m at 27.9881, 86.925.');
|
||||
const many = await catalogWith({ terrain }).call('get_terrain_height', {
|
||||
points: [
|
||||
{ lat: 1, lon: 1 },
|
||||
{ lat: 2, lon: 2 },
|
||||
],
|
||||
});
|
||||
assert.deepEqual(
|
||||
many.data.points.map((point) => point.elevation_m),
|
||||
[100, 200],
|
||||
);
|
||||
const short = { getHeights: async () => [] };
|
||||
await assert.rejects(
|
||||
catalogWith({ terrain: short }).call('get_terrain_height', {
|
||||
points: [{ lat: 1, lon: 1 }],
|
||||
}),
|
||||
(error) => error.code === 'malformed',
|
||||
);
|
||||
await assert.rejects(
|
||||
catalogWith({ terrain }).call('get_terrain_height', {
|
||||
points: Array(21).fill({ lat: 0, lon: 0 }),
|
||||
}),
|
||||
/at most 20 items/,
|
||||
);
|
||||
});
|
||||
|
||||
test('military installations need a bounded area and are nearest first', async () => {
|
||||
const requested = [];
|
||||
const options = [];
|
||||
let saturated = false;
|
||||
const installations = {
|
||||
async getMappedSites(box, { signal, ...rest } = {}) {
|
||||
requested.push(box);
|
||||
options.push(rest);
|
||||
return {
|
||||
saturated,
|
||||
source: 'OpenStreetMap',
|
||||
records: [
|
||||
{
|
||||
id: 'osm:1',
|
||||
kind: 'installation',
|
||||
class: 'base',
|
||||
name: 'Naval Base Point Loma',
|
||||
latitude: 32.6941,
|
||||
longitude: -117.2494,
|
||||
},
|
||||
{
|
||||
id: 'osm:2',
|
||||
kind: 'installation',
|
||||
class: 'airfield',
|
||||
name: 'North Island',
|
||||
latitude: 32.7,
|
||||
longitude: -117.2,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
const catalog = catalogWith({ installations });
|
||||
const result = await catalog.call('find_military_installations', {
|
||||
area: { lat: 32.7, lon: -117.2, radius_km: 20 },
|
||||
});
|
||||
assert.equal(
|
||||
result.summary,
|
||||
'2 mapped military installations in 20 km around 32.700, -117.200.',
|
||||
);
|
||||
assert.deepEqual(
|
||||
result.data.rows.map((row) => row.id),
|
||||
['osm:2', 'osm:1'],
|
||||
);
|
||||
assert.equal(result.data.source, 'OpenStreetMap');
|
||||
assert.equal(result.data.complete, true);
|
||||
assert.ok(requested[0].north - requested[0].south < 1);
|
||||
assert.deepEqual(options[0], { exact: true, thinned: false });
|
||||
saturated = true;
|
||||
const partial = await catalog.call('find_military_installations', {
|
||||
area: { lat: 32.7, lon: -117.2, radius_km: 20 },
|
||||
});
|
||||
assert.equal(partial.data.complete, false);
|
||||
assert.match(
|
||||
partial.summary,
|
||||
/\(partial: the source returned only some sites\)\.$/,
|
||||
);
|
||||
await assert.rejects(
|
||||
catalog.call('find_military_installations', {
|
||||
area: { bbox: [-130, 20, -100, 50] },
|
||||
}),
|
||||
/at most 10° on each side/,
|
||||
);
|
||||
});
|
||||
|
||||
test('map features name what is at a location and report an unconfigured server', async () => {
|
||||
const features = {
|
||||
async getAdministrativeAreas() {
|
||||
return [
|
||||
{
|
||||
id: 1,
|
||||
category: 'administrative',
|
||||
names: { primary: 'Texas' },
|
||||
level: 4,
|
||||
point: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
category: 'administrative',
|
||||
names: { primary: 'Travis County', english: 'Travis County' },
|
||||
level: 6,
|
||||
point: null,
|
||||
},
|
||||
{ id: 3, category: 'administrative', names: {}, level: 8, point: null },
|
||||
];
|
||||
},
|
||||
getEnclosingAreas: async () => null,
|
||||
getMonuments: async () => ({
|
||||
unavailable: true,
|
||||
code: 'OVERPASS_NOT_CONFIGURED',
|
||||
}),
|
||||
};
|
||||
const catalog = catalogWith({ features });
|
||||
const result = await catalog.call('get_map_features', {
|
||||
location: { lat: 30.27, lon: -97.74 },
|
||||
kind: 'administrative_areas',
|
||||
});
|
||||
assert.equal(
|
||||
result.summary,
|
||||
'2 named features (administrative areas) at 30.2700, -97.7400.',
|
||||
);
|
||||
assert.deepEqual(
|
||||
result.data.rows.map((row) => [row.name, row.admin_level]),
|
||||
[
|
||||
['Texas', 4],
|
||||
['Travis County', 6],
|
||||
],
|
||||
);
|
||||
await assert.rejects(
|
||||
catalog.call('get_map_features', {
|
||||
location: { lat: 30.27, lon: -97.74 },
|
||||
kind: 'monuments',
|
||||
}),
|
||||
(error) => error.code === 'unavailable' && /Overpass/.test(error.message),
|
||||
);
|
||||
await assert.rejects(
|
||||
catalog.call('get_map_features', {
|
||||
location: { lat: 30.27, lon: -97.74 },
|
||||
kind: 'named_places',
|
||||
}),
|
||||
(error) => error.code === 'retry_later',
|
||||
);
|
||||
});
|
||||
|
||||
test('the situation brief combines available sections and marks failures', async () => {
|
||||
const services = {
|
||||
weather,
|
||||
earthquakes: {
|
||||
getSnapshot: async () => [
|
||||
{
|
||||
stableId: 'q',
|
||||
usgsId: 'us1',
|
||||
lat: 30.3,
|
||||
lon: -97.7,
|
||||
mag: 3.1,
|
||||
place: 'near Austin',
|
||||
time: 0,
|
||||
depthKm: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
fires: { getSnapshot: async () => ({ keyRequired: true }) },
|
||||
cyclones: {
|
||||
getSnapshot: async () => {
|
||||
throw new Error('upstream down');
|
||||
},
|
||||
},
|
||||
places,
|
||||
};
|
||||
const result = await catalogWith(services).call('situation_brief', {
|
||||
area: { place: 'Austin' },
|
||||
});
|
||||
assert.equal(
|
||||
result.summary,
|
||||
'Situation in Austin, Texas: Weather at Austin, Texas: overcast, 26°C, wind 5 km/h. ' +
|
||||
'1 earthquake of M2.5+ in the last 24 hours in Austin, Texas; strongest M3.1 near Austin.',
|
||||
);
|
||||
assert.deepEqual(Object.keys(result.data.sections), [
|
||||
'weather',
|
||||
'earthquakes',
|
||||
'fires',
|
||||
'cyclones',
|
||||
]);
|
||||
assert.deepEqual(result.data.sections.fires, {
|
||||
unavailable: true,
|
||||
reason: 'Fire detections need a NASA FIRMS key configured for this server',
|
||||
});
|
||||
assert.deepEqual(result.data.sections.cyclones, {
|
||||
unavailable: true,
|
||||
reason: 'unavailable right now',
|
||||
});
|
||||
assert.equal(result.data.sections.aircraft, undefined);
|
||||
});
|
||||
|
||||
test('the HUD caption summarizes the brief through the summary service', async () => {
|
||||
const sent = [];
|
||||
const summary = {
|
||||
async summarize(context) {
|
||||
sent.push(context);
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
data: { summary: 'Overcast skies over Austin', error: null },
|
||||
};
|
||||
},
|
||||
};
|
||||
const result = await catalogWith({ weather, summary, places }).call(
|
||||
'get_hud_caption',
|
||||
{ area: { place: 'Austin' } },
|
||||
);
|
||||
assert.equal(result.summary, 'Overcast skies over Austin');
|
||||
assert.equal(sent[0].location, 'Austin, Texas');
|
||||
assert.deepEqual(sent[0].placeLabels, ['Austin, Texas']);
|
||||
assert.deepEqual(sent[0].enabledLayerLabels, ['Weather']);
|
||||
assert.equal(sent[0].feedProvenance.overall, 'nominal');
|
||||
assert.match(sent[0].observations[0], /^Weather at /);
|
||||
const failing = {
|
||||
summarize: async () => ({
|
||||
ok: false,
|
||||
status: 502,
|
||||
data: { summary: null },
|
||||
}),
|
||||
};
|
||||
await assert.rejects(
|
||||
catalogWith({ weather, summary: failing, places }).call('get_hud_caption', {
|
||||
area: { place: 'Austin' },
|
||||
}),
|
||||
(error) => error instanceof ToolError && error.code === 'unavailable',
|
||||
);
|
||||
});
|
||||
|
||||
test('military awareness gathers contacts around a point and marks failures', async () => {
|
||||
const contact = (id, latitude, longitude) => ({
|
||||
id,
|
||||
reference: id,
|
||||
latitude,
|
||||
longitude,
|
||||
callsign: 'RCH123',
|
||||
baroAltitudeM: 9000,
|
||||
onGround: false,
|
||||
positionTimeMs: Date.UTC(2026, 0, 1),
|
||||
});
|
||||
const military = {
|
||||
getSnapshot: async () => ({
|
||||
records: [contact('ae1234', 32.8, -117.1), contact('ae9999', 40, -100)],
|
||||
complete: true,
|
||||
source: 'Military feed',
|
||||
freshness: 'current',
|
||||
}),
|
||||
};
|
||||
const aircraft = {
|
||||
getSnapshot: async () => {
|
||||
throw new Error('upstream down');
|
||||
},
|
||||
};
|
||||
const installations = {
|
||||
getMappedSites: async () => ({
|
||||
source: 'OpenStreetMap',
|
||||
records: [
|
||||
{
|
||||
id: 'osm:1',
|
||||
kind: 'installation',
|
||||
class: 'base',
|
||||
name: 'Naval Base Point Loma',
|
||||
latitude: 32.6941,
|
||||
longitude: -117.2494,
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
const catalog = catalogWith({ military, aircraft, installations });
|
||||
const result = await catalog.call('military_awareness', {
|
||||
location: { lat: 32.7, lon: -117.2 },
|
||||
radius_km: 100,
|
||||
});
|
||||
assert.equal(
|
||||
result.summary,
|
||||
'Military awareness within 100 km of 32.7000, -117.2000: ' +
|
||||
'1 military aircraft in 100 km around 32.700, -117.200. ' +
|
||||
'1 mapped military installation in 100 km around 32.700, -117.200.',
|
||||
);
|
||||
assert.deepEqual(Object.keys(result.data.sections), [
|
||||
'military_aircraft',
|
||||
'aircraft',
|
||||
'installations',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
result.data.sections.military_aircraft.data.rows.map((row) => row.id),
|
||||
['ae1234'],
|
||||
);
|
||||
assert.deepEqual(result.data.sections.aircraft, {
|
||||
unavailable: true,
|
||||
reason: 'unavailable right now',
|
||||
});
|
||||
assert.equal(result.data.radius_km, 100);
|
||||
const voice = catalogForSurface(catalog, 'voice');
|
||||
assert.equal(voice.get('aircraft_in_area'), undefined);
|
||||
assert.deepEqual(
|
||||
Object.keys(
|
||||
(
|
||||
await voice.call('military_awareness', {
|
||||
location: { lat: 32.7, lon: -117.2 },
|
||||
radius_km: 100,
|
||||
})
|
||||
).data.sections,
|
||||
),
|
||||
['military_aircraft', 'aircraft', 'installations'],
|
||||
);
|
||||
assert.equal(
|
||||
(await catalog.call('military_awareness', { location: { lat: 0, lon: 0 } }))
|
||||
.data.radius_km,
|
||||
250,
|
||||
);
|
||||
await assert.rejects(
|
||||
catalog.call('military_awareness', {
|
||||
location: { lat: 0, lon: 0 },
|
||||
radius_km: 500,
|
||||
}),
|
||||
/radius_km/,
|
||||
);
|
||||
});
|
||||
|
||||
test('composites reach their sections through the catalog', async () => {
|
||||
const replacement = defineTool({
|
||||
name: 'get_weather',
|
||||
title: 'Weather',
|
||||
description: 'Replaced weather.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { location: { type: 'object' } },
|
||||
},
|
||||
run: async () => ({ summary: 'Replaced weather.', data: {} }),
|
||||
});
|
||||
const seen = [];
|
||||
const catalog = composeCatalog({
|
||||
tools: [...coreTools, replacement],
|
||||
replace: ['get_weather'],
|
||||
services: { weather, places },
|
||||
interceptors: [
|
||||
(call, next) => {
|
||||
seen.push([call.tool.name, call.parent ?? null]);
|
||||
return next(call);
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = await catalog.call('situation_brief', {
|
||||
area: { place: 'Austin' },
|
||||
});
|
||||
assert.equal(result.data.sections.weather.summary, 'Replaced weather.');
|
||||
assert.deepEqual(seen, [
|
||||
['situation_brief', null],
|
||||
['get_weather', 'situation_brief'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('the HUD caption context carries each section feed state', async () => {
|
||||
const sent = [];
|
||||
const summary = {
|
||||
async summarize(context) {
|
||||
sent.push(context);
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
data: { summary: 'Austin aircraft STALE' },
|
||||
};
|
||||
},
|
||||
};
|
||||
const aircraft = {
|
||||
getSnapshot: async () => ({
|
||||
records: [],
|
||||
complete: true,
|
||||
source: 'Test feed',
|
||||
freshness: 'stale',
|
||||
}),
|
||||
};
|
||||
const cyclones = {
|
||||
getSnapshot: async () => {
|
||||
throw new Error('upstream down');
|
||||
},
|
||||
};
|
||||
await catalogWith({ weather, summary, places, aircraft, cyclones }).call(
|
||||
'get_hud_caption',
|
||||
{ area: { place: 'Austin' } },
|
||||
);
|
||||
assert.deepEqual(
|
||||
sent[0].enabledLayers.map((layer) => [layer.id, layer.feedState]),
|
||||
[
|
||||
['weather', 'nominal'],
|
||||
['aircraft', 'stale'],
|
||||
['cyclones', 'unavailable'],
|
||||
],
|
||||
);
|
||||
assert.equal(sent[0].feedProvenance.overall, 'unavailable');
|
||||
assert.match(sent[0].feedProvenance.note, /Aircraft is STALE/);
|
||||
});
|
||||
|
||||
test('brief sections use a named place at its own point', async () => {
|
||||
const tokyo = {
|
||||
resolve: async () => ({
|
||||
name: 'Tokyo, Japan',
|
||||
point: { lat: 35.6769, lon: 139.7639 },
|
||||
bounds: { west: 135, south: 20, east: 155, north: 36 },
|
||||
}),
|
||||
};
|
||||
const queries = [];
|
||||
const aircraft = {
|
||||
getSnapshot: async (query) => {
|
||||
queries.push(query);
|
||||
return { records: [], complete: true, source: 'Test feed' };
|
||||
},
|
||||
};
|
||||
const result = await catalogWith({ weather, places: tokyo, aircraft }).call(
|
||||
'situation_brief',
|
||||
{ area: { place: 'Tokyo' } },
|
||||
);
|
||||
assert.deepEqual(queries[0], { latitude: 35.6769, longitude: 139.7639 });
|
||||
assert.match(result.data.sections.aircraft.summary, /in Tokyo, Japan\.$/);
|
||||
});
|
||||
|
||||
test('a fire perimeter enclosing the whole area is found', async () => {
|
||||
const perimeters = {
|
||||
getSnapshot: async () => [
|
||||
{
|
||||
stableId: 'big',
|
||||
name: 'Big Fire',
|
||||
acres: 250000,
|
||||
polygons: [
|
||||
[
|
||||
[
|
||||
[-122, 38],
|
||||
[-120, 38],
|
||||
[-120, 40],
|
||||
[-122, 40],
|
||||
],
|
||||
],
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = await catalogWith({ perimeters }).call('get_fire_perimeters', {
|
||||
area: { bbox: [-121.2, 38.9, -120.8, 39.1] },
|
||||
});
|
||||
assert.deepEqual(
|
||||
result.data.rows.map((row) => row.id),
|
||||
['big'],
|
||||
);
|
||||
});
|
||||
|
||||
test('stale weather and partial fire data reach answers and the HUD caption', async () => {
|
||||
const sent = [];
|
||||
const summary = {
|
||||
async summarize(context) {
|
||||
sent.push(context);
|
||||
return { ok: true, status: 200, data: { summary: 'Austin STALE' } };
|
||||
},
|
||||
};
|
||||
const staleWeather = {
|
||||
getConditions: async () => ({ status: 'stale', weather: conditions }),
|
||||
};
|
||||
const fires = {
|
||||
getSnapshot: async () => ({
|
||||
stale: false,
|
||||
fetchedAt: Date.UTC(2026, 0, 1),
|
||||
sources: [
|
||||
{ source: 'VIIRS_NOAA20_NRT', ok: true },
|
||||
{ source: 'VIIRS_SNPP_NRT', ok: false },
|
||||
],
|
||||
fires: [],
|
||||
}),
|
||||
};
|
||||
const catalog = catalogWith({
|
||||
weather: staleWeather,
|
||||
summary,
|
||||
places,
|
||||
fires,
|
||||
});
|
||||
const weatherResult = await catalog.call('get_weather', {
|
||||
location: { place: 'Austin' },
|
||||
});
|
||||
assert.equal(weatherResult.data.stale, true);
|
||||
assert.match(weatherResult.summary, /\(data may be stale\)\.$/);
|
||||
const fireResult = await catalog.call('get_active_fires', {
|
||||
area: { place: 'Austin' },
|
||||
});
|
||||
assert.deepEqual(fireResult.data.missing_sources, ['VIIRS_SNPP_NRT']);
|
||||
assert.match(fireResult.summary, /\(no data from VIIRS_SNPP_NRT\)\.$/);
|
||||
await catalog.call('get_hud_caption', { area: { place: 'Austin' } });
|
||||
assert.deepEqual(
|
||||
sent[0].enabledLayers.map((layer) => [layer.id, layer.feedState]),
|
||||
[
|
||||
['weather', 'stale'],
|
||||
['fires', 'degraded'],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('installation names wait for the name pack, briefly', async (t) => {
|
||||
const site = (name) => ({
|
||||
id: 'osm:9',
|
||||
kind: 'installation',
|
||||
name,
|
||||
latitude: 30.31,
|
||||
longitude: -97.76,
|
||||
});
|
||||
const source = (enrichment) => ({
|
||||
getMappedSites: async () => ({
|
||||
source: 'OpenStreetMap tiles',
|
||||
records: [site('Military area')],
|
||||
enrichment,
|
||||
}),
|
||||
});
|
||||
const area = { lat: 30.31, lon: -97.76, radius_km: 5 };
|
||||
const named = await catalogWith({
|
||||
installations: source(
|
||||
Promise.resolve({
|
||||
source: 'OpenStreetMap tiles',
|
||||
records: [site('Camp Mabry')],
|
||||
}),
|
||||
),
|
||||
}).call('find_military_installations', { area });
|
||||
assert.equal(named.data.rows[0].name, 'Camp Mabry');
|
||||
assert.equal(named.data.names_pending, false);
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const pending = catalogWith({
|
||||
installations: source(new Promise(() => {})),
|
||||
}).call('find_military_installations', { area });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
t.mock.timers.tick(5000);
|
||||
const slow = await pending;
|
||||
assert.equal(slow.data.rows[0].name, 'Military area');
|
||||
assert.equal(slow.data.names_pending, true);
|
||||
assert.match(slow.summary, /site names are still loading\)\.$/);
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
/** The bundled Bhote Koshi 2026 outburst flood event pack. */
|
||||
|
||||
import { suggestView } from '../views.js';
|
||||
import { BHOTE_KOSHI_FLOOD_PATH } from '../../data/bhoteKoshiFloodPath.js';
|
||||
import { defineTool, ToolError } from '../catalog.js';
|
||||
import { distanceKm } from '../area.js';
|
||||
|
||||
const EVENT_ID = 'bhote-koshi-2026';
|
||||
const ATTRIBUTION =
|
||||
'Imagery: Vantor Open Data; reconstruction: GeoPera (CC BY-NC 4.0). ' +
|
||||
'Linked witness posts and the GeoGeorgeShadrach geolocation map keep ' +
|
||||
"their owners' terms.";
|
||||
|
||||
const round = (value, digits) =>
|
||||
Number.isFinite(value) ? Number(value.toFixed(digits)) : null;
|
||||
|
||||
/** Length of a `[lon, lat]` line in kilometers. */
|
||||
function pathLengthKm(path) {
|
||||
let total = 0;
|
||||
for (let index = 1; index < path.length; index += 1)
|
||||
total += distanceKm(
|
||||
{ lat: path[index - 1][1], lon: path[index - 1][0] },
|
||||
{ lat: path[index][1], lon: path[index][0] },
|
||||
);
|
||||
return total;
|
||||
}
|
||||
|
||||
const pathPoint = ([lon, lat]) => ({ lat, lon });
|
||||
|
||||
/** The box around a `[lon, lat]` path, as an area to frame. */
|
||||
function pathArea(path) {
|
||||
const lons = path.map(([lon]) => lon);
|
||||
const lats = path.map(([, lat]) => lat);
|
||||
return {
|
||||
west: Math.min(...lons),
|
||||
south: Math.min(...lats),
|
||||
east: Math.max(...lons),
|
||||
north: Math.max(...lats),
|
||||
};
|
||||
}
|
||||
|
||||
function evidenceRow(record) {
|
||||
return {
|
||||
sequence: record.sequence ?? null,
|
||||
id: record.id,
|
||||
title: record.title ?? null,
|
||||
summary: record.summary ?? null,
|
||||
phase: record.phase ?? null,
|
||||
lat: round(record.lat, 5),
|
||||
lon: round(record.lon, 5),
|
||||
location_confidence: record.confidence?.location ?? null,
|
||||
time_confidence: record.confidence?.time ?? null,
|
||||
captured_at: record.timing?.capturedAt ?? null,
|
||||
in_imagery: record.imageryCoverage === 'within',
|
||||
source_url: record.media?.sourceUrl ?? null,
|
||||
platform: record.media?.platform ?? null,
|
||||
via: record.provenance?.via ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export const getBhoteKoshiFlood = defineTool({
|
||||
name: 'get_bhote_koshi_flood',
|
||||
title: 'Bhote Koshi flood',
|
||||
description:
|
||||
"The 26 August 2026 Bhote Koshi outburst flood in Nepal as God's Eye " +
|
||||
'View maps it: the evidence trail of geolocated public witness posts ' +
|
||||
'from the collapse to Devighat in story order, the mapped flood path, ' +
|
||||
'and the before and after satellite imagery. A visualization, not an ' +
|
||||
'official hazard model; capture times are unverified.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
phase: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
maxLength: 40,
|
||||
description: 'Only evidence in a phase, such as "downstream passage".',
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['events'],
|
||||
async run(args, { services, signal }) {
|
||||
const event = await services.events.getEvent(EVENT_ID, { signal });
|
||||
if (!Array.isArray(event?.evidenceSpine))
|
||||
throw new ToolError('malformed', 'The event pack has no evidence');
|
||||
const wanted = args.phase?.toLowerCase();
|
||||
const evidence = event.evidenceSpine
|
||||
.filter((record) => !wanted || record.phase?.toLowerCase() === wanted)
|
||||
.sort((a, b) => a.sequence - b.sequence)
|
||||
.map(evidenceRow);
|
||||
const phases = [
|
||||
...new Set(event.evidenceSpine.map((record) => record.phase)),
|
||||
];
|
||||
const lengthKm = round(pathLengthKm(BHOTE_KOSHI_FLOOD_PATH), 1);
|
||||
const { imagery = {} } = event;
|
||||
return {
|
||||
summary:
|
||||
`${event.title}, observed ${event.observedDate}: ${evidence.length} ` +
|
||||
`evidence record${evidence.length === 1 ? '' : 's'}` +
|
||||
(args.phase ? ` in the ${args.phase} phase` : '') +
|
||||
` along a ${lengthKm} km mapped flood path.`,
|
||||
data: {
|
||||
view: suggestView(services, {
|
||||
area: pathArea(BHOTE_KOSHI_FLOOD_PATH),
|
||||
layers: ['bhote-koshi-2026'],
|
||||
}),
|
||||
id: event.id,
|
||||
title: event.title,
|
||||
observed_date: event.observedDate,
|
||||
caveat: event.reconstruction?.caveat ?? null,
|
||||
phases,
|
||||
evidence,
|
||||
witnesses: (event.witnessAnchors || []).map((anchor) => ({
|
||||
title: anchor.title ?? null,
|
||||
lat: round(anchor.lat, 6),
|
||||
lon: round(anchor.lon, 6),
|
||||
source_url: anchor.sourceUrl ?? null,
|
||||
provenance: anchor.provenance ?? null,
|
||||
})),
|
||||
flood_path: {
|
||||
start: pathPoint(BHOTE_KOSHI_FLOOD_PATH[0]),
|
||||
end: pathPoint(BHOTE_KOSHI_FLOOD_PATH.at(-1)),
|
||||
length_km: lengthKm,
|
||||
description:
|
||||
'Simplified river centerline from the debris-dammed lake to Trishuli Bazaar.',
|
||||
},
|
||||
imagery: {
|
||||
box: imagery.rectangle ?? null,
|
||||
before: imagery.before
|
||||
? {
|
||||
observed_at: imagery.before.observedAt,
|
||||
source: imagery.before.source,
|
||||
}
|
||||
: null,
|
||||
after: imagery.after
|
||||
? {
|
||||
observed_at: imagery.after.observedAt,
|
||||
source: imagery.after.source,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
attribution: ATTRIBUTION,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
import { composeCatalog, coreTools } from '../index.js';
|
||||
import { createEventPackSource } from '../../sources/eventPacks.js';
|
||||
|
||||
const EVENT_URL = new URL(
|
||||
'../../../public/events/bhote-koshi-2026/event.json',
|
||||
import.meta.url,
|
||||
);
|
||||
const bundled = JSON.parse(await readFile(EVENT_URL, 'utf8'));
|
||||
|
||||
test('the event pack source reads each event once and checks the id', async () => {
|
||||
const requested = [];
|
||||
const source = createEventPackSource({
|
||||
fetchImpl: async (url) => {
|
||||
requested.push(url);
|
||||
return Response.json(bundled);
|
||||
},
|
||||
});
|
||||
const event = await source.getEvent('bhote-koshi-2026');
|
||||
assert.deepEqual(event, bundled);
|
||||
assert.equal(await source.getEvent('bhote-koshi-2026'), event);
|
||||
assert.deepEqual(requested, ['/events/bhote-koshi-2026/event.json']);
|
||||
await assert.rejects(source.getEvent('../secret'), /Invalid event id/);
|
||||
const missing = createEventPackSource({
|
||||
fetchImpl: async () => new Response(null, { status: 404 }),
|
||||
});
|
||||
await assert.rejects(missing.getEvent('other'), /unavailable \(404\)/);
|
||||
});
|
||||
|
||||
test('the flood tool lists the bundled evidence in story order', async () => {
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { events: { getEvent: async () => bundled } },
|
||||
});
|
||||
const all = await catalog.call('get_bhote_koshi_flood', {});
|
||||
assert.match(
|
||||
all.summary,
|
||||
/^Bhote Koshi Outburst Flood, observed 2026-08-26: 16 evidence records along a \d+(\.\d)? km mapped flood path\.$/,
|
||||
);
|
||||
assert.deepEqual(
|
||||
all.data.evidence.map((row) => row.sequence),
|
||||
Array.from({ length: 16 }, (_, index) => index + 1),
|
||||
);
|
||||
assert.deepEqual(all.data.evidence[3], {
|
||||
sequence: 4,
|
||||
id: 'gyirong-border-gate',
|
||||
title: 'Gyirong border gate',
|
||||
summary: 'Public footage geolocated at the Nepal-China border crossing.',
|
||||
phase: 'border arrival',
|
||||
lat: 28.28083,
|
||||
lon: 85.37802,
|
||||
location_confidence: 'geolocated',
|
||||
time_confidence: 'unverified',
|
||||
captured_at: null,
|
||||
in_imagery: true,
|
||||
source_url: 'https://www.youtube.com/watch?v=qlE51Eu0thk',
|
||||
platform: 'YouTube',
|
||||
via: 'GeoGeorgeShadrach public geolocation map; independently consistent with the linked GeoConfirmed witness',
|
||||
});
|
||||
assert.deepEqual(all.data.flood_path.start, { lat: 28.33255, lon: 85.48476 });
|
||||
assert.ok(all.data.flood_path.length_km > 50);
|
||||
assert.equal(all.data.imagery.after.observed_at, '2026-08-27');
|
||||
assert.match(all.data.caveat, /not an official hazard model/);
|
||||
assert.match(all.data.attribution, /CC BY-NC 4\.0/);
|
||||
const downstream = await catalog.call('get_bhote_koshi_flood', {
|
||||
phase: 'Downstream passage',
|
||||
});
|
||||
assert.ok(
|
||||
downstream.data.evidence.every((row) => row.phase === 'downstream passage'),
|
||||
);
|
||||
assert.match(downstream.summary, /in the Downstream passage phase/);
|
||||
});
|
||||
Loaded 100 of 136 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user