diff --git a/.env.example b/.env.example index 67a1e74a7..7a802927c 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/CHANGELOG.md b/CHANGELOG.md index 58103d04d..44d69e98c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index f6a3ae886..13ff96215 100644 --- a/README.md +++ b/README.md @@ -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.) diff --git a/build/embed-framing.js b/build/embed-framing.js new file mode 100644 index 000000000..f3b53d5dc --- /dev/null +++ b/build/embed-framing.js @@ -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, + }; +} diff --git a/build/panel.js b/build/panel.js new file mode 100644 index 000000000..e10d0bbca --- /dev/null +++ b/build/panel.js @@ -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`; +} diff --git a/build/vite.js b/build/vite.js index cdff4a27c..bd18aece9 100644 --- a/build/vite.js +++ b/build/vite.js @@ -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'", diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 22ce1eb3b..3731726f1 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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 diff --git a/docs/MCP_SETUP.md b/docs/MCP_SETUP.md new file mode 100644 index 000000000..ec11d923b --- /dev/null +++ b/docs/MCP_SETUP.md @@ -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. diff --git a/docs/TOOLS.md b/docs/TOOLS.md new file mode 100644 index 000000000..4ed69db69 --- /dev/null +++ b/docs/TOOLS.md @@ -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 | diff --git a/package.json b/package.json index e48881c6d..dda83c1ec 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/public/events/bhote-koshi-2026/README.md b/public/events/bhote-koshi-2026/README.md index f8d3657e7..a0160eab9 100644 --- a/public/events/bhote-koshi-2026/README.md +++ b/public/events/bhote-koshi-2026/README.md @@ -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: diff --git a/scripts/build-panel.mjs b/scripts/build-panel.mjs new file mode 100644 index 000000000..ce98bfb3b --- /dev/null +++ b/scripts/build-panel.mjs @@ -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}`); diff --git a/scripts/check-import-directions.mjs b/scripts/check-import-directions.mjs index a5e61f039..ac1a3f732 100644 --- a/scripts/check-import-directions.mjs +++ b/scripts/check-import-directions.mjs @@ -37,6 +37,10 @@ const portableExport = (key) => ) || [ './director', + './tools', + './tools/mcp', + './tools/services', + './view', './voice/action-schemas', './voice/session', './data/lifecycle', diff --git a/scripts/format-scope.json b/scripts/format-scope.json index cd836a5cb..a0a7820a9 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 276b9e0db..a880f5596 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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": [] } } diff --git a/server/mcp/plugin.js b/server/mcp/plugin.js new file mode 100644 index 000000000..c1d3c6c94 --- /dev/null +++ b/server/mcp/plugin.js @@ -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); + }); +} diff --git a/server/mcp/server.js b/server/mcp/server.js new file mode 100644 index 000000000..a2acdba27 --- /dev/null +++ b/server/mcp/server.js @@ -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 })], + }); +} diff --git a/server/mcp/services.js b/server/mcp/services.js new file mode 100644 index 000000000..513bc146f --- /dev/null +++ b/server/mcp/services.js @@ -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, + }); +} diff --git a/server/mcp/stdio.js b/server/mcp/stdio.js new file mode 100644 index 000000000..c89407be3 --- /dev/null +++ b/server/mcp/stdio.js @@ -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 ` 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; + } +} diff --git a/server/providers/aircraft/opensky.js b/server/providers/aircraft/opensky.js index 612d01e11..db4920668 100644 --- a/server/providers/aircraft/opensky.js +++ b/server/providers/aircraft/opensky.js @@ -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} 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 && diff --git a/server/providers/cctv.js b/server/providers/cctv.js index a71e2af4d..9cf3b0c47 100644 --- a/server/providers/cctv.js +++ b/server/providers/cctv.js @@ -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, diff --git a/server/providers/cctv/catalog.js b/server/providers/cctv/catalog.js index ae0ab9afa..7e9be7ac9 100644 --- a/server/providers/cctv/catalog.js +++ b/server/providers/cctv/catalog.js @@ -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>|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; } diff --git a/server/providers/cctv/normalize.js b/server/providers/cctv/normalize.js index b564ef440..9e183b23d 100644 --- a/server/providers/cctv/normalize.js +++ b/server/providers/cctv/normalize.js @@ -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; } /** diff --git a/server/providers/local.js b/server/providers/local.js index 398519d92..fe6ee2e5a 100644 --- a/server/providers/local.js +++ b/server/providers/local.js @@ -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(), diff --git a/server/providers/openai/instructions.js b/server/providers/openai/instructions.js index fa957ac7c..5658f9ece 100644 --- a/server/providers/openai/instructions.js +++ b/server/providers/openai/instructions.js @@ -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 " 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.', diff --git a/server/providers/openai/realtime.js b/server/providers/openai/realtime.js index b4dfb4b5a..85539400f 100644 --- a/server/providers/openai/realtime.js +++ b/server/providers/openai/realtime.js @@ -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', }, }; diff --git a/server/providers/openai/tools.js b/server/providers/openai/tools.js index a1bbc4745..fdbb14cd8 100644 --- a/server/providers/openai/tools.js +++ b/server/providers/openai/tools.js @@ -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)), + ]; +} diff --git a/server/standalone/vite.config.js b/server/standalone/vite.config.js index ffa5a1e27..8cb3679ef 100644 --- a/server/standalone/vite.config.js +++ b/server/standalone/vite.config.js @@ -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, diff --git a/server/standalone/voiceTools.js b/server/standalone/voiceTools.js new file mode 100644 index 000000000..35dc47440 --- /dev/null +++ b/server/standalone/voiceTools.js @@ -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')), + ); +} diff --git a/src/app/embed.js b/src/app/embed.js new file mode 100644 index 000000000..f88568094 --- /dev/null +++ b/src/app/embed.js @@ -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); + }; +} diff --git a/src/app/embed.test.mjs b/src/app/embed.test.mjs new file mode 100644 index 000000000..acbf87baa --- /dev/null +++ b/src/app/embed.test.mjs @@ -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/, + ); +}); diff --git a/src/app/globePanelRuntime.js b/src/app/globePanelRuntime.js new file mode 100644 index 000000000..33f807af3 --- /dev/null +++ b/src/app/globePanelRuntime.js @@ -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}` : '.'), + ), + ); +} diff --git a/src/app/tools.js b/src/app/tools.js index 613add71c..25d763e2d 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -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 }; } diff --git a/src/data/aisLiveVessels.js b/src/data/aisLiveVessels.js index 348ca26c3..b3800db68 100644 --- a/src/data/aisLiveVessels.js +++ b/src/data/aisLiveVessels.js @@ -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, diff --git a/src/data/cctvCatalogCap.test.mjs b/src/data/cctvCatalogCap.test.mjs index 6f1ff6c59..9d93778db 100644 --- a/src/data/cctvCatalogCap.test.mjs +++ b/src/data/cctvCatalogCap.test.mjs @@ -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 }); +}); diff --git a/src/data/infrastructure.js b/src/data/infrastructure.js index 59cc09893..ea78494c8 100644 --- a/src/data/infrastructure.js +++ b/src/data/infrastructure.js @@ -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. diff --git a/src/data/infrastructure.test.mjs b/src/data/infrastructure.test.mjs index 2baf5e580..45fb3d317 100644 --- a/src/data/infrastructure.test.mjs +++ b/src/data/infrastructure.test.mjs @@ -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', diff --git a/src/data/layerState.js b/src/data/layerState.js index eccc555f6..df67bee9f 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -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; - } -} diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index 1b504a439..b7ec02174 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -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'; diff --git a/src/data/layerStateCoordinator.js b/src/data/layerStateCoordinator.js new file mode 100644 index 000000000..86b6af8e7 --- /dev/null +++ b/src/data/layerStateCoordinator.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; + } +} diff --git a/src/data/localGeojsonCore.js b/src/data/localGeojsonCore.js index 834361c8c..1d04fa46c 100644 --- a/src/data/localGeojsonCore.js +++ b/src/data/localGeojsonCore.js @@ -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 }; diff --git a/src/data/militaryNames.js b/src/data/militaryNames.js index 8e9e9a5c2..3f0371bd9 100644 --- a/src/data/militaryNames.js +++ b/src/data/militaryNames.js @@ -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); } diff --git a/src/data/militaryNames.test.mjs b/src/data/militaryNames.test.mjs index bdc4449c8..7e52b2c60 100644 --- a/src/data/militaryNames.test.mjs +++ b/src/data/militaryNames.test.mjs @@ -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); +}); diff --git a/src/firstRunExperience.js b/src/firstRunExperience.js index 6dffb7c5e..60bd5d2a8 100644 --- a/src/firstRunExperience.js +++ b/src/firstRunExperience.js @@ -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') diff --git a/src/keySetup.js b/src/keySetup.js index ee1d3b2dd..d66d23867 100644 --- a/src/keySetup.js +++ b/src/keySetup.js @@ -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. } diff --git a/src/layers/aircraft/index.js b/src/layers/aircraft/index.js index b9b7315f7..e5f048164 100644 --- a/src/layers/aircraft/index.js +++ b/src/layers/aircraft/index.js @@ -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'; diff --git a/src/layers/bikeshare/model.js b/src/layers/bikeshare/model.js index 89628f613..f12148dba 100644 --- a/src/layers/bikeshare/model.js +++ b/src/layers/bikeshare/model.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} 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} 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, diff --git a/src/layers/cctv/source.js b/src/layers/cctv/source.js index bf4363128..0dddce1b4 100644 --- a/src/layers/cctv/source.js +++ b/src/layers/cctv/source.js @@ -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 }; + }, }; } diff --git a/src/layers/cctv/source.test.mjs b/src/layers/cctv/source.test.mjs index f0e3b0cc3..0103c03de 100644 --- a/src/layers/cctv/source.test.mjs +++ b/src/layers/cctv/source.test.mjs @@ -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/); +}); diff --git a/src/layers/installations/source.js b/src/layers/installations/source.js index 7cad3fca0..fed73e87a 100644 --- a/src/layers/installations/source.js +++ b/src/layers/installations/source.js @@ -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( diff --git a/src/layers/launches/source.js b/src/layers/launches/source.js index 39a49e36c..2779e7d81 100644 --- a/src/layers/launches/source.js +++ b/src/layers/launches/source.js @@ -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(); diff --git a/src/layers/satellites/orbits.js b/src/layers/satellites/orbits.js index ed26a8883..32bb57cf9 100644 --- a/src/layers/satellites/orbits.js +++ b/src/layers/satellites/orbits.js @@ -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. diff --git a/src/layers/satellites/source.js b/src/layers/satellites/source.js index 8bcab7b02..e900d34b0 100644 --- a/src/layers/satellites/source.js +++ b/src/layers/satellites/source.js @@ -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 }; }, }; } diff --git a/src/layers/traffic/flowSource.js b/src/layers/traffic/flowSource.js index 18d0b9863..49567a9ee 100644 --- a/src/layers/traffic/flowSource.js +++ b/src/layers/traffic/flowSource.js @@ -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(), diff --git a/src/layers/transit/source.js b/src/layers/transit/source.js index 6ccdb9e55..702b5ed00 100644 --- a/src/layers/transit/source.js +++ b/src/layers/transit/source.js @@ -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); diff --git a/src/layers/transit/source.test.mjs b/src/layers/transit/source.test.mjs new file mode 100644 index 000000000..88d66664a --- /dev/null +++ b/src/layers/transit/source.test.mjs @@ -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', + ); +}); diff --git a/src/layers/weather/source.js b/src/layers/weather/source.js index ec92cd5a2..65bcd4a51 100644 --- a/src/layers/weather/source.js +++ b/src/layers/weather/source.js @@ -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 }; + }, }; } diff --git a/src/layers/weather/source.test.mjs b/src/layers/weather/source.test.mjs index 8913d462e..83ab24e76 100644 --- a/src/layers/weather/source.test.mjs +++ b/src/layers/weather/source.test.mjs @@ -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/, + ); +}); diff --git a/src/reasonableDefaults.test.mjs b/src/reasonableDefaults.test.mjs index 4772630aa..dfd951377 100644 --- a/src/reasonableDefaults.test.mjs +++ b/src/reasonableDefaults.test.mjs @@ -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() {} }, diff --git a/src/sharelink.celestial.test.mjs b/src/sharelink.celestial.test.mjs index cc2672785..567bb09dd 100644 --- a/src/sharelink.celestial.test.mjs +++ b/src/sharelink.celestial.test.mjs @@ -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 = { diff --git a/src/sharelink.js b/src/sharelink.js index 08360932b..36862dcbf 100644 --- a/src/sharelink.js +++ b/src/sharelink.js @@ -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. */ diff --git a/src/sources/eventPacks.js b/src/sources/eventPacks.js new file mode 100644 index 000000000..53a2c3776 --- /dev/null +++ b/src/sources/eventPacks.js @@ -0,0 +1,24 @@ +/** Event packs the app serves under `/events//`, 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); + }, + }; +} diff --git a/src/sources/gbfsStations.js b/src/sources/gbfsStations.js new file mode 100644 index 000000000..9c5b93528 --- /dev/null +++ b/src/sources/gbfsStations.js @@ -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} 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} 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; +} diff --git a/src/sources/infrastructureData.js b/src/sources/infrastructureData.js new file mode 100644 index 000000000..6936b9aa5 --- /dev/null +++ b/src/sources/infrastructureData.js @@ -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); + }, + }; +} diff --git a/src/sources/tle.js b/src/sources/tle.js new file mode 100644 index 000000000..423651e6b --- /dev/null +++ b/src/sources/tle.js @@ -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; +} diff --git a/src/standalone/layerSources.js b/src/standalone/layerSources.js index 1856dfb69..c84595483 100644 --- a/src/standalone/layerSources.js +++ b/src/standalone/layerSources.js @@ -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(), diff --git a/src/standalone/toolCatalog.js b/src/standalone/toolCatalog.js new file mode 100644 index 000000000..4d697d9ed --- /dev/null +++ b/src/standalone/toolCatalog.js @@ -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; +} diff --git a/src/standalone/tools.js b/src/standalone/tools.js index 36c273cd9..fb127d598 100644 --- a/src/standalone/tools.js +++ b/src/standalone/tools.js @@ -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 }, }); } diff --git a/src/tooling/liveProviders.test.mjs b/src/tooling/liveProviders.test.mjs index 38953eff9..ae136b994 100644 --- a/src/tooling/liveProviders.test.mjs +++ b/src/tooling/liveProviders.test.mjs @@ -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; diff --git a/src/tooling/localServices.test.mjs b/src/tooling/localServices.test.mjs index 26eebfa08..61f25271a 100644 --- a/src/tooling/localServices.test.mjs +++ b/src/tooling/localServices.test.mjs @@ -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"\}/); +}); diff --git a/src/tooling/panelBuild.test.mjs b/src/tooling/panelBuild.test.mjs new file mode 100644 index 000000000..0b130167e --- /dev/null +++ b/src/tooling/panelBuild.test.mjs @@ -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'), '

panel

'); + 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), '

panel

'); + 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; + } +}); diff --git a/src/tooling/viteBuild.test.mjs b/src/tooling/viteBuild.test.mjs index 497035a40..a7c79e53f 100644 --- a/src/tooling/viteBuild.test.mjs +++ b/src/tooling/viteBuild.test.mjs @@ -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'", + }); +}); diff --git a/src/tools/area.js b/src/tools/area.js new file mode 100644 index 000000000..4af62a03c --- /dev/null +++ b/src/tools/area.js @@ -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)) + ); + }); +} diff --git a/src/tools/area.test.mjs b/src/tools/area.test.mjs new file mode 100644 index 000000000..7ec551aae --- /dev/null +++ b/src/tools/area.test.mjs @@ -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); +}); diff --git a/src/tools/catalog.js b/src/tools/catalog.js new file mode 100644 index 000000000..4d5106b6e --- /dev/null +++ b/src/tools/catalog.js @@ -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); + }, + }); +} diff --git a/src/tools/catalog.test.mjs b/src/tools/catalog.test.mjs new file mode 100644 index 000000000..cdd492d16 --- /dev/null +++ b/src/tools/catalog.test.mjs @@ -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/, + ); +}); diff --git a/src/tools/functions.js b/src/tools/functions.js new file mode 100644 index 000000000..ef5cdbf0e --- /dev/null +++ b/src/tools/functions.js @@ -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 } : {}), + }; +} diff --git a/src/tools/functions.test.mjs b/src/tools/functions.test.mjs new file mode 100644 index 000000000..439d7aeef --- /dev/null +++ b/src/tools/functions.test.mjs @@ -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, + }, + ); +}); diff --git a/src/tools/globePanel.js b/src/tools/globePanel.js new file mode 100644 index 000000000..abc8213bc --- /dev/null +++ b/src/tools/globePanel.js @@ -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 ` + + + +God's Eye View + + + +
Waiting for a view…
+
+ + +
+ + + +`; +} + +/** + * 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, + }, + }, + }); +} diff --git a/src/tools/globePanel.test.mjs b/src/tools/globePanel.test.mjs new file mode 100644 index 000000000..51270d884 --- /dev/null +++ b/src/tools/globePanel.test.mjs @@ -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, /