Compare commits

...
Author SHA1 Message Date
Sameh Khamis b1a958242c Merge pull request #897 from bilawalsidhu/release/v0.2.0
chore(release): prepare v0.2.0
2026-10-02 19:00:29 -07:00
Sameh Khamis b86239aa6d chore(release): prepare v0.2.0
Group the changes since v0.1.1 under a dated 0.2.0 section and align the
package metadata.
2026-10-02 18:47:29 -07:00
Sameh Khamis 74213d18da Merge pull request #896 from bilawalsidhu/feat/tool-catalog
Add a shared tool catalog and MCP server, with God's Eye View inside Claude and ChatGPT
2026-10-02 18:45:08 -07:00
Sameh Khamis a55d260551 docs: drop the server check from the MCP setup 2026-10-02 18:29:42 -07:00
Sameh Khamis bcbc350354 docs: add a one-page MCP setup for Claude and Codex
Steps people and coding agents can follow to run the app, add the MCP
server to Claude Code, Claude Desktop, the Codex CLI and the Codex and
ChatGPT desktop apps, and check it works, linked from the README and the
tools guide.
2026-10-02 18:29:42 -07:00
Sameh Khamis b69d34bad2 Frame a view that only follows an aircraft where the aircraft is
Asked for an aircraft's cockpit view, assistants passed only the follow,
which failed for want of an area or camera, so every such request took
a second call. show_in_gods_eye_view now looks the aircraft up and frames
the view on it, and says so when the aircraft is not reported.
2026-10-02 18:29:42 -07:00
Sameh Khamis ae00f009e6 Log why a tool call failed in the stdio server's log
A failed call's reason went only to the client, so client logs showed a
failure without its cause.
2026-10-02 18:29:42 -07:00
Sameh Khamis e82ab7440d Run the worker prelude ahead of Cesium's own workers script
Cesium's script sets CESIUM_WORKERS itself when it loads, so the panel's
combined workers script was never used, and neither was the embedded
terrain-heights file: the worker still requested it from the panel
page's site. The panel build now writes only the prelude, and the panel
puts it ahead of whatever Cesium assigns to CESIUM_WORKERS. The separate
esbuild bundle of Cesium's workers is gone.
2026-10-02 18:29:42 -07:00
Sameh Khamis 9e5ef78f91 Embed the files Cesium's workers load in the panel's workers script
The ground-polyline worker loads approximateTerrainHeights.json itself,
and a worker's requests go to the panel page's own site, where they
failed with 404 and stopped rendering once ground-clamped lines such as
military installations appeared. The panel build now embeds that file in
the workers script, which answers the worker's request from memory.
2026-10-02 18:29:42 -07:00
Sameh Khamis d4311a9d54 Let a view show an aircraft's cockpit view
A view's follow may ask for cockpit view, which the app enters after it
follows the aircraft; links cannot carry it and open the app following.
Applying a view no longer flies the camera when the followed entity is
already there, since the flight ended the follow; it flies only while
waiting for the entity. The panel applies its first view again only
when it asks for cockpit view.
2026-10-02 18:29:42 -07:00
Sameh Khamis 89a448ee83 Do not apply a panel's first view twice
The panel starts the app from the first view's link, which the app
restores, following included. Sending the same view again once the app
was ready flew the camera to it, which ended the follow and left cockpit
view. The panel now sends only views that arrive while the app loads.
2026-10-02 18:29:42 -07:00
Sameh Khamis 9bc031c753 docs: describe God's Eye View in conversations in the changelog and current state 2026-10-02 18:29:42 -07:00
Sameh Khamis abca609708 Keep panel requests on the app's server and bounded
panel_request resolved paths with a check for a leading //, but URL
parsing also reads /\host as another host, so it could request other
servers. It now resolves the path against the app's address, refuses
any other origin, matches refused routes decoded as well, and does not
follow redirects. Responses are capped at 64 MiB while reading, with a
deadline; held responses make room on arrival and expire by time only,
so a response being read keeps its parts.

The panel keeps converted images only for its build files, so live
camera frames no longer accumulate, and failed build loads are retried.
The dev server answers a malformed /panel/ path with 400 instead of
crashing.
2026-10-02 18:29:42 -07:00
Sameh Khamis efdee8d255 Clean up the panel after host testing
Drop the render restarts added while chasing the black overlays; keep
the full render-error report and stop the timer after an error, as
Cesium does. Correct comments the investigation outran, share the
panel_request name, and document the host differences the panel works
around.
2026-10-02 18:29:42 -07:00
Sameh Khamis d0da4b0722 Keep the panel's 2D canvases in memory
In Claude, the app's 2D overlay canvases covered the globe in black
while the host treated the panel as off screen, and the globe showed
once they were hidden. The panel now asks for 2D canvases backed by
memory rather than the GPU.
2026-10-02 18:29:42 -07:00
Sameh Khamis dbbdef0f78 Offer fullscreen in the panel, and hold images in inserted HTML
Where the host offers fullscreen, an Expand button asks for it; the page
fills the room it is given. Images in markup the app inserts as HTML
text now wait for their files instead of requesting the panel host's
site, which ended the mic.svg and Cesium credit 404s.
2026-10-02 18:29:42 -07:00
Sameh Khamis 5ad0dd0004 Report a panel's render errors in full and restart drawing
Cesium stops drawing after a render error and prints worker failures as
[object Object]. An inline panel now logs the error's details and starts
drawing again, up to three times.
2026-10-02 18:29:42 -07:00
Sameh Khamis 47742f335d Keep an inline panel drawing when its host reports it hidden
Claude reports a panel on screen as hidden, which stopped the app's
render loop and the browser's animation frames, so no map was drawn. An
inline panel no longer suspends rendering when hidden, and draws from a
timer while animation frames stop arriving.
2026-10-02 18:29:42 -07:00
Sameh Khamis 62468c547f Fit the panel to Codex's and Claude's panel rules
Images and stylesheet files load as data: URLs, which hosts allow where
some refuse blob: images; the scene-asset source gets an https app
address the panel serves, since Codex pages use their own scheme; and a
panel whose 3D context the browser takes back says so.
2026-10-02 18:29:42 -07:00
Sameh Khamis 996b95eb45 Load the God's Eye View panel through the MCP server
Hosts serve panels from their own sites and may refuse other addresses;
Codex refuses any address on the user's machine. The panel now never
requests the app's server: it loads the app's panel build and data
through panel_request, a tool only the panel may call, which requests
the path from the app's server. One path in every host; only map
providers load directly.

npm run build:panel writes the panel build to dist/panel, served at
/panel/: one app script, one stylesheet, Cesium's script, and Cesium's
workers as one script Cesium runs from memory. Tools may now be marked
app-only. The panel CORS rules are gone.
2026-10-02 18:29:42 -07:00
Sameh Khamis b11d54d8b8 Support panel hosts that serve the page from their own scheme
Codex serves panels from codex-sandbox:// pages, where origins are
opaque: the panel recognizes the app's own addresses by scheme and host,
the vessels feed resolves against the document address, and the dev
server answers Codex's sandbox origins with CORS.
2026-10-02 15:21:37 -07:00
Sameh Khamis 64c1b1951f Answer panel origins only through the CORS plugin 2026-10-02 15:21:37 -07:00
Sameh Khamis 282efac862 Start the panel's app at the first view instead of the default place 2026-10-02 15:21:37 -07:00
Sameh Khamis 5b555d7708 Load images the panel redirects to the app's server with CORS
The app treats those images as its own site's and loads them without
CORS, so once they come from the app's server WebGL refuses them and
Cesium stops rendering.
2026-10-02 15:21:37 -07:00
Sameh Khamis 06460020d0 Send the panel's app requests to the app's server without <base>
Claude refuses <base> in panels, so relative addresses reached the
host's site: styles, Cesium assets and API calls all failed. The panel
now points fetch, XHR, beacons, images, media, links, workers and added
style url()s at the app's server, and declares its policy under OpenAI's
widget key as well.
2026-10-02 15:21:37 -07:00
Sameh Khamis d9ecd126c8 Let the app run under a panel page's document base
A panel page loads the app with <base> pointing at the app's server, so
addresses the app builds resolve against the document base instead of
the page location, and hash updates stay on the page's own address.
The dev server answers panel origins with CORS on the API routes too.
2026-10-02 15:21:37 -07:00
Sameh Khamis 63341b947e Run God's Eye View inside the MCP Apps panel instead of framing it
Hosts such as Claude do not let a panel frame other sites, so the panel
now loads the app's page, styles and scripts into itself in inline embed
mode, and declares the app and map providers as resource and connect
domains. The dev server answers panel origins with CORS.
2026-10-02 15:21:37 -07:00
Sameh Khamis cb04fd5b19 feat: say in the panel what a host's security policy blocked
When a host's content security policy refuses part of the panel, such as
its frame, the panel now names the blocked directive and address instead of
waiting until its load timeout.
2026-10-02 15:21:37 -07:00
Sameh Khamis d368df533d fix: complete the MCP Apps handshake the way hosts validate it
The panel sent clientInfo and capabilities in ui/initialize, where the MCP
Apps schema requires appInfo, so hosts such as Claude rejected the handshake
and never showed the panel. It now sends exactly appCapabilities, appInfo and
protocolVersion, sends initialized without parameters, says so when a host
rejects it, and offers the link when God's Eye View does not load within 20
seconds. Checked against the MCP Apps SDK's own host bridge.
2026-10-02 15:21:37 -07:00
Sameh Khamis ba751d9433 fix: also name a tool's UI resource with the flat ui/resourceUri key
Some MCP Apps hosts read the earlier flat _meta key; the MCP Apps SDK sends
it alongside _meta.ui.resourceUri, and so does the adapter now.
2026-10-02 15:21:37 -07:00
Sameh Khamis 5775eb9053 feat: log local MCP requests to stderr by method
The stdio server writes each request's method, and the tool or resource it
names, to stderr, which clients such as Claude Desktop copy into their logs.
Arguments and results are never logged.
2026-10-02 15:21:36 -07:00
Sameh Khamis 47ef168815 refactor: name the view tool show_in_gods_eye_view
The tool and its panel now say "Show in God's Eye View" rather than
"show on the globe", in tool names, descriptions, server instructions and
docs.
2026-10-02 15:21:36 -07:00
Sameh Khamis 216a75ac71 feat: show views as the live globe in MCP Apps clients
show_on_globe replaces open_in_gods_eye_view and names an MCP Apps view, a
ui:// panel that loads the app in embed mode and posts each later view to it,
so the globe in the conversation changes without reloading. It also takes a
view another answer returned. Tools can declare a UI resource, and the MCP
adapter serves resources/list and resources/read and lists each tool's
resource in _meta.ui.resourceUri.
2026-10-02 15:21:36 -07:00
Sameh Khamis 60c40865e9 feat: add an embed mode that shows only the globe
?embed=1 shows the globe alone: clean view with the HUD, panels, welcome and
setup prompts hidden, and provider attribution kept. The page that frames it
sends views with postMessage; the app applies each through its own actions
and reports every step. Annotations in any link are drawn once it restores.
Only embed-mode documents may be framed; every other page keeps
X-Frame-Options DENY and frame-ancestors 'none', and
GEV_EMBED_FRAME_ANCESTORS can restrict who frames them.
2026-10-02 15:21:36 -07:00
Sameh Khamis f68aa988ec feat: carry annotations in views and aim tilted views at the area
A view can carry the marks the app's annotate_map action draws, and links
carry them in the an parameter, bounded in count and size. A tilted view of
an area now places the camera behind its center so the area stays in the
middle of the frame.
2026-10-02 15:21:36 -07:00
Sameh Khamis af8856cedf feat: lead the MCP tool list with what the globe shows
MCP no longer lists place search, routing, plain weather and wind, the
regional brief, the HUD caption, radio, bike share and transit. Assistants
already cover these or they add little without the globe; voice keeps them,
and tools that combine others still reach them.
2026-10-02 15:21:36 -07:00
Sameh Khamis d6473b6049 feat: return the view that shows each answer
Tool answers with something to show now include data.view: the view with
the matching layers on, the area framed from above or a single aircraft or
satellite followed, and the link that opens it. Area framing moves to
src/tools/views.js so links and answers frame alike.
2026-10-02 15:21:36 -07:00
Sameh Khamis 96a8a9addb feat: describe what the app shows as a view
A view is a camera, data layers, visual style, map imagery and an aircraft,
military aircraft or satellite to follow, written in the share-link format
the app already restores. gods-eye-view/view builds, encodes and decodes one,
and share links take their style names from it. open_in_gods_eye_view now
builds its link from a full view: an area or camera, layers, style, map and
something to follow.
2026-10-02 15:21:36 -07:00
Sameh Khamis de1e0836b3 fix: send "vessels" to the ships layer in voice
Voice hears audio directly, and "vessels" is easily heard as "visuals",
which the instructions route to visual styles, so asking for vessels turned
on other layers or a CRT style. The instructions now say a vessels request
without a style name means the ships layer, and a style needs its name.
2026-10-02 15:21:36 -07:00
Sameh Khamis 759d05db4e fix: name only tools voice offers in its instructions
The voice instructions recommended aircraft_in_area, vessels_in_area and
get_earthquakes, which voice no longer offers, and the docs said voice gets
nearly every query. The instructions now name tools voice has and send
aircraft, ship and hazard questions to analyst_query; a test keeps the
instructions to tools the voice session lists.
2026-10-02 15:21:36 -07:00
Sameh Khamis 33445aec73 fix: keep each aircraft feed's freshness in search answers
find_aircraft merged the records of the feeds that answered and dropped their
freshness, so a stale OpenSky snapshot read as a current "not reported".
Answers now list each feed with its freshness and coverage and say which
feed's data may be stale.
2026-10-02 15:21:36 -07:00
Sameh Khamis 41d1352930 fix: search every vessel the server retains
Vessel tools read the source's default of the newest 12,000 vessels while
the server keeps up to 50,000, so a lookup could miss a vessel still held.
They now ask for more than any server keeps and receive everything it has.
2026-10-02 15:21:36 -07:00
Sameh Khamis ad9a3976a9 fix: say the camera catalog has no cameras rather than none exist
An empty camera answer outside the catalog's regions read as "0 public
cameras", as if the area had none. It now says the catalog has no cameras
there.
2026-10-02 15:21:36 -07:00
Sameh Khamis f610de77dc fix: judge camera catalog trimming by region, not by returned cameras
Coverage was checked only for packs among the cameras found, so an area
whose cameras were all trimmed away answered zero cameras, complete. The
catalog now reports where each trimmed pack's cameras are, and the tool
marks an answer partial whenever its area overlaps that region.
2026-10-02 15:21:36 -07:00
Sameh Khamis 7f56679a46 fix: show the whole weather map when a window cannot cover the area
The map window was moved inside the product's bounds without checking that
it still covered the requested area, so an area across the antimeridian got
an image of only one side. A window must now hold the whole area, clipped to
the product, or the tool returns the product's full extent.
2026-10-02 15:21:36 -07:00
Sameh Khamis 4cfca57cfb fix: bound large boxes by their farthest point on the sphere
A box's radius was the distance to its farthest corner, which falls short for
large boxes: a whole-world box measured about 10,000 km, so bike-share and
transit selection never considered systems beyond it. The radius now checks
whether the box holds the point opposite its center and otherwise samples
every edge.
2026-10-02 15:21:36 -07:00
Sameh Khamis e1ac5ac270 fix: cancel shared place lookups nobody is waiting for
The place cache shared one lookup between callers but started it without a
signal or deadline, so a cancelled request kept running and stayed cached.
A shared lookup now has its own deadline, is cancelled when every caller has
given up, and only a successful answer is kept.
2026-10-02 15:21:36 -07:00
Sameh Khamis 66b07418bd fix: say when the camera catalog serves only part of a region
Camera packs keep their nearest cameras and the catalog cap thins them
further, so London answered 250 cameras while TfL offers many more. The
catalog now records each pack's full count, tags cameras with their pack,
and serves the packs it trimmed; find_cctv_cameras names them.
2026-10-02 15:21:36 -07:00
Sameh Khamis e779432925 fix: report stale and partial data in more tool answers
A sweep for the same pattern found more answers presented as current and
complete: launches and satellite orbits served from a stale proxy cache, a
regional aircraft fallback read as worldwide, license plate cameras trimmed
or partly loaded, and a radio directory of popular stations read as every
station. The launch and satellite sources now report the proxies' stale
markers, and each answer says what it covers.
2026-10-02 15:21:36 -07:00
Sameh Khamis 6a45a8f6a2 fix: check only own properties when validating tool arguments
Inherited names such as constructor, toString and __proto__ passed as
declared properties despite additionalProperties: false, and a schema-valued
additionalProperties was accepted but never enforced. Validation now uses own
properties, and definitions must give additionalProperties as a boolean.
2026-10-02 15:21:36 -07:00
Sameh Khamis c4a4247f70 fix: cancel a local MCP tool call when its client disconnects
The /mcp route built its Request without a signal, so a tool kept working
after the client left. The request's signal now aborts when the connection
closes before the answer is sent.
2026-10-02 15:21:36 -07:00
Sameh Khamis 13f8fa7c8e fix: wait briefly for installation names before answering
Installation tile results can arrive before the name pack, and the tool
answered with generic names such as "Military area" where a moment later
it would say "Camp Mabry". It now waits up to five seconds for the named
result and says when names are still loading.
2026-10-02 15:21:36 -07:00
Sameh Khamis 9e4cc9095b fix: measure traffic inside the requested circle and report missing tiles
Traffic figures for a radius area counted roads anywhere in its bounding
box, and a tile that failed to load silently changed them. Roads are now cut
to the circle, and the flow source reports per request whether any tile
failed so the answer can say its figures are partial.
2026-10-02 15:21:36 -07:00
Sameh Khamis 302a1c1995 fix: say when bike share, transit and place limits leave results out
Bike share and transit read only the three nearest systems, and place
search returns at most five matches within 50 km, while answers read as
complete. Answers now name the systems not searched and say when a place
search limit may have left places out.
2026-10-02 15:21:36 -07:00
Sameh Khamis 56f0d2e203 fix: carry weather and fire freshness into tool answers
get_weather dropped the proxy's stale status and get_active_fires dropped
FIRMS staleness and failed satellite sources, so answers and the HUD caption
read them as current. Both now report them, and the caption context marks
such sections stale or degraded.
2026-10-02 15:21:36 -07:00
Sameh Khamis dd8ba562d2 fix: find fire perimeters that overlap an area
Fire perimeters were placed at their average vertex, so a fire enclosing the
whole requested area was missed. Polygons now count when a vertex is inside
the area, an edge crosses it, or the polygon contains it outside any hole.
2026-10-02 15:21:36 -07:00
Sameh Khamis 4af15aa6a9 fix: keep a named place's own point in situation briefs
Brief sections received a named place as its bounding box, so they centered
on the box's middle: Tokyo's aircraft were sorted from a point in the
Pacific. Sections now receive the place name and resolve it like a direct
query, and place lookups are cached briefly so a brief resolves it once.
2026-10-02 15:21:36 -07:00
Sameh Khamis 1b3aa8de8b fix: stop reading MCP request bodies past the size limit
The HTTP handler read the whole body before checking its size, so a body
without Content-Length could be buffered without bound. It now reads the
stream and stops as soon as the body passes the limit.
2026-10-02 15:21:36 -07:00
Sameh Khamis 6f56c84b0b fix: find cables that cross an area between vertices
Cable searches only tested vertices, so a cable crossing an area with no
vertex inside it was missed. Lines now test each segment against the area's
bounds, across the antimeridian too, and against a radius area's great-circle
distance.
2026-10-02 15:21:36 -07:00
Sameh Khamis 5b73b70d19 fix: bound radius areas by the exact spherical cap
The longitude span divided the angular radius by the center's cosine, which
is too narrow for large radii and near the poles. Areas now use the exact
tangent bound and span every longitude when the circle reaches a pole.
2026-10-02 15:21:36 -07:00
Sameh Khamis cac5ba3b72 fix: derive HUD caption feed states from section results
The caption request declared every answered section nominal, so a stale
aircraft snapshot reached the caption as live. Each section now reports
stale or unavailable from its own result, and the provenance envelope is
built with the app's feedProvenanceEnvelope.
2026-10-02 15:21:36 -07:00
Sameh Khamis 853c60583f fix: report stale transit feeds and drop expired positions
get_transit_vehicles returned positions from a feed the proxy served from
its error cache, and hour-old fixes, as current. It now applies the transit
layer's rules: a feed is stale when the proxy says so or the operator has
not answered recently, and fixes older than the vehicle limit are dropped
and counted.
2026-10-02 15:21:36 -07:00
Sameh Khamis 61974848fe fix: keep aircraft search results when one feed is down
find_aircraft failed whenever either feed failed. It now answers from the
feeds that responded, names the ones that did not, and searches only the
military feed for registrations, the only feed that reports them.
2026-10-02 15:21:36 -07:00
Sameh Khamis 963c791a5c fix: run composite tool sections through the catalog
situation_brief, military_awareness and the HUD caption called other tools
directly, so replaced tools and interceptors did not apply to their sections.
Tools now reach each other through the catalog, and interceptors see these
calls with the calling tool as parent. Surfaces become a view over the
composed catalog, so a composite offered on voice keeps sections that voice
does not list.
2026-10-02 15:21:36 -07:00
Sameh Khamis 7ddee426e7 fix: count every mapped installation in tool answers
The installation source keeps one named site per display cell for the map.
Queries now ask for every site and the exact box, and report when the
source returned only part of the mapped sites.
2026-10-02 15:21:36 -07:00
Sameh Khamis 2e071b62d3 fix: repeat MCP tool data as JSON text for clients without structured content 2026-10-02 15:21:36 -07:00
Sameh Khamis df0e0f32e8 feat: choose per tool whether MCP, voice or both offer it
src/tools/surfaces.js lists the tools a surface leaves out, and the MCP
server, the voice session's tool list and the browser catalog voice calls
all filter through it. Voice leaves out tools that answer with images, link
to the app, or repeat what its app actions already answer.
2026-10-01 14:36:12 -07:00
Sameh Khamis b4067f9859 fix: time-limit OpenSky snapshot requests and retry once
A stalled global snapshot held the request for over a minute. Each attempt
now gets 10 seconds, a timed-out or failed attempt is retried once, and a
second failure is answered from the stale cache or the regional fallback.
The OAuth token request gets the same limit.
2026-10-01 14:26:20 -07:00
Sameh Khamis ec1adbdc19 fix: tighten tool answers found in local testing
Unknown aircraft tracks read as empty instead of a server error, aircraft
info names the aircraft and flight, the HUD caption sends the app's caption
context, and satellite pass, satellites overhead and nearby places take a
place name or point like the other tools.
2026-10-01 14:01:25 -07:00
Sameh Khamis 1a03e7483d feat: share the tool catalog with voice 2026-10-01 13:52:36 -07:00
Sameh Khamis 0953ebb026 feat: add a Bhote Koshi flood event tool 2026-10-01 13:44:41 -07:00
Sameh Khamis a747b8a4e0 feat: add a military awareness tool 2026-10-01 13:41:30 -07:00
Sameh Khamis 28cfd39d1a feat: add a datacenter and dam tool 2026-10-01 13:38:38 -07:00
Sameh Khamis a8e5ff9390 refactor: move infrastructure data parsing into a portable source module
The datacenter and dam layers parsed their bundled GeoJSON Lines and
built analyst records inside the Cesium-backed layer module. Move the
data URLs, the line parser, mapAnalystRecord and layerTitle unchanged to
src/sources/infrastructureData.js; the layer module imports them and
still exports mapAnalystRecord. The consumer-build pin lists the new
module.
2026-10-01 13:34:26 -07:00
Sameh Khamis 0a454815cb feat: add a license plate reader camera tool
Add find_alpr_cameras over the layer's OpenStreetMap camera extracts:
cameras in a US or Canadian area of at most 3° per side, nearest first,
with operator, manufacturer and facing direction, and the source's
attribution. Areas outside coverage or too wide are reported.
2026-10-01 13:31:31 -07:00
Sameh Khamis c1957d884e feat: add a submarine cable tool
Add find_submarine_cables over the bundled TeleGeography data: cables
passing through or landing in an area, or matching a name, with landing
points and the dataset's attribution. The local MCP server's fetch reads
the bundled data files a browser would load by URL, and nothing outside
that directory.
2026-10-01 13:28:02 -07:00
Sameh Khamis 9be90fff44 feat: add a recent satellite imagery tool
Add get_recent_imagery. It searches the recent Harmonized Landsat and
Sentinel-2 catalog for an area, picks the image the layer would show
with the layer's own ranking, and returns it with its date, sensor and
cloud cover, falling back to the VIIRS daily overview.
2026-10-01 13:25:00 -07:00
Sameh Khamis a3aa058569 feat: add weather map and wind tools
Add get_weather_map, which returns the latest NOAA radar, satellite or
lightning map over an area as an image, choosing a window the image
route accepts and reporting areas outside the product's coverage, and
get_wind, which samples GFS or IFS wind at a location with the wind
layer's own grid sampling.
2026-10-01 13:22:16 -07:00
Sameh Khamis c43c94ba5f feat: read weather image frames through the weather source
Add getImage() to the weather source. It reads one frame through the
bounded image route the layer already uses and returns its content type
and bytes, so code outside the layer can show the current map.
2026-10-01 13:18:01 -07:00
Sameh Khamis fe69f22346 feat: turn layers on in links to an area
open_in_gods_eye_view accepts layers and writes them with the app's own
share-link layer codec, so the link opens with those layers on. Layer
names are limited to the registered layer ids.
2026-10-01 13:17:15 -07:00
Sameh Khamis 9de2705eaf refactor: move the layer state coordinator into its own module
layerState.js held both the share-link layer codec and the coordinator
that restores layer choices into the running app and persists them to
browser storage. Move the coordinator, unchanged, to
layerStateCoordinator.js so the codec stays a pure data module that code
outside the browser can use to write share links. The registry stays in
layerState.js, where the token tooling reads it, and the aircraft
barrel re-exports both.
2026-10-01 13:16:19 -07:00
Sameh Khamis a8b5860e9f refactor: share the default tool services between surfaces
Move the composition of the default tool services from the Node MCP
server into gods-eye-view/tools/services, a portable export. Given a
fetch that resolves the sources' /api paths, it builds the same services
in a browser as on the local MCP server, so other surfaces can compose
the same catalog. Queries still read only their services.
2026-10-01 13:12:34 -07:00
Sameh Khamis 0dbd6375fc fix: center named places on their own location
A place's bounds can reach far from the place itself; Tokyo's include
remote Pacific islands, so the middle of its box is open ocean. Keep the
geocoder's point for a named place and use it as the area's center,
while bounds still decide what lies inside the area.
2026-10-01 12:50:15 -07:00
Sameh Khamis 469b1e8d89 feat: serve the MCP server at /mcp on the local servers
Mount the local MCP server at /mcp on the development and preview
servers for clients that connect by URL. The route accepts only
requests from this machine that name a loopback host and, when an
Origin is sent, come from a loopback origin, and drains oversized
bodies so the 413 response is delivered. The provider-order regression
test now pins the route between the providers and the API fallback.
2026-10-01 12:47:44 -07:00
Sameh Khamis b6af8b34e0 feat: add a tool that links to an area in the app
Add open_in_gods_eye_view. It builds a version 2 share link looking
straight down on an area, with altitude chosen from the area's size, on
the app address the application supplies.
2026-10-01 12:42:08 -07:00
Sameh Khamis 9d12da0a79 feat: add a traffic flow tool
Add get_traffic_flow over the traffic source's TomTom flow tiles. It
reports average speed as a share of free-flow speed, congested and
closed road length, and a breakdown by road category, stepping down a
zoom level when an area needs more tiles than the source reads at once.
2026-10-01 12:36:00 -07:00
Sameh Khamis e6cbcc31ad feat: add ship tools
Add vessels_in_area, find_vessel and get_vessel_track over the live
vessel source, and a ships section in situation_brief. Without an AIS
key the source's own message reaches clients as an unavailable feed.
2026-10-01 12:32:09 -07:00
Sameh Khamis 0a74e36ce8 feat: add weather, hazard, terrain and situation tools
Add get_weather, get_regional_brief, get_cyclones, get_fire_perimeters,
get_terrain_height, find_military_installations and get_map_features
over the application request services and the layers' sources.

situation_brief combines weather, earthquakes, fires, aircraft and
cyclones for an area by reusing those tools, marking sections that are
unavailable instead of failing. get_hud_caption returns the app's
heads-up display caption for the same overview.
2026-10-01 12:28:34 -07:00
Sameh Khamis 8c97fed92d feat: add bike-share and transit tools
Add get_bike_share over the public GBFS systems and get_transit_vehicles
over the GTFS-Realtime feeds. Each finds the systems or feeds whose
coverage reaches the area, reads up to three, and reports any that were
unavailable instead of failing the whole answer. Transit results carry
each feed's attribution and license.
2026-10-01 12:22:13 -07:00
Sameh Khamis 2ba751ace8 feat: read the transit feed catalog through the transit source
Add getFeeds() to the transit source. It reads the public catalog route
the server already serves, so code outside the layer can find feeds
near a location without loading the layer.
2026-10-01 12:19:19 -07:00
Sameh Khamis eb12303f46 refactor: move GBFS station parsing into a portable source module
The bike-share layer parsed station documents inside its model factory.
Move the parsers unchanged to src/sources/gbfsStations.js and have the
model import them, so code outside the layer can read GBFS stations
without constructing the layer.
2026-10-01 12:18:48 -07:00
Sameh Khamis e680a59bf5 feat: add place search and routing tools
Add search_places and places_nearby over the app's Google Places routes,
and plan_route over its OpenStreetMap routing. The place search service
reports when no search key is configured, so an unconfigured server is
not mistaken for an empty result, and rate limits become retry_later.
Route endpoints accept a place name or coordinates.
2026-10-01 12:16:01 -07:00
Sameh Khamis 85fee59da1 feat: add public camera and radio tools
Add find_cctv_cameras, get_cctv_snapshot and find_radio_stations. A
snapshot returns the camera's current frame as image content with its
credit. Tool results may now carry base64 images, which the MCP adapter
sends as image content.
2026-10-01 12:11:00 -07:00
Sameh Khamis d65402eae3 feat: read current camera frames through the CCTV source
Add getFrame(camera) to the CCTV source. It reads one frame through the
registered frame endpoint the layer already uses and returns its content
type and bytes, so code outside the layer can show a camera's view.
2026-10-01 12:11:00 -07:00
Sameh Khamis 22c3fe40b0 feat: add aviation and satellite tools
Add aircraft_in_area, find_aircraft, get_aircraft_track and
get_aircraft_info over the live aircraft sources, with an optional
military feed, and next_satellite_pass and satellites_overhead over the
satellite catalog and the existing pass predictor.

Source failures from the live contract now become tool errors with the
matching code, so a rate limit reaches clients as retry_later with a
wait time. Tool schemas may use pattern.
2026-10-01 12:05:50 -07:00
Sameh Khamis 9895942633 refactor: move TLE text parsing into a portable source module
The satellites layer parsed catalog text inside its Cesium-backed orbit
owner. Move the parser unchanged to src/sources/tle.js, add a catalog
number helper, and have the layer delegate to it so code outside the
layer can read TLE text without loading rendering.
2026-10-01 12:05:41 -07:00
Sameh Khamis c3dfa8e853 feat: add a tool catalog and local MCP server
Define tools that answer questions from the app's data once, independent
of the surface that exposes them. A tool declares its arguments and the
services it reads; applications compose tools with their own services and
optional call interceptors. Queries reuse the layers' portable source
factories, so they run wherever an application routes /api requests.

The first queries are get_earthquakes, get_active_fires and
get_recent_launches, with a shared area argument (place name, bounding
box, or point and radius) and capped lists.

gods-eye-view/tools/mcp adapts a catalog to the Model Context Protocol
with a small JSON-RPC handler and a stateless HTTP transport, adding no
dependencies. npm run mcp serves Core's tools over stdio to local clients
such as Claude Code, reading from a running app. Both exports are
portable and checked by check:boundaries; the app does not import them.
2026-10-01 11:51:59 -07:00
137 changed files with 16281 additions and 1049 deletions

No files matched your search

+5
View File
@@ -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=
+56 -28
View File
@@ -1,5 +1,33 @@
# Changelog
## [Unreleased]
## [0.2.0] — 2026-10-02 — God's Eye View through Your Agent
- Show God's Eye View inside AI conversations. Answers that can be shown
include a view (camera, layers, style, map, marks, something to follow),
and `show_in_gods_eye_view` displays it as the live globe in clients that
support MCP Apps, such as Claude Desktop and the Codex and ChatGPT desktop
apps, and as a link everywhere else. The panel loads the app's panel build
(`npm run build:panel`) and its data through the MCP server, so a local
server needs no HTTPS or tunnel. `?embed=1` shows the globe alone for pages
that frame the app.
- Add tools for language-model clients and a local MCP server, served over
stdio by `npm run mcp` and over HTTP at `/mcp` on the development and preview
servers for local requests only. `npm run mcp`
serves earthquake, active-fire, launch, aircraft, ship, satellite, camera, radio,
place, routing, bike-share, transit, traffic, weather, cyclone, fire-perimeter,
terrain, installation and map-feature queries, plus a situation brief, over
stdio to clients such as Claude Code, reading
from a running app. Tools are defined
once in `gods-eye-view/tools`, reuse the layers' source factories, and are
exposed through the protocol adapter in `gods-eye-view/tools/mcp`. The
catalog also covers weather maps, wind, recent imagery, submarine cables,
license plate reader cameras, datacenters and dams, the Bhote Koshi flood,
military awareness and links that open the app over an area with layers on.
Voice offers the same queries next to its app actions.
- 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
@@ -182,7 +210,7 @@
(`src/ui/imagerySplit.js`, `src/maps/imageryComparison.js`), and
`MapSourceController.subscribe()` reports every settled map switch.
## Unreleased — local receiver feeds
### Local receiver feeds
- The Local ADS-B layer also reads local 1090 MHz and 978 MHz UAT decoder
feeds: the `aircraft.json` that dump1090-fa, readsb, tar1090 or skyaware978
@@ -211,7 +239,7 @@
- Records carry `band` (`1090`/`978`) and `source` (`webusb`/`feed`).
- See `docs/LOCAL-RECEIVERS.md`.
## Unreleased — local RTL-SDR and Local ADS-B
### Local RTL-SDR and Local ADS-B
- Add a Local RTL-SDR card to the Radio panel. It connects a USB RTL-SDR in
desktop Chrome or Edge through WebUSB and receives broadcast FM (tune, seek,
@@ -236,7 +264,7 @@
- Add `@jtarrio/webrtlsdr` and `@jtarrio/signals` (Apache-2.0); see
`THIRD_PARTY_NOTICES.md`.
## Unreleased — weather review
### Weather review
- On 3D Tiles, draw a 4096×2048 detail window around the view on each
observed-weather shell except global infrared, sampled by the shell's own
@@ -502,26 +530,26 @@ Analyst records for loaded satellites, datacenters and dams, with explicit bound
- Split application scene, controls, catalog, tools and HTML into reusable components; configure application request services and sources without changing global fetch. Preserve standalone markup and voice behavior. Explicit annotation navigation may resolve a distant named target.
## Satellite pass prediction
### Satellite pass prediction
- Bisect pass rise/set to ~0.2 s and fit peak elevation with a parabola.
- Mark passes visible from Earth-shadow and civil-twilight checks.
- Add `getNextSatellitePass(noradId, options)` for any loaded catalog satellite.
- `next_iss_pass` retains the next geometric pass and adds visibility metadata. `next_satellite_pass` adds bounded loaded-catalog name/NORAD lookup and optional visible-only filtering (Rehaan Delmotra, #451; maintainer adaptation).
## Voice component boundaries
### Voice component boundaries
- Separate voice controls, Realtime connection requests and the action runner.
- Allow compatible endpoints and server-selected models through construction options.
- Cancel pending token/SDP requests on Stop or teardown and reject expired secrets.
## Configurable geospatial services
### Configurable geospatial services
- Compose geocoding, place context and routes through independent providers.
- Allow compatible endpoint configuration without changing voice tools or annotation behavior.
- Isolate configured source caches and reject results after cancellation.
## ALPR camera locations
### ALPR camera locations
- Port Manjunath's (@manjunath22466) cyan camera badges, coral selection brackets,
gradient direction wedges and animated tactical labels into the reusable ALPR
@@ -541,32 +569,32 @@ Analyst records for loaded satellites, datacenters and dams, with explicit bound
- Separate the request adapter, camera model, presentation, and instance lifecycle.
Source cancellation also guards late response bodies and rejects invalid query bounds.
## Release disabled infrastructure rendering
### Release disabled infrastructure rendering
- Remove built Data Center, Dam and Submarine Cable entities when their layers
are disabled, avoiding retained visualizer work and entity memory.
- Keep parsed datasets cached for re-enable; rebuild entities without refetching.
## Camera layer components
### Camera layer components
- Separate camera source requests, placement, frames, projection, cards and calibration.
- Own visibility listeners and pending initialization within each layer lifetime.
- Preserve existing camera catalogs, URL families, geometry and playback behavior.
## Traffic and bikeshare components
### Traffic and bikeshare components
- Separate traffic loading, animation, styling and lifecycle into factory-owned components.
- Give each flow source its own bounded decode cache and cancellation checks.
- Separate bikeshare registry, station requests, rendering, selection and proximity handling.
## Installation and context components
### Installation and context components
- Separate mapped-site requests, records, placement, selection and viewport lifecycle.
- Separate proximity queries, subject tracking, navigation/history, panel and direction rendering.
- Retain source and ground-floor ownership in standalone composition; reject malformed
installation snapshots and ignore failures from cancelled requests.
## Satellite and mission layer components
### Satellite and mission layer components
- Separate catalog loading, orbit calculations, display, tracking and interaction
into instance-owned satellite components.
@@ -574,20 +602,20 @@ Analyst records for loaded satellites, datacenters and dams, with explicit bound
operations, retaining existing layer controls and satellite coordination.
- Cancel late mission source work and reject malformed launch snapshots.
## Fire layer components
### Fire layer components
- Split fire source loading, state, rendering, cards, selection and viewport work
into reusable components with application-owned scene services.
- Cancel late refreshes, retain good data after malformed responses, and preserve
selection identity without repeating a user-selection notification on refresh.
## Earthquake components
### Earthquake components
- Separate earthquake snapshot loading, record validation, and display ownership.
- Cancel pending earthquake refreshes on disable or destruction, retaining the
last good snapshot after malformed or failed refreshes.
## September 8, 2026
### September 8, 2026
Earthquake refreshes validate the complete feed and construct replacement entities before clearing the previous snapshot. Malformed rows and duplicate rendered IDs retain the last good entities, overlays, count and timestamp and report a malformed response; unknown magnitude is excluded from M2.5+ rendering.
@@ -598,7 +626,7 @@ Launch payloads with missing records now say PAYLOAD DATA UNAVAILABLE. Missing n
This changelog records public product changes. For the authoritative description
of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md).
## [Unreleased]
### Other changes
- Add ECMWF IFS model selection to Wind (#464, thanks @beneduzi), with model-scoped forecast-step caches, cancellation of replaced requests, and separate issue/valid timestamps.
@@ -619,7 +647,7 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
- Separate submarine cable sources and rendering components, and export bundled geography lookup modules.
### Added
#### Added
- DISPLAY ▸ Draw: draw on the world by hand. Pick Area, Line or Pin, click the
vertices, double-click or press Enter to finish, label and colour it; Backspace
@@ -629,7 +657,7 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
together. While you are drawing, the draw tool owns the pointer and no layer
selects what you click through (#235 — thanks @cora-fresh-labs).
### Fixed
#### Fixed
- Keep traffic-road bounds crossing the antimeridian monotonic and inside the
longitude range accepted by the Overpass request path, preserving the small
@@ -696,7 +724,7 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
toggle. Camera departure cancels pending work, arrival checks the final view,
and superseded requests cannot keep a newer view loading.
### Added
#### Added
- Add MODIS NRT (Terra+Aqua, ~1 km) active fires to the FIRMS layer, sharing the
existing `FIRMS_MAP_KEY` and 30-minute cache.
@@ -824,9 +852,9 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
- Extract shared surface keyboard handling for the welcome launcher and Provider
Settings, preserving Tab/Escape behavior and releasing the listener on teardown.
### Added
#### Added
### Security
#### Security
- The CCTV media route no longer forwards a client `Range` header to the upstream
camera host as it arrived. A single `bytes=` range is canonicalized and
@@ -857,7 +885,7 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
- Cancel the active location lookup when its controls are disposed.
### Fixed
#### Fixed
- `DATA_SOURCES.md` states what the project does with camera frame content: a
successful upstream response is relayed as the provider served it, nothing in
@@ -936,7 +964,7 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
fetching, AIS records/tracks and shared request helpers; preserve existing
routes, local setup, fallback behavior and rendering.
### Added
#### Added
- **Directions layer** — keyless A→B directions without a geocoder or a
microphone (thanks @spcpza). The row's chips arm a globe click for A and B
@@ -964,7 +992,7 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
voice routes since launch, previously uncredited), with the OpenStreetMap
credit and the "fix the map" link the service's usage policy asks for.
### Changed
#### Changed
- The interface asks Google Fonts for only the icon glyphs it draws, instead of
the whole variable icon font, and no longer requests a second icon family that
@@ -979,7 +1007,7 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
- Rename standalone browser startup to `src/standalone/` and add a Node-only
`gods-eye-view/build/vite` export with checked package ownership.
### Development
#### Development
- The CCTV launcher and preview-server tests resolve their temporary fixture
root through `fs.realpath`, so they pass on macOS, where the system temp
@@ -1013,7 +1041,7 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
infrastructure modules and their consumer tests. Package boundary checks keep
those exports separate from app startup and local Node services.
### Fixed
#### Fixed
- Reduce terrain-height timeouts when Re:Earth slows down. Batches are
sized against measured response latency on both browser and server to reduce
@@ -1104,7 +1132,7 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
and upstream error bodies; response statuses and cache fallback remain intact.
Includes the security fixes contributed by Tom-Neverwinter in PR #171.
### Fixed
#### Fixed
- Map Source keyboard opening retries focus until the selected tile is visible.
Leaving the disclosure, pointer interaction, or closing the tray cancels the
@@ -1146,7 +1174,7 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
that had since recovered. A miss is now cached only when every source
consulted actually returned a verdict.
### Added
#### Added
- Keyless place search. The LOCATION search box and the `fly_to_location` voice
tool now resolve place names through Photon (komoot, over OpenStreetMap) when
+2
View File
@@ -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.)
+55
View File
@@ -0,0 +1,55 @@
/**
* Lets other pages frame the app in embed mode (`?embed=1`). Every other
* document keeps the server's `X-Frame-Options: DENY` and
* `frame-ancestors 'none'`, which protect Provider Settings. Embed mode
* shows only the globe, so for those documents framing is limited to
* `ancestors` instead: `GEV_EMBED_FRAME_ANCESTORS`, or any frame by default.
* "Any" sends no framing restriction at all: `frame-ancestors *` would still
* refuse sandboxed frames with an opaque origin, which panel hosts use.
*/
const FRAMING_HEADERS = new Set(['x-frame-options', 'content-security-policy']);
/** Whether a request is for an embed-mode document. */
export function isEmbedDocumentRequest(url) {
let parsed;
try {
parsed = new URL(url || '/', 'http://localhost');
} catch {
return false;
}
return (
parsed.searchParams.get('embed') === '1' &&
(parsed.pathname === '/' || parsed.pathname.endsWith('.html'))
);
}
/** Vite plugin applying the embed framing policy on dev and preview servers. */
export function embedFramingPlugin({
ancestors = process.env.GEV_EMBED_FRAME_ANCESTORS || '*',
} = {}) {
const anywhere = ancestors.trim() === '*';
const policy = `frame-ancestors ${ancestors}`;
const install = (server) => {
server.middlewares.use((req, res, next) => {
if (!isEmbedDocumentRequest(req.url)) return next();
// The server's own headers are written when the response is sent, so
// intercept them rather than setting ours first.
const setHeader = res.setHeader.bind(res);
res.setHeader = (name, value) => {
const key = String(name).toLowerCase();
if (!FRAMING_HEADERS.has(key)) return setHeader(name, value);
if (key === 'content-security-policy' && !anywhere)
return setHeader(name, policy);
return res;
};
if (!anywhere) setHeader('Content-Security-Policy', policy);
next();
});
};
return {
name: 'embed-framing',
configureServer: install,
configurePreviewServer: install,
};
}
+153
View File
@@ -0,0 +1,153 @@
/**
* The MCP Apps panel build. A panel loads the app through its MCP server,
* one tool call per file (see src/tools/globePanel.js), so the app must be
* few files: one app script, one stylesheet, and Cesium's own script, which
* carries its workers and starts them from memory. The build is served at
* PANEL_BASE next to the app.
*/
// The panel loads these paths (src/tools/globePanel.js); a test keeps the
// two in step.
export const PANEL_BASE = '/panel/';
/** The script the panel runs ahead of Cesium's workers (workerFilesPrelude). */
export const PANEL_WORKER_PRELUDE_PATH = 'cesium/worker-prelude.js';
export const PANEL_OUT_DIR = 'dist/panel';
/** A browser Vite config changed to produce the panel build. */
export function panelBuildConfig(config) {
return {
...config,
base: PANEL_BASE,
build: {
...config.build,
outDir: PANEL_OUT_DIR,
emptyOutDir: true,
modulePreload: false,
cssCodeSplit: false,
rollupOptions: {
...config.build?.rollupOptions,
output: { inlineDynamicImports: true },
},
},
};
}
const CONTENT_TYPES = {
'.css': 'text/css',
'.geojsonl': 'application/geo+json-seq',
'.glb': 'model/gltf-binary',
'.html': 'text/html',
'.jpg': 'image/jpeg',
'.js': 'text/javascript',
'.json': 'application/json',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.wasm': 'application/wasm',
'.woff2': 'font/woff2',
'.xml': 'application/xml',
};
/**
* Vite plugin serving the panel build at PANEL_BASE on the dev server, as
* the preview server and production builds serve it from the build output.
*/
export function panelBuildPlugin({ outDir = PANEL_OUT_DIR } = {}) {
return {
name: 'panel-build',
configureServer(server) {
server.middlewares.use(async (req, res, next) => {
const { pathname } = new URL(req.url || '/', 'http://localhost');
if (!pathname.startsWith(PANEL_BASE)) return next();
const { readFile } = await import('node:fs/promises');
const { extname, resolve, sep } = await import('node:path');
const root = resolve(server.config.root, outDir);
let relative;
try {
relative = decodeURIComponent(pathname.slice(PANEL_BASE.length));
} catch {
res.statusCode = 400;
res.end('Bad path');
return;
}
const file = resolve(root, relative || 'index.html');
if (file !== root && !file.startsWith(root + sep)) return next();
try {
const body = await readFile(file);
res.setHeader(
'Content-Type',
CONTENT_TYPES[extname(file)] || 'application/octet-stream',
);
res.end(body);
} catch {
res.statusCode = 404;
res.end(
relative ? 'Not found' : 'No panel build; run npm run build:panel',
);
}
});
},
};
}
/**
* Cesium files its workers load themselves, as paths under Cesium's base.
* Workers request them from the panel page's own site, which has none of
* the app's files, so the panel build embeds them in the workers script.
*/
export const PANEL_WORKER_FILES = Object.freeze([
'Assets/approximateTerrainHeights.json',
]);
/**
* A script, run in each worker before Cesium's, that answers the worker's
* requests for `files` (Cesium-relative path → text) from memory and lets
* every other request through.
*/
export function workerFilesPrelude(files) {
const answer = (FILES) => {
const find = (url) => {
const path = String(url).split(/[?#]/)[0];
for (const name of Object.keys(FILES))
if (path.endsWith(`/${name}`)) return FILES[name];
return null;
};
const xhr = XMLHttpRequest.prototype;
const { open, send, setRequestHeader } = xhr;
xhr.open = function (method, url, ...rest) {
this.__gevFile = find(url);
if (this.__gevFile === null) return open.call(this, method, url, ...rest);
};
xhr.setRequestHeader = function (...args) {
if (this.__gevFile === null) return setRequestHeader.apply(this, args);
};
xhr.send = function (body) {
const text = this.__gevFile;
if (text === null || text === undefined) return send.call(this, body);
const type = this.responseType;
const response =
type === 'json'
? JSON.parse(text)
: type === 'arraybuffer'
? new TextEncoder().encode(text).buffer
: text;
const state = {
readyState: 4,
status: 200,
statusText: 'OK',
response,
responseText: typeof response === 'string' ? response : '',
getAllResponseHeaders: () => 'content-type: application/json\r\n',
getResponseHeader: (name) =>
name.toLowerCase() === 'content-type' ? 'application/json' : null,
};
for (const [name, value] of Object.entries(state))
Object.defineProperty(this, name, { configurable: true, value });
setTimeout(() => {
this.dispatchEvent(new Event('readystatechange'));
this.dispatchEvent(new ProgressEvent('load'));
this.dispatchEvent(new ProgressEvent('loadend'));
});
};
};
return `(${answer})(${JSON.stringify(files)});\n`;
}
+10 -1
View File
@@ -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'",
+37
View File
@@ -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
+71
View File
@@ -0,0 +1,71 @@
# MCP setup
Use God's Eye View from Claude and Codex: tools, links, and the live globe
inside the conversation. Agents: follow these steps in order and check each
one before going on.
## 1. Run the app
```sh
npm install
npm run build:panel # the in-conversation globe; rerun after app changes
npm run dev # http://localhost:5173, keep it running
```
The MCP server reads from this app.
## 2. Add the server to a client
The server is `server/mcp/stdio.js` in this checkout. Use absolute paths
(clients do not load your shell profile), with each argument as its own entry.
| Client | How |
| --- | --- |
| Claude Code | `claude mcp add gods-eye-view -- node /abs/path/gods-eye-view/server/mcp/stdio.js --api-base http://localhost:5173` |
| Codex CLI | `codex mcp add gods-eye-view -- node /abs/path/gods-eye-view/server/mcp/stdio.js --api-base http://localhost:5173` |
| Codex and ChatGPT desktop | Same `~/.codex/config.toml` as the CLI (block below), or Settings → MCP servers → Add server (STDIO). |
| Claude Desktop | Settings → Developer → Edit Config, then add the JSON below. |
```toml
[mcp_servers.gods-eye-view]
command = "node"
args = ["/abs/path/gods-eye-view/server/mcp/stdio.js", "--api-base", "http://localhost:5173"]
```
```json
{
"mcpServers": {
"gods-eye-view": {
"command": "node",
"args": ["/abs/path/gods-eye-view/server/mcp/stdio.js", "--api-base", "http://localhost:5173"]
}
}
}
```
**Windows with the checkout in WSL:** use `"command": "wsl.exe"` and put
`"-e"` and the absolute Linux path to `node` first in `args`, for example
`["-e", "/home/you/.local/share/mise/installs/node/24/bin/node", "/home/you/gods-eye-view/server/mcp/stdio.js", "--api-base", "http://localhost:5173"]`.
## 3. Restart and try it
Fully quit the client (on Windows, from the tray icon) and reopen it, then
start a new chat; clients keep the server they started with. Ask: *"Show San
Diego in God's Eye View with military flights on."*
- Claude Desktop (a chat, not the Code tab), Codex and ChatGPT desktop show
the live globe; the first load takes 10–20 seconds.
- Claude Code and the Codex CLI answer with data and a link.
## Troubleshooting
- **The server does not appear:** run the command from step 2 in a terminal;
it should print `God's Eye View MCP server reading http://localhost:5173`
and wait. A path or `args` mistake fails here.
- **The panel says it could not load:** `npm run build:panel` was not run, or
the dev server is down.
- **Logs:** the server writes each call, and why one failed, to stderr. Claude
Desktop keeps it in its logs folder as `mcp-server-gods-eye-view.log`; in
Codex, right-click the panel → DevTools for the panel's console.
See [tools and the MCP server](TOOLS.md) for what the tools do.
+291
View File
@@ -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 |
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "gods-eye-view",
"version": "0.1.1",
"version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gods-eye-view",
"version": "0.1.1",
"version": "0.2.0",
"license": "MIT",
"dependencies": {
"@jtarrio/signals": "^0.10.0",
+9 -3
View File
@@ -1,7 +1,7 @@
{
"name": "gods-eye-view",
"private": true,
"version": "0.1.1",
"version": "0.2.0",
"description": "A real-time intelligence console for planet Earth — photorealistic 3D globe, live aircraft/ships/satellites/earthquakes/CCTV, and hands-free voice control. Runs in a browser.",
"type": "module",
"license": "MIT",
@@ -41,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"
}
}
+2 -1
View File
@@ -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:
+46
View File
@@ -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}`);
+4
View File
@@ -37,6 +37,10 @@ const portableExport = (key) =>
) ||
[
'./director',
'./tools',
'./tools/mcp',
'./tools/services',
'./view',
'./voice/action-schemas',
'./voice/session',
'./data/lifecycle',
+29 -1
View File
@@ -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"
]
+163 -4
View File
@@ -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": []
}
}
+129
View File
@@ -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);
});
}
+44
View File
@@ -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 })],
});
}
+63
View File
@@ -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,
});
}
+90
View File
@@ -0,0 +1,90 @@
/**
* Local MCP server over stdio for agents such as Claude Code and Codex.
*
* npm run mcp -- [--api-base http://localhost:4173]
*
* Reads newline-delimited JSON-RPC from stdin and writes responses to stdout.
* Tools read data from a running God's Eye View server (`npm run dev` or
* `npm run preview`) at the API base. Diagnostics go to stderr.
*/
import { createInterface } from 'node:readline';
import { fileURLToPath } from 'node:url';
import { parseErrorResponse } from '../../src/tools/mcp/index.js';
import { createLocalMcpServer } from './server.js';
import { DEFAULT_API_BASE } from './services.js';
/**
* A one-line description of a request for the diagnostic log: its method,
* and the tool or resource it names. Arguments and data are never logged.
*/
export function describeRequest(message) {
const method = typeof message?.method === 'string' ? message.method : null;
if (!method) return null;
const subject =
method === 'tools/call'
? message.params?.name
: method === 'resources/read'
? message.params?.uri
: null;
return typeof subject === 'string' ? `${method} ${subject}` : method;
}
/** Serve newline-delimited JSON-RPC until `input` ends. */
export async function serveStdio(server, { input, output, log = () => {} }) {
const lines = createInterface({ input, crlfDelay: Infinity });
const pending = new Set();
for await (const line of lines) {
if (!line.trim()) continue;
const work = respond(server, line, log).then((response) => {
if (response) output.write(`${JSON.stringify(response)}\n`);
});
pending.add(work);
work.finally(() => pending.delete(work));
}
await Promise.all(pending);
}
async function respond(server, line, log) {
let message;
try {
message = JSON.parse(line);
} catch {
return parseErrorResponse();
}
const described = describeRequest(message);
if (described) log(described);
const response = await server.handle(message);
// A failed tool call answers the client, not the log; say why here too.
if (response?.result?.isError)
log(` ${described} failed: ${response.result.content?.[0]?.text}`);
return response;
}
/** Read `--api-base <url>` from command-line arguments. */
export function parseArgs(argv) {
const options = {};
for (let index = 0; index < argv.length; index += 1) {
if (argv[index] === '--api-base' && argv[index + 1])
options.apiBase = argv[(index += 1)];
else throw new Error(`Unknown argument: ${argv[index]}`);
}
return options;
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
try {
const options = parseArgs(process.argv.slice(2));
console.error(
`God's Eye View MCP server reading ${options.apiBase || DEFAULT_API_BASE}`,
);
await serveStdio(createLocalMcpServer(options), {
input: process.stdin,
output: process.stdout,
log: (line) => console.error(`<- ${line}`),
});
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}
+33 -9
View File
@@ -25,6 +25,36 @@ let _openskyCacheMeta = null;
let _openskyCacheSourceEpochMs = null;
/** TTL for the OpenSky response cache (ms). */
const OPENSKY_CACHE_MS = 9000;
/** Per-attempt limit for the global snapshot; it normally arrives in ~1.5 s. */
const OPENSKY_ATTEMPT_TIMEOUT_MS = 10_000;
const OPENSKY_STATES_URL =
'https://opensky-network.org/api/states/all?extended=1';
/**
* Fetch and read the global snapshot, giving each attempt
* OPENSKY_ATTEMPT_TIMEOUT_MS and retrying once when an attempt times out or
* the connection fails. A second failure throws, which the proxy answers
* from its stale cache or the regional fallback.
* @param {Record<string, string>} headers Request headers.
* @returns {Promise<{upstream: Response, body: string}>}
*/
async function fetchOpenSkyStates(headers) {
for (let attempt = 1; ; attempt++) {
try {
const upstream = await fetch(OPENSKY_STATES_URL, {
headers,
signal: AbortSignal.timeout(OPENSKY_ATTEMPT_TIMEOUT_MS),
});
return { upstream, body: await upstream.text() };
} catch (error) {
if (attempt >= 2) throw error;
console.warn(
'[OpenSky] global snapshot attempt failed, retrying:',
error?.message || error,
);
}
}
}
// --- OpenSky credit governor (field-test fix 2026-07-06) -------------------
// The global /states/all this proxy fetches costs 4 CREDITS per call against
// OpenSky's ~4000/day authenticated budget — a day with the app open burned
@@ -107,6 +137,7 @@ export async function getOpenSkyToken() {
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
signal: AbortSignal.timeout(OPENSKY_ATTEMPT_TIMEOUT_MS),
body: `grant_type=client_credentials&client_id=${encodeURIComponent(clientId)}&client_secret=${encodeURIComponent(clientSecret)}`,
},
);
@@ -470,10 +501,7 @@ export function openSkyProxy() {
}
}
let upstream = await fetch(
'https://opensky-network.org/api/states/all?extended=1',
{ headers },
);
let { upstream, body } = await fetchOpenSkyStates(headers);
// Auto-mode fallback: if OAuth was rejected, retry with Basic credentials
if (
(upstream.status === 401 || upstream.status === 403) &&
@@ -485,15 +513,11 @@ export function openSkyProxy() {
Accept: 'application/json',
Authorization: `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`,
};
upstream = await fetch(
'https://opensky-network.org/api/states/all?extended=1',
{ headers: retryHeaders },
);
({ upstream, body } = await fetchOpenSkyStates(retryHeaders));
usedMode = 'basic';
reason = 'oauth_rejected_fallback_basic';
}
let body = await upstream.text();
const sourceEpochMs = upstream.ok ? openSkySourceEpochMs(body) : null;
if (
upstream.ok &&
+2
View File
@@ -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,
+22 -2
View File
@@ -3,7 +3,7 @@ import path from 'node:path';
import { DEFAULT_CCTV_SOURCE_FILE, CCTV_SOURCE_CACHE_MS } from './constants.js';
import { allocateSourceCap, resolveCatalogCap } from './cap.js';
import { loadGroundHeights, joinGroundHeights } from './groundHeights.js';
import { normalizeSourceItem } from './normalize.js';
import { cameraRegion, normalizeSourceItem } from './normalize.js';
import {
loadAustinSourcesFromOpenData,
loadCaltransSourcesFromOpenData,
@@ -143,6 +143,9 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
/** @type {Promise<Array<object>>|null} In-flight refresh, shared by concurrent
* callers so a post-TTL burst launches ONE refetch, not one per request. */
let _cctvSourceInflight = null;
/** @type {Array<{pack: string, available: number, served: number}>} Packs
* that serve fewer cameras than they offer. */
let _cctvTrimmedPacks = [];
/**
* Assemble and cache the merged CCTV source list.
@@ -209,8 +212,12 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
name,
sources: items
.filter((item) => item && typeof item === 'object')
.map((item) => normalizeSourceItem(item))
.map((item) => ({ ...normalizeSourceItem(item), pack: name }))
.filter((item) => item.id),
available: Number.isFinite(items.available)
? items.available
: items.length,
region: items.region ?? null,
});
const packs = [
...LIVE_PACKS.map((pack, index) =>
@@ -234,6 +241,16 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
loadGroundHeights(sourceRoot),
);
const trimmed = allocation.packs.filter((pack) => pack.kept < pack.offered);
// Each pack's own nearest-first cap and the shared catalog cap both trim.
const trimmedPacks = packs
.map((pack, index) => ({
pack: pack.name,
available: Math.max(pack.available, allocation.packs[index].offered),
served: allocation.packs[index].kept,
// Where the pack's cameras are, including those not served.
region: pack.region ?? cameraRegion(pack.sources),
}))
.filter((pack) => pack.served < pack.available);
if (trimmed.length) {
const detail = trimmed
.map((pack) => `${pack.name} ${pack.kept}/${pack.offered}`)
@@ -244,6 +261,7 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
}
if (capped.length > 0 || _cctvSourceCache.length === 0) {
_cctvSourceCache = capped;
_cctvTrimmedPacks = trimmedPacks;
} else {
// Every source came back empty (all live packs timed out / upstream outage)
// but a good catalog is already cached — serve it stale rather than blanking
@@ -257,5 +275,7 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
return _cctvSourceCache;
}
/** Packs serving fewer cameras than they offer, as of the last refresh. */
getCctvSources.trimmedPacks = () => _cctvTrimmedPacks;
return getCctvSources;
}
+25 -1
View File
@@ -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;
}
/**
+6 -3
View File
@@ -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(),
+2
View File
@@ -19,6 +19,7 @@ function realtimeInstructions(
'analyst_query ANSWERS questions; it never moves the camera or starts tracking. For requests to FOLLOW or TRACK a specific aircraft/ship, call track_entity (get_entity_context first when the target is ambiguous), never analyst_query as the final or only action. For "follow/track the nearest aircraft", first call analyst_query with the aircraft layer(s), sortBy=distance, and limit=1, then call track_entity with the returned aircraft identity in the same turn. The lookup alone does not fulfill a follow/track command.',
'For a request to enable an aircraft layer and SELECT or FIND the nearest/closest aircraft near a named place — for example, "Turn on flights and select the closest aircraft to Austin" — call select_nearest_aircraft once. It atomically turns on the requested aircraft layer first, waits for location arrival, refreshes that layer for the destination viewport, filters out landed/on-ground records, and selects the nearest airborne result. A healthy fallback feed is valid data: report the returned feed source briefly, never call it an enable failure. Do not also call fly_to_location, set_layer_visibility, analyst_query, track_entity, set_context_mode, or control_cockpit for the same request. SELECT/FIND never implies Contacts or Cockpit unless the user explicitly asks for either mode.',
'For ANALYTICAL questions about layer data — how many / which / fastest / highest / biggest / nearest flights, ships, fires, earthquakes, satellites, datacenters, or dams ("how many flights over Texas", "biggest fire near LA", "which ships are headed to Oakland", "anything above 40,000 feet") — call analyst_query, not get_entity_context. Narrate the count plus two or three notable examples by name, and reflect the result\'s coverage note honestly: the answer covers data loaded by enabled layers, not the whole world. If the needed layer is disabled, say so and offer to enable it. For follow-ups about the same set ("which of THOSE is closest?"), call analyst_query with followUp=true and only the new filter/sort.',
'WORLD QUERIES — the query tools (get_weather, get_wind, search_places, places_nearby, plan_route, get_cyclones, get_fire_perimeters, get_bike_share, get_transit_vehicles, get_traffic_flow, situation_brief, military_awareness and the other get_* and find_* tools you have) look up data anywhere in the world without changing the map and without any layer being on. Use them when the user asks about a place they are not looking at or data whose layer is off ("what is the weather in Tokyo", "how do I drive from Austin to Dallas", "any wildfires in California"). For aircraft, ships, earthquakes, active fires, datacenters, dams and satellites overhead, use analyst_query over the layers instead, turning the layer on first when the user wants it. Use analyst_query and get_entity_context for what the app has loaded or what is on screen. Answer from the result summary and data, mention its source when it has one, and never claim the map changed. If the user also wants to SEE it, use the matching app action (fly_to_location, set_layer_visibility) as well.',
'FEED STATE CONTRACT — analyst_query and get_current_view_state carry feedProvenance from the same layerFeedState the Data Layers chips use (nominal/loading/degraded/stale/fallback/unavailable, or off). NEVER narrate a count as live or current when feedProvenance.overall is not nominal. Say the feedState in the same breath as the count ("12 flights in view, STALE"). If unavailable, do not invent a count; say the feed is unavailable. Fallback is valid data — name the fallback source, never call it an enable failure. Confirm only what the tool result returned.',
'COUNTING CONTRACT — what "near" means. (1) While Contacts is ACTIVE, "near / nearby / how many aircraft" means the Contacts window: answer from contactsWindow in the tool result — those are the exact numbers on the user\'s panel. set_context_mode, analyst_query, and get_current_view_state carry it after Contacts settles. For "Open Contacts and tell me how many aircraft are within 250 km", call set_context_mode{mode:"contacts"} first and answer from contactsWindow.aircraft; do not answer from a pre-Contacts analyst query. analyst_query\'s own count measures currently-loaded records and is usually lower; never give it as the window count. CENTER PRECEDENCE for a nearby/how-many ask, in order: an explicit place in the question ("over Texas", "near Austin") always wins and ignores Contacts state; else the CONTACTS SUBJECT when Contacts is active and has one — a selected datacenter, dam, fire, or cable does NOT silently become the center; else an entity the user explicitly names ("around this datacenter"); else the current view, said aloud ("nothing is selected, so this is the current view"). With Contacts active but NO subject yet, use the view and say so; never read an empty panel. (2) With Contacts OFF, "nearby" means in view; "near <place>" means a radius around that place. (3) EVERY count names its scope in words — "42 in your window", "8 in view", "about 30 within 250 km of Austin" — never a bare number; analyst_query returns scopeLabel for exactly this. Two different numbers with named scopes are not a contradiction; say both if asked. (4) State counts VERBATIM — never estimate, round, or hedge ("a few", "less than a dozen"): if a tool returns 46, say 46. (5) When it matters, add once: counts cover loaded data, and the flights layer loads where you look.',
'While Cockpit is active, navigate with control_cockpit (next/previous, optionally targetLayer or aircraftClass). track_entity and fly_to_location are REFUSED by design while Cockpit owns the camera — that refusal is correct, not an error to retry. To go somewhere else, exit Cockpit first. control_cockpit enter establishes Contacts itself, so do not call set_context_mode before or after it.',
@@ -48,6 +49,7 @@ function realtimeInstructions(
// string is the whole rollback.
'NAMED VIEWS are shorthand for tool calls you already have — there is no "mode" tool for them. Treat ONLY these as the shorthand: "infrastructure mode" / "the infrastructure view" / "show me global infrastructure" means three set_layer_visibility calls (local-datacenters, local-dams, telegeography-submarine-cables) plus zoom_to_globe; "environmental mode" / "earth watch" / "active events", said as the name of a view, means set_layer_visibility for local-firms and earthquakes plus zoom_to_globe. Anything vaguer is NOT this shorthand — an open-ended question about the world or the news is an ordinary question: answer it, or use analyst_query over the layers already on. Never switch a whole view on to answer a question nobody asked to see. When you do run one, make every call before speaking, then give one confirmation naming the resulting state; if the fires layer comes back unavailable because no FIRMS key is configured, say so plainly — the earthquakes still loaded. "Live contacts" and "space missions" are NOT this pattern: they stay set_context_mode{mode:"contacts"} and set_context_mode{mode:"space-missions"}.',
'For visual filter requests, call set_visual_style with one of the allowed style IDs.',
'SHIPS vs STYLES: "vessels" is easily heard as "visuals". "Show me vessels", "turn on vessels", "the vessels", "visuals" or "vestibules" said WITHOUT a style name means the ships layer: set_layer_visibility{layerId:"ais-live-vessels"}, exactly as "ships" or "boats" does. Call set_visual_style only when the user names a style (retro/CRT, surveillance/night vision, thermal, anime, noir, snow, normal) or says filter, effect or look. Never turn on a different data layer (such as fires) for a ships request.',
'Disambiguation table — basemap vs layer vs style: basemap switching requires an explicit stack name — "Bing aerial" means set_map_stack bing-aerial, "aerial with labels" means bing-labels, "OSM"/"road map" means osm, "Esri"/"Esri imagery" means esri-imagery, "Google 3D"/"photorealistic" means photoreal. Any mention of "satellite" or "satellites" ALWAYS means the satellites DATA LAYER via set_layer_visibility, never a basemap. "surveillance"/"night vision"/"thermal" are visual STYLES via set_visual_style.',
'HUD requests ("hud on/off", "switch to operator/minimal/tactical/cyber layout") use set_hud. Detection requests ("detection on", "dense mode", "balanced mode", "sparse mode", "set density to 25", "use weighted allocation") use set_detection. Density snaps to 0/25/50/75/100 and derives Sparse/Balanced/Dense; panoptic is a legacy alias for Dense.',
'Cyber sonar requests use set_cyber_sonar: enabled, rings, rangePct, intensityPct (Power), opacityPct, sectorDeg. For settings questions read get_current_view_state.controls.hud.sonar. Sonar is not detection. Do not silently switch themes, reveal the HUD or enable sonar when changing a slider. If the user explicitly requests Cyber plus sonar settings, call set_hud first, wait for success, then set_cyber_sonar. Confirm returned values; mapSweepActive and contactSweepActive distinguish saved settings from active map effects, including in Cockpit or reduced motion. Do not claim visible contacts solely because enabled is true.',
+2 -1
View File
@@ -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',
},
};
+12
View File
@@ -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)),
];
}
+7 -1
View File
@@ -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,
+18
View File
@@ -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')),
);
}
+293
View File
@@ -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);
};
}
+312
View File
@@ -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/,
);
});
+661
View File
@@ -0,0 +1,661 @@
/**
* The God's Eye View panel's script, run inside the panel page a host shows.
* It is sent as source text (see src/tools/globePanel.js), so it must not use
* imports or anything outside the function.
*
* The page lives on the host's site, and hosts may refuse other addresses,
* such as a server on the user's machine. So the panel never requests the
* app's server itself: every request for the app's own paths goes to the
* panel's MCP server as a call to `config.toolName`, which requests the path
* from the app's server. The app's built page, scripts, styles, images and
* data all arrive that way; map imagery from providers loads directly.
*/
export function panelRuntime(config) {
const status = document.getElementById('status');
const open = document.getElementById('open');
const expand = document.getElementById('expand');
const say = (text) => {
if (status.isConnected) status.textContent = text;
};
let blocked = null;
document.addEventListener('securitypolicyviolation', (event) => {
blocked = `${event.effectiveDirective} blocked ${event.blockedURI || 'a resource'}`;
});
// JSON-RPC with the host.
let nextId = 1;
const pending = new Map();
const send = (message) =>
window.parent.postMessage({ jsonrpc: '2.0', ...message }, '*');
const request = (method, params) =>
new Promise((resolve, reject) => {
const id = nextId++;
pending.set(id, { resolve, reject });
send({ id, method, params });
});
const notify = (method, params) => send({ method, params });
// Requests to the app's server, through the MCP server.
const MAX_CALLS = 6;
let calls = 0;
const waiting = [];
async function callTool(args) {
if (calls >= MAX_CALLS) await new Promise((go) => waiting.push(go));
calls += 1;
try {
const result = await request('tools/call', {
name: config.toolName,
arguments: args,
});
if (result?.isError || !result?.structuredContent)
throw new Error(result?.content?.[0]?.text || 'The request failed');
return result.structuredContent;
} finally {
calls -= 1;
waiting.shift()?.();
}
}
const toBase64 = (bytes) => {
let binary = '';
for (let start = 0; start < bytes.length; start += 0x8000)
binary += String.fromCharCode(...bytes.subarray(start, start + 0x8000));
return btoa(binary);
};
const fromBase64 = (text) =>
Uint8Array.from(atob(text), (char) => char.charCodeAt(0));
async function bodyBytes(body) {
if (body == null) return null;
if (typeof body === 'string') return new TextEncoder().encode(body);
if (body instanceof ArrayBuffer) return new Uint8Array(body);
if (ArrayBuffer.isView(body))
return new Uint8Array(body.buffer, body.byteOffset, body.byteLength);
return new Uint8Array(await new Response(body).arrayBuffer());
}
const NULL_BODY = new Set([101, 204, 205, 304]);
const held = new Map();
/** Request a path on the app's server; resolves to a Response. */
function appFetch(path, init = {}) {
const method = (init.method || 'GET').toUpperCase();
// Built files never change while the panel runs, so each loads once.
const shared = method === 'GET' && path.startsWith(config.panelBase);
if (shared && held.has(path)) return held.get(path).then(respond);
const loading = (async () => {
const headers = {};
new Headers(init.headers || {}).forEach((value, name) => {
headers[name] = value;
});
const bytes = await bodyBytes(init.body);
let part = await callTool({
path,
method,
headers,
...(bytes ? { body: toBase64(bytes) } : {}),
});
const parts = [fromBase64(part.body)];
while (part.nextOffset !== undefined) {
if (init.signal?.aborted) break;
part = await callTool({ id: part.id, offset: part.nextOffset });
parts.push(fromBase64(part.body));
}
let body = new Blob(parts);
if (part.encoding === 'gzip')
body = await new Response(
body.stream().pipeThrough(new DecompressionStream('gzip')),
).blob();
return {
body: await body.arrayBuffer(),
status: part.status,
statusText: part.statusText,
headers: part.headers,
};
})();
if (shared) {
held.set(path, loading);
loading.catch(() => held.delete(path));
}
const result = loading.then(respond);
if (!init.signal) return result;
return Promise.race([
result,
new Promise((_, reject) => {
const abort = () =>
reject(new DOMException('The request was aborted', 'AbortError'));
if (init.signal.aborted) abort();
init.signal.addEventListener('abort', abort, { once: true });
}),
]);
}
function respond({ body, status, statusText, headers }) {
return new Response(NULL_BODY.has(status) ? null : body.slice(0), {
status,
statusText,
headers,
});
}
// The app's own paths: relative ones, absolute ones on this page's site
// (matched by scheme and host, since a host may use its own scheme), and
// ones under the app's address in the page, an https address for code
// that needs one (`config.appBaseUrl`).
const page = new URL(document.baseURI);
const appOrigin = new URL(config.appBaseUrl).origin;
function appPath(value) {
let url;
try {
url = new URL(String(value), document.baseURI);
} catch {
return null;
}
const own =
url.origin === appOrigin ||
(url.protocol === page.protocol && url.host === page.host);
return own ? url.pathname + url.search : null;
}
const fileUrls = new Map();
/**
* A data: URL holding an app file, for elements that load by URL. Hosts
* allow data: images where some refuse blob: ones. Files of the panel
* build never change and are kept; others, such as live camera frames
* that change address on every refresh, are not.
*/
function appFileUrl(path) {
if (fileUrls.has(path)) return fileUrls.get(path);
const loading = appFetch(path)
.then((response) => response.blob())
.then(
(blob) =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob);
}),
);
if (path.startsWith(config.panelBase)) {
fileUrls.set(path, loading);
loading.catch(() => fileUrls.delete(path));
}
return loading;
}
function installRequests() {
const nativeFetch = window.fetch.bind(window);
window.fetch = async (input, init = {}) => {
const isRequest = input instanceof Request;
const path = appPath(isRequest ? input.url : input);
if (path === null) return nativeFetch(input, init);
if (!isRequest) return appFetch(path, init);
return appFetch(path, {
method: init.method || input.method,
headers: init.headers || input.headers,
body:
init.body ??
(['GET', 'HEAD'].includes(input.method)
? undefined
: await input.arrayBuffer()),
signal: init.signal || input.signal,
});
};
// XMLHttpRequest for app paths: answered from appFetch, with the
// instance's state replaced, and the usual events dispatched.
const xhr = XMLHttpRequest.prototype;
const nativeOpen = xhr.open;
const nativeSend = xhr.send;
const nativeHeader = xhr.setRequestHeader;
const nativeAbort = xhr.abort;
xhr.open = function (method, url, ...rest) {
const path = appPath(url);
if (path === null) {
this.__gev = null;
return nativeOpen.call(this, method, url, ...rest);
}
this.__gev = { method, path, headers: {}, controller: null };
setState(this, { readyState: 1 });
this.dispatchEvent(new Event('readystatechange'));
};
xhr.setRequestHeader = function (name, value) {
if (!this.__gev) return nativeHeader.call(this, name, value);
this.__gev.headers[name] = value;
};
xhr.abort = function () {
if (!this.__gev) return nativeAbort.call(this);
this.__gev.controller?.abort();
};
xhr.send = function (body) {
const state = this.__gev;
if (!state) return nativeSend.call(this, body);
state.controller = new AbortController();
appFetch(state.path, {
method: state.method,
headers: state.headers,
body,
signal: state.controller.signal,
})
.then(async (response) => {
const type = this.responseType;
const value =
type === 'arraybuffer'
? await response.arrayBuffer()
: type === 'blob'
? await response.blob()
: await response.text();
const text = typeof value === 'string' ? value : null;
let parsed = value;
if (type === 'json') {
try {
parsed = JSON.parse(text);
} catch {
parsed = null;
}
} else if (type === 'document')
parsed = new DOMParser().parseFromString(text, 'text/html');
const headers = [...response.headers]
.map(([name, val]) => `${name}: ${val}`)
.join('\r\n');
setState(this, {
readyState: 4,
status: response.status,
statusText: response.statusText,
response: parsed,
responseText: type === '' || type === 'text' ? text : '',
responseURL: new URL(state.path, document.baseURI).href,
getAllResponseHeaders: () => headers,
getResponseHeader: (name) => response.headers.get(name),
});
for (const name of ['readystatechange', 'load', 'loadend'])
this.dispatchEvent(
name === 'readystatechange'
? new Event(name)
: new ProgressEvent(name),
);
})
.catch((error) => {
setState(this, { readyState: 4, status: 0 });
const name = error?.name === 'AbortError' ? 'abort' : 'error';
for (const event of ['readystatechange', name, 'loadend'])
this.dispatchEvent(
event === 'readystatechange'
? new Event(event)
: new ProgressEvent(event),
);
});
};
function setState(target, values) {
for (const [name, value] of Object.entries(values))
Object.defineProperty(target, name, {
configurable: true,
...(typeof value === 'function' ? { value } : { get: () => value }),
});
}
const nativeBeacon = navigator.sendBeacon?.bind(navigator);
if (nativeBeacon)
navigator.sendBeacon = (url, data) => {
const path = appPath(url);
if (path === null) return nativeBeacon(url, data);
appFetch(path, { method: 'POST', body: data }).catch(() => {});
return true;
};
// Images name app files by URL; give them the file as a data: URL.
const image = Object.getOwnPropertyDescriptor(
HTMLImageElement.prototype,
'src',
);
Object.defineProperty(HTMLImageElement.prototype, 'src', {
...image,
set(value) {
const path = appPath(value);
if (path === null) return image.set.call(this, value);
appFileUrl(path).then(
(url) => image.set.call(this, url),
() => this.dispatchEvent(new Event('error')),
);
},
});
// Workers on the app's server start from a small worker here that is
// handed the script once it has loaded; messages wait until then.
const NativeWorker = window.Worker;
const BOOT =
'const early=[];let ready=false;' +
"addEventListener('message',e=>{if(e.data&&e.data.__gevScript){" +
'const go=()=>{ready=true;for(const d of early)dispatchEvent(new MessageEvent("message",{data:d}))};' +
'if(e.data.module)import(e.data.__gevScript).then(go);else{importScripts(e.data.__gevScript);go()}' +
'e.stopImmediatePropagation();return}' +
'if(!ready){early.push(e.data);e.stopImmediatePropagation()}});';
window.Worker = function Worker(url, options) {
const path = appPath(url);
if (path === null || String(url).startsWith('blob:'))
return new NativeWorker(url, options);
const worker = new NativeWorker(
URL.createObjectURL(new Blob([BOOT], { type: 'text/javascript' })),
options,
);
appFetch(path)
.then((response) => response.blob())
.then((blob) => URL.createObjectURL(blob))
.then((script) =>
worker.postMessage({
__gevScript: script,
module: options?.type === 'module',
}),
);
return worker;
};
window.Worker.prototype = NativeWorker.prototype;
// Some hosts drop the GPU contents of a panel's 2D canvases while they
// consider the panel off screen, and the overlays then cover the globe
// in black. Keep 2D canvases in memory instead, which hosts do not drop.
const getContext = HTMLCanvasElement.prototype.getContext;
HTMLCanvasElement.prototype.getContext = function (type, options) {
return type === '2d'
? getContext.call(this, type, { ...options, willReadFrequently: true })
: getContext.call(this, type, options);
};
// Markup the app adds as HTML text would request its images from this
// page's site at once; hold their addresses until the files are here.
const htmlWithHeldImages = (html) =>
String(html).replace(
/(<(?:img|source)\b[^>]*?\s)src=(["'])([^"']*)\2/gi,
(tag, before, quote, value) =>
appPath(value) === null
? tag
: `${before}data-gev-src=${quote}${value}${quote}`,
);
const fillHeldImages = (root) => {
for (const element of root?.querySelectorAll?.('[data-gev-src]') ?? []) {
const path = appPath(element.getAttribute('data-gev-src'));
element.removeAttribute('data-gev-src');
appFileUrl(path).then((url) => element.setAttribute('src', url));
}
};
const innerHtml = Object.getOwnPropertyDescriptor(
Element.prototype,
'innerHTML',
);
Object.defineProperty(Element.prototype, 'innerHTML', {
...innerHtml,
set(value) {
innerHtml.set.call(this, htmlWithHeldImages(value));
fillHeldImages(this);
},
});
const insertHtml = Element.prototype.insertAdjacentHTML;
Element.prototype.insertAdjacentHTML = function (position, html) {
insertHtml.call(this, position, htmlWithHeldImages(html));
fillHeldImages(this.parentNode ?? this);
};
// Markup and styles the app adds later name app files too.
new MutationObserver((records) => {
for (const record of records)
for (const node of record.addedNodes) {
if (node.nodeType !== 1) continue;
if (node.nodeName === 'STYLE') void inlineStyleUrls(node);
for (const element of [node, ...node.querySelectorAll('img[src]')]) {
if (element.nodeName !== 'IMG') continue;
const path = appPath(element.getAttribute('src'));
if (path !== null)
appFileUrl(path).then((url) => element.setAttribute('src', url));
}
}
}).observe(document.documentElement, { childList: true, subtree: true });
}
/** Replace url() references to app files in CSS with data: URLs. */
async function cssWithFiles(text, baseUrl) {
const found = new Map();
for (const match of text.matchAll(/url\(\s*(['"]?)([^'")]+)\1\s*\)/g)) {
const reference = match[2];
if (/^(data|blob):/.test(reference)) continue;
const path = appPath(new URL(reference, baseUrl).href);
if (path !== null && !found.has(reference))
found.set(
reference,
appFileUrl(path).catch(() => null),
);
}
let result = text;
for (const [reference, loading] of found) {
const url = await loading;
if (url) result = result.split(reference).join(url);
}
return result;
}
async function inlineStyleUrls(style) {
if (!/url\(/.test(style.textContent)) return;
style.textContent = await cssWithFiles(style.textContent, document.baseURI);
}
async function addStylesheet(href) {
const path = appPath(href);
const style = document.createElement('style');
document.head.appendChild(style);
if (path === null) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = href;
style.replaceWith(link);
return;
}
const text = await (await appFetch(path)).text();
style.textContent = await cssWithFiles(
text,
new URL(path, document.baseURI).href,
);
}
function runScript(text, type) {
const script = document.createElement('script');
if (type) script.type = type;
script.textContent = text;
document.body.appendChild(script);
}
/**
* Load God's Eye View into this page from the app's panel build, in
* inline embed mode, starting at the first view as its link would.
*/
async function startApp(url, view) {
window.GEV_EMBED_INLINE = true;
window.GEV_APP_BASE_URL = config.appBaseUrl;
try {
history.replaceState(
null,
'',
location.href.split('#')[0] + new URL(url).hash,
);
// The app restores this view from its link, following included, so
// it needs applying again only for what links cannot carry: cockpit.
if (queued === view && !view.follow?.cockpit) queued = null;
} catch {
// The view still arrives once the app is ready.
}
installRequests();
const response = await appFetch(config.panelBase);
if (!response.ok)
throw new Error(
response.status === 404
? 'the app has no panel build (npm run build:panel)'
: `the app answered ${response.status}`,
);
const built = new DOMParser().parseFromString(
await response.text(),
'text/html',
);
window.CESIUM_BASE_URL = `${config.panelBase}cesium/`;
const [prelude] = await Promise.all([
appFetch(config.panelBase + config.workerPreludePath).then((r) =>
r.text(),
),
...[...built.head.querySelectorAll('link[rel="stylesheet"]')].map(
(link) => addStylesheet(link.getAttribute('href')),
),
]);
// Cesium's script sets CESIUM_WORKERS, its workers as one script it
// starts from memory. Workers request files themselves, which this page
// cannot answer, so the prelude, holding those files, runs ahead of it.
let workers;
Object.defineProperty(window, 'CESIUM_WORKERS', {
configurable: true,
get: () => workers,
set: (script) => {
workers = prelude + script;
},
});
// Images in the markup load their files once added; give them the
// files instead of letting them request this page's site.
const images = [];
for (const image of built.body.querySelectorAll('img[src]')) {
const path = appPath(image.getAttribute('src'));
if (path === null) continue;
image.removeAttribute('src');
images.push([image, path]);
}
for (const node of [...built.body.childNodes]) {
if (node.nodeName !== 'SCRIPT')
document.body.insertBefore(document.adoptNode(node), status);
}
for (const [image, path] of images)
appFileUrl(path).then((url) => image.setAttribute('src', url));
const scripts = await Promise.all(
[...built.querySelectorAll('script')].map(async (script) => ({
type: script.type,
text: script.getAttribute('src')
? await (await appFetch(appPath(script.getAttribute('src')))).text()
: script.textContent,
})),
);
for (const { type, text } of scripts) runScript(text, type);
// Browsers cap live 3D contexts across pages and take one back when
// there are too many; say so rather than show no map.
document.addEventListener(
'webglcontextlost',
() => {
const notice = document.createElement('div');
notice.id = 'status';
notice.textContent =
"This panel lost its 3D graphics, likely because other God's Eye " +
"View panels in this conversation hold them. Use Open in God's " +
'Eye View above, or show it in a new conversation.';
document.body.appendChild(notice);
},
{ capture: true, once: true },
);
}
// Tool results: the first view starts the app, later ones move it.
let started = false;
let ready = false;
let queued = null;
let currentUrl = null;
const postView = (view) =>
window.postMessage({ type: 'gev:view', id: nextId++, view }, '*');
function show(result) {
const data = result?.structuredContent;
const view = data?.view;
const url = data?.url || view?.url;
if (!view || !url) return;
currentUrl = url;
open.hidden = false;
if (ready) return postView(view);
queued = view;
if (started) return;
started = true;
say("Loading God's Eye View…");
startApp(url, view).catch((error) =>
say(`God's Eye View could not load here: ${error?.message || error}.`),
);
setTimeout(() => {
if (!ready)
say(
"God's Eye View did not load here" +
(blocked ? ` (${blocked})` : '') +
". Use Open in God's Eye View above.",
);
}, config.loadTimeoutMs);
}
window.addEventListener('message', (event) => {
const message = event.data;
if (event.source === window) {
if (message?.type === 'gev:ready') {
ready = true;
status.remove();
if (queued) postView(queued);
queued = null;
}
return;
}
if (event.source !== window.parent || message?.jsonrpc !== '2.0') return;
if (
message.id !== undefined &&
pending.has(message.id) &&
!message.method
) {
const { resolve, reject } = pending.get(message.id);
pending.delete(message.id);
if (message.error) reject(message.error);
else resolve(message.result);
return;
}
if (message.method === 'ui/notifications/tool-result') show(message.params);
if (message.method === 'ui/notifications/host-context-changed')
showDisplayMode(message.params);
});
// Fullscreen, where the host offers it, gives the globe the room.
let displayMode = 'inline';
const showDisplayMode = (context) => {
if (context?.displayMode) displayMode = context.displayMode;
if (context?.availableDisplayModes)
expand.hidden = !context.availableDisplayModes.includes('fullscreen');
expand.textContent = displayMode === 'fullscreen' ? 'Collapse' : 'Expand';
};
expand.addEventListener('click', () => {
request('ui/request-display-mode', {
mode: displayMode === 'fullscreen' ? 'inline' : 'fullscreen',
}).then(
(result) => showDisplayMode({ displayMode: result?.mode }),
() => {},
);
});
open.addEventListener('click', () => {
if (!currentUrl) return;
request('ui/open-link', { url: currentUrl }).catch(() =>
window.open(currentUrl, '_blank', 'noopener'),
);
});
// The handshake the MCP Apps SDK's App.connect performs: hosts validate
// these parameters, and keep a view hidden until it is initialized.
request('ui/initialize', {
appCapabilities: { availableDisplayModes: ['inline', 'fullscreen'] },
appInfo: {
name: 'gods-eye-view',
title: "God's Eye View",
version: '1.0.0',
},
protocolVersion: config.protocolVersion,
}).then(
(result) => {
showDisplayMode(result?.hostContext);
send({ method: 'ui/notifications/initialized' });
notify('ui/notifications/size-changed', {
width: document.body.clientWidth,
height: config.panelHeight,
});
},
(error) =>
say(
"This client did not accept the God's Eye View panel" +
(error?.message ? `: ${error.message}` : '.'),
),
);
}
+13 -1
View File
@@ -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 };
}
+3
View File
@@ -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,
+25
View File
@@ -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 });
});
+3 -7
View File
@@ -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.
+1
View File
@@ -192,6 +192,7 @@ test('consumer build includes only infrastructure code and resolves assets under
const sources = Object.keys(entry.modules).filter((id) => id.endsWith('.js'));
assert.deepEqual(sources.map((id) => id.split('/').at(-1)).sort(), [
'infrastructure.js',
'infrastructureData.js',
'infrastructureOverlayEntry.js',
'localGeojsonCore.js',
'localGeojsonLod.js',
+2 -723
View File
@@ -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;
}
}
+1 -1
View File
@@ -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';
+742
View File
@@ -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;
}
}
+7 -82
View File
@@ -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 };
+11 -3
View File
@@ -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);
}
+10
View File
@@ -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);
});
+3
View File
@@ -211,6 +211,9 @@ export function shouldShowFirstRun({
if (hasShareState) return false;
const params = new URLSearchParams(location?.search || '');
if (params.get('welcome') === '0') return false;
// An embedded globe never shows the welcome launcher.
if (params.get('embed') === '1' || globalThis.GEV_EMBED_INLINE === true)
return false;
// The demo/support escape hatch outranks both suppressions on purpose.
if (params.get('welcome') === '1') return true;
if (readStored('local', storage, FIRST_RUN_STORAGE_KEY) === 'suppressed')
+5 -1
View File
@@ -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.
}
+1
View File
@@ -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';
+8 -119
View File
@@ -1,36 +1,12 @@
import {
extractStationsArray,
normalizeGbfsBool,
parseStationInformation,
parseStationStatus,
toNonNegativeInteger,
} from '../../sources/gbfsStations.js';
export function createModel({ state: layerState, services, parts, source }) {
/**
* Coerce a GBFS boolean field to a native boolean.
* GBFS feeds are inconsistent — some use booleans, others use 0/1 or strings.
* @param {*} value - Raw field value from GBFS JSON.
* @param {boolean} [fallback=true] - Default when value is null/undefined/unrecognized.
* @returns {boolean}
*/
function normalizeGbfsBool(value, fallback = true) {
if (value == null) return fallback;
if (typeof value === 'boolean') return value;
const num = Number(value);
if (Number.isFinite(num)) return num !== 0;
const text = String(value).trim().toLowerCase();
if (text === 'true' || text === 'yes') return true;
if (text === 'false' || text === 'no') return false;
return fallback;
}
/**
* Parse a value as a non-negative integer, returning fallback on failure.
* @param {*} value - Raw numeric value.
* @param {number|null} [fallback=null] - Returned when value is not a valid non-negative number.
* @returns {number|null}
*/
function toNonNegativeInteger(value, fallback = null) {
const n = Number(value);
if (!Number.isFinite(n) || n < 0) return fallback;
return Math.round(n);
}
/**
* Build a globally unique render key for a station.
* @param {string} cityId - City identifier.
@@ -63,93 +39,6 @@ export function createModel({ state: layerState, services, parts, source }) {
return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
/**
* Extract the stations array from a GBFS JSON payload.
* Handles multiple response shapes: { data: { stations: [...] } },
* { data: [...] }, and nested objects with a stations sub-key.
* @param {Object} payload - Parsed GBFS JSON response.
* @returns {Object[]} Array of raw station objects (may be empty).
*/
function extractStationsArray(payload) {
const data = payload?.data;
if (Array.isArray(data?.stations)) return data.stations;
if (Array.isArray(data)) return data;
// Some feeds nest stations under an additional key (e.g. locale wrappers)
if (data && typeof data === 'object') {
for (const value of Object.values(data)) {
if (Array.isArray(value?.stations)) return value.stations;
}
}
return [];
}
/**
* Parse a GBFS station_information payload into a Map of station metadata.
* Handles field-name variations across different GBFS providers
* (station_id vs id, lat vs latitude, etc.). Stations with missing or
* invalid coordinates are silently skipped.
* @param {Object} payload - Parsed station_information.json response.
* @returns {Map<string, Object>} Map of stationId to station info objects.
*/
function parseStationInformation(payload) {
const stations = extractStationsArray(payload);
const stationMap = new Map();
for (const raw of stations) {
// Accept both GBFS 2.x (station_id) and alternate (id) field names
const stationId = String(raw?.station_id ?? raw?.id ?? '').trim();
const lat = Number(raw?.lat ?? raw?.latitude);
const lon = Number(raw?.lon ?? raw?.longitude);
if (!stationId || !Number.isFinite(lat) || !Number.isFinite(lon))
continue;
stationMap.set(stationId, {
stationId,
name: String(raw?.name || raw?.short_name || '').trim(),
lat,
lon,
capacity: toNonNegativeInteger(raw?.capacity),
isInstalled: normalizeGbfsBool(raw?.is_installed, true),
isRenting: normalizeGbfsBool(raw?.is_renting, true),
isReturning: normalizeGbfsBool(raw?.is_returning, true),
});
}
return stationMap;
}
/**
* Parse a GBFS station_status payload into a Map of real-time availability.
* @param {Object} payload - Parsed station_status.json response.
* @returns {Map<string, Object>} Map of stationId to status objects containing
* bikesAvailable, docksAvailable, and operational flags.
*/
function parseStationStatus(payload) {
const stations = extractStationsArray(payload);
const statusMap = new Map();
for (const raw of stations) {
const stationId = String(raw?.station_id ?? raw?.id ?? '').trim();
if (!stationId) continue;
const bikes = toNonNegativeInteger(raw?.num_bikes_available);
const docks = toNonNegativeInteger(raw?.num_docks_available);
statusMap.set(stationId, {
stationId,
bikesAvailable: bikes,
docksAvailable: docks,
isInstalled: normalizeGbfsBool(raw?.is_installed, true),
isRenting: normalizeGbfsBool(raw?.is_renting, true),
isReturning: normalizeGbfsBool(raw?.is_returning, true),
lastReported: toNonNegativeInteger(raw?.last_reported),
});
}
return statusMap;
}
return {
normalizeGbfsBool,
toNonNegativeInteger,
+13
View File
@@ -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 };
},
};
}
+25
View File
@@ -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/);
});
+14 -8
View File
@@ -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(
+11 -3
View File
@@ -2,8 +2,8 @@
export function createLaunchSource({
fetchImpl = (...args) => globalThis.fetch(...args),
} = {}) {
return {
async getLaunches({ signal } = {}) {
/** 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}`);
@@ -11,7 +11,15 @@ export function createLaunchSource({
signal?.throwIfAborted();
if (!Array.isArray(payload) && !Array.isArray(payload?.results))
throw new Error('Malformed launch snapshot');
return payload;
return {
payload,
stale: response.headers?.get?.('x-gev-cache') === 'STALE-ERROR',
};
}
return {
getLaunchSnapshot,
async getLaunches(options) {
return (await getLaunchSnapshot(options)).payload;
},
async getActiveTle({ signal } = {}) {
signal?.throwIfAborted();
+2 -18
View File
@@ -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.
+4 -1
View File
@@ -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 };
},
};
}
+14 -4
View File
@@ -25,21 +25,31 @@ export function createFlowTileSource({
maxTiles: 16,
});
let partial = false;
return {
async fetchFlowForBounds(bounds, { signal, zoom = 12 } = {}) {
/**
* 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;
if (bounds.coverage) return result.tiles.flat();
return result.tiles.flat().flatMap((segment) =>
const segments = bounds.coverage
? result.tiles.flat()
: result.tiles.flat().flatMap((segment) =>
clipTileLine(segment.coords, bounds).map((coords) => ({
...segment,
coords,
})),
);
return { segments, partial: result.partial === true };
}
return {
fetchFlowDetail,
async fetchFlowForBounds(bounds, options) {
return (await fetchFlowDetail(bounds, options)).segments;
},
getFlowSessionStats: () => ({ ...tiles.getStats(), partial }),
resetFlowTileCache: () => tiles.clear(),
+15
View File
@@ -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);
+46
View File
@@ -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',
);
});
+14
View File
@@ -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 };
},
};
}
+37
View File
@@ -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/,
);
});
+1 -1
View File
@@ -61,7 +61,7 @@ function uiBlock(start, end) {
/** A ShareLinkManager over a synthetic hash — enough surface for parseInitialHash. */
function managerForHash(hash) {
globalThis.window = { location: { hash, href: `http://localhost/${hash}` } };
globalThis.history = { replaceState(_s, _t, next) { window.location.hash = next; } };
globalThis.history = { replaceState(_s, _t, next) { window.location.hash = new URL(next, window.location.href).hash; } };
const viewer = {
camera: {
changed: { addEventListener() {} },
+2 -2
View File
@@ -34,8 +34,8 @@ function assertClaimsBefore(block, mutation, label) {
function makeManager(hash = '') {
globalThis.window = { location: { hash, href: `http://localhost/${hash}` } };
globalThis.history = {
replaceState(_state, _title, nextHash) {
window.location.hash = nextHash;
replaceState(_state, _title, next) {
window.location.hash = new URL(next, window.location.href).hash;
},
};
const viewer = {
+10 -11
View File
@@ -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. */
+24
View File
@@ -0,0 +1,24 @@
/** Event packs the app serves under `/events/<id>/`, read once per id. */
const EVENT_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
export function createEventPackSource({
fetchImpl = (...args) => globalThis.fetch(...args),
} = {}) {
const cache = new Map();
return {
async getEvent(id, { signal } = {}) {
if (!EVENT_ID.test(id)) throw new TypeError(`Invalid event id: ${id}`);
if (!cache.has(id)) {
signal?.throwIfAborted();
const response = await fetchImpl(`/events/${id}/event.json`, {
signal,
});
if (!response.ok)
throw new Error(`Event pack ${id} unavailable (${response.status})`);
cache.set(id, await response.json());
}
return cache.get(id);
},
};
}
+115
View File
@@ -0,0 +1,115 @@
/** Parse GBFS station documents, tolerating field-name variations across providers. */
/**
* Coerce a GBFS boolean field to a native boolean.
* GBFS feeds are inconsistent — some use booleans, others use 0/1 or strings.
* @param {*} value - Raw field value from GBFS JSON.
* @param {boolean} [fallback=true] - Default when value is null/undefined/unrecognized.
* @returns {boolean}
*/
export function normalizeGbfsBool(value, fallback = true) {
if (value == null) return fallback;
if (typeof value === 'boolean') return value;
const num = Number(value);
if (Number.isFinite(num)) return num !== 0;
const text = String(value).trim().toLowerCase();
if (text === 'true' || text === 'yes') return true;
if (text === 'false' || text === 'no') return false;
return fallback;
}
/**
* Parse a value as a non-negative integer, returning fallback on failure.
* @param {*} value - Raw numeric value.
* @param {number|null} [fallback=null] - Returned when value is not a valid non-negative number.
* @returns {number|null}
*/
export function toNonNegativeInteger(value, fallback = null) {
const n = Number(value);
if (!Number.isFinite(n) || n < 0) return fallback;
return Math.round(n);
}
/**
* Extract the stations array from a GBFS JSON payload.
* Handles multiple response shapes: { data: { stations: [...] } },
* { data: [...] }, and nested objects with a stations sub-key.
* @param {Object} payload - Parsed GBFS JSON response.
* @returns {Object[]} Array of raw station objects (may be empty).
*/
export function extractStationsArray(payload) {
const data = payload?.data;
if (Array.isArray(data?.stations)) return data.stations;
if (Array.isArray(data)) return data;
// Some feeds nest stations under an additional key (e.g. locale wrappers)
if (data && typeof data === 'object') {
for (const value of Object.values(data)) {
if (Array.isArray(value?.stations)) return value.stations;
}
}
return [];
}
/**
* Parse a GBFS station_information payload into a Map of station metadata.
* Handles field-name variations across different GBFS providers
* (station_id vs id, lat vs latitude, etc.). Stations with missing or
* invalid coordinates are silently skipped.
* @param {Object} payload - Parsed station_information.json response.
* @returns {Map<string, Object>} Map of stationId to station info objects.
*/
export function parseStationInformation(payload) {
const stations = extractStationsArray(payload);
const stationMap = new Map();
for (const raw of stations) {
// Accept both GBFS 2.x (station_id) and alternate (id) field names
const stationId = String(raw?.station_id ?? raw?.id ?? '').trim();
const lat = Number(raw?.lat ?? raw?.latitude);
const lon = Number(raw?.lon ?? raw?.longitude);
if (!stationId || !Number.isFinite(lat) || !Number.isFinite(lon)) continue;
stationMap.set(stationId, {
stationId,
name: String(raw?.name || raw?.short_name || '').trim(),
lat,
lon,
capacity: toNonNegativeInteger(raw?.capacity),
isInstalled: normalizeGbfsBool(raw?.is_installed, true),
isRenting: normalizeGbfsBool(raw?.is_renting, true),
isReturning: normalizeGbfsBool(raw?.is_returning, true),
});
}
return stationMap;
}
/**
* Parse a GBFS station_status payload into a Map of real-time availability.
* @param {Object} payload - Parsed station_status.json response.
* @returns {Map<string, Object>} Map of stationId to status objects containing
* bikesAvailable, docksAvailable, and operational flags.
*/
export function parseStationStatus(payload) {
const stations = extractStationsArray(payload);
const statusMap = new Map();
for (const raw of stations) {
const stationId = String(raw?.station_id ?? raw?.id ?? '').trim();
if (!stationId) continue;
const bikes = toNonNegativeInteger(raw?.num_bikes_available);
const docks = toNonNegativeInteger(raw?.num_docks_available);
statusMap.set(stationId, {
stationId,
bikesAvailable: bikes,
docksAvailable: docks,
isInstalled: normalizeGbfsBool(raw?.is_installed, true),
isRenting: normalizeGbfsBool(raw?.is_renting, true),
isReturning: normalizeGbfsBool(raw?.is_returning, true),
lastReported: toNonNegativeInteger(raw?.last_reported),
});
}
return statusMap;
}
+163
View File
@@ -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);
},
};
}
+27
View File
@@ -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;
}
+3
View File
@@ -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(),
+29
View File
@@ -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;
}
+7 -1
View File
@@ -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 },
});
}
+54
View File
@@ -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;
+57
View File
@@ -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"\}/);
});
+116
View File
@@ -0,0 +1,116 @@
import assert from 'node:assert/strict';
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import * as PANEL_PATHS from '../tools/globePanel.js';
import {
PANEL_BASE,
PANEL_WORKER_PRELUDE_PATH,
panelBuildConfig,
panelBuildPlugin,
workerFilesPrelude,
} from '../../build/panel.js';
test('the panel loads the paths the panel build writes', () => {
assert.equal(PANEL_BASE, PANEL_PATHS.PANEL_BASE);
assert.equal(
PANEL_WORKER_PRELUDE_PATH,
PANEL_PATHS.PANEL_WORKER_PRELUDE_PATH,
);
});
test('the panel build is one script and one stylesheet under /panel/', () => {
const config = panelBuildConfig({
plugins: [],
build: { chunkSizeWarningLimit: 1500 },
});
assert.equal(config.base, PANEL_BASE);
assert.equal(config.build.outDir, 'dist/panel');
assert.equal(config.build.cssCodeSplit, false);
assert.equal(config.build.rollupOptions.output.inlineDynamicImports, true);
assert.equal(config.build.chunkSizeWarningLimit, 1500);
});
test('the dev server serves the panel build and nothing outside it', async () => {
const root = await mkdtemp(join(tmpdir(), 'panel-build-'));
try {
await mkdir(join(root, 'out', 'assets'), { recursive: true });
await writeFile(join(root, 'out', 'index.html'), '<p>panel</p>');
await writeFile(join(root, 'out', 'assets', 'a.js'), 'x');
await writeFile(join(root, 'secret.txt'), 'secret');
let middleware;
panelBuildPlugin({ outDir: 'out' }).configureServer({
config: { root },
middlewares: { use: (fn) => (middleware = fn) },
});
const get = (url) =>
new Promise((resolve) => {
const res = {
statusCode: 200,
headers: {},
setHeader(name, value) {
this.headers[name] = value;
},
end(body) {
resolve({ status: this.statusCode, headers: this.headers, body });
},
};
middleware({ url }, res, () => resolve({ passed: true }));
});
const page = await get('/panel/');
assert.equal(String(page.body), '<p>panel</p>');
assert.equal(page.headers['Content-Type'], 'text/html');
const script = await get('/panel/assets/a.js');
assert.equal(script.headers['Content-Type'], 'text/javascript');
assert.equal((await get('/panel/missing.js')).status, 404);
assert.equal((await get('/panel/%ZZ')).status, 400);
assert.deepEqual(await get('/panel/..%2Fsecret.txt'), { passed: true });
assert.deepEqual(await get('/other'), { passed: true });
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("Cesium's workers get embedded files from memory and other requests from the network", async () => {
const sent = [];
class FakeRequest extends EventTarget {
open(method, url) {
this.url = url;
}
setRequestHeader() {}
send() {
sent.push(this.url);
}
}
const saved = {
XMLHttpRequest: globalThis.XMLHttpRequest,
ProgressEvent: globalThis.ProgressEvent,
};
globalThis.XMLHttpRequest = FakeRequest;
globalThis.ProgressEvent ??= class extends Event {};
try {
new Function(workerFilesPrelude({ 'Assets/heights.json': '{"a":1}' }))();
const embedded = new XMLHttpRequest();
embedded.open(
'GET',
'https://panel.example/panel/cesium/Assets/heights.json?v=1',
);
embedded.setRequestHeader('Accept', 'application/json');
embedded.responseType = 'text';
const loaded = new Promise((resolve) =>
embedded.addEventListener('load', resolve),
);
embedded.send();
await loaded;
assert.equal(embedded.status, 200);
assert.equal(embedded.response, '{"a":1}');
const other = new XMLHttpRequest();
other.open('GET', 'https://tiles.example/1.png');
other.send();
assert.deepEqual(sent, ['https://tiles.example/1.png']);
} finally {
globalThis.XMLHttpRequest = saved.XMLHttpRequest;
globalThis.ProgressEvent = saved.ProgressEvent;
}
});
+62 -4
View File
@@ -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'",
});
});
+357
View File
@@ -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))
);
});
}
+264
View File
@@ -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);
});
+226
View File
@@ -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);
},
});
}
+234
View File
@@ -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/,
);
});
+28
View File
@@ -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 } : {}),
};
}
+50
View File
@@ -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,
},
);
});
+128
View File
@@ -0,0 +1,128 @@
/**
* The God's Eye View panel: an MCP Apps view (`io.modelcontextprotocol/ui`) that shows
* God's Eye View inside a conversation. The panel is a small page that runs
* the app's panel build inside itself in embed mode, loading it through the
* MCP server, and sends it each view the show_in_gods_eye_view tool returns;
* see docs/TOOLS.md.
*/
export const GLOBE_PANEL_URI = 'ui://gods-eye-view/globe';
/** Where the app's server serves its panel build (see build/panel.js). */
export const PANEL_BASE = '/panel/';
/** The script the panel runs ahead of Cesium's workers, in the build. */
export const PANEL_WORKER_PRELUDE_PATH = 'cesium/worker-prelude.js';
/**
* Sites the app's browser code loads from directly: map imagery, 3D tiles,
* terrain and fonts. Everything from the app's own server comes through the
* MCP server.
*/
const PROVIDER_ORIGINS = Object.freeze([
'https://tile.googleapis.com',
'https://maps.googleapis.com',
'https://api.cesium.com',
'https://assets.ion.cesium.com',
'https://assets.cesium.com',
'https://dev.virtualearth.net',
'https://ecn.t0.tiles.virtualearth.net',
'https://ecn.t1.tiles.virtualearth.net',
'https://ecn.t2.tiles.virtualearth.net',
'https://ecn.t3.tiles.virtualearth.net',
'https://services.arcgisonline.com',
'https://server.arcgisonline.com',
'https://tile.openstreetmap.org',
'https://tiles.openfreemap.org',
'https://gibs.earthdata.nasa.gov',
'https://fonts.googleapis.com',
'https://fonts.gstatic.com',
]);
export const MCP_APP_MIME_TYPE = 'text/html;profile=mcp-app';
const MCP_APPS_PROTOCOL_VERSION = '2026-01-26';
const PANEL_HEIGHT_PX = 520;
const LOAD_TIMEOUT_MS = 90_000;
/**
* The app's address inside the panel, for code that needs an https address
* (the page's own may use a host's scheme). The panel loads its paths
* through the MCP server; the name never resolves.
*/
const PANEL_APP_BASE_URL = 'https://app.gods-eye-view.invalid/';
/** The panel-only tool the panel loads the app through. */
export const PANEL_REQUEST_TOOL = 'panel_request';
/**
* The panel page: its status line, the Open in God's Eye View button, and
* the panel's script, `runtime`, which loads the app through the MCP server
* and shows each view a tool returns.
*/
function panelHtml(runtime) {
const config = {
appBaseUrl: PANEL_APP_BASE_URL,
loadTimeoutMs: LOAD_TIMEOUT_MS,
panelBase: PANEL_BASE,
panelHeight: PANEL_HEIGHT_PX,
protocolVersion: MCP_APPS_PROTOCOL_VERSION,
toolName: PANEL_REQUEST_TOOL,
workerPreludePath: PANEL_WORKER_PRELUDE_PATH,
};
const script = `(${runtime})(${JSON.stringify(config)});`.replace(
/<\/script/gi,
'<\\/script',
);
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>God's Eye View</title>
<style>
html, body { margin: 0; height: 100%; min-height: ${PANEL_HEIGHT_PX}px; background: #05070a;
color: #b8c4cc; font: 13px/1.4 system-ui, sans-serif; overflow: hidden; }
#status { position: absolute; inset: 0; z-index: 10000; display: flex; align-items: center;
justify-content: center; padding: 0 24px; text-align: center; }
#actions { position: absolute; right: 10px; top: 10px; z-index: 10001; display: flex; gap: 6px; }
#actions button { padding: 6px 10px; border: 1px solid #3a4a55; border-radius: 6px;
background: rgba(5, 7, 10, 0.75); color: #dfe8ee; font: inherit; cursor: pointer; }
#actions button[hidden] { display: none; }
</style>
</head>
<body>
<div id="status">Waiting for a view…</div>
<div id="actions">
<button id="expand" type="button" hidden>Expand</button>
<button id="open" type="button" hidden>Open in God's Eye View</button>
</div>
<script>${script}</script>
</body>
</html>
`;
}
/**
* The panel as an MCP resource. `runtime` is the panel's script, a function
* of its configuration that runs in the panel page; the application supplies
* it (src/app/globePanelRuntime.js), as it is browser code. The security
* policy lets the panel load map imagery, tiles and fonts from their
* providers; everything from the app's own server arrives through the MCP
* server instead.
*/
export function createGlobePanelResource({ runtime }) {
if (typeof runtime !== 'function')
throw new TypeError('The globe panel needs its runtime script');
return Object.freeze({
uri: GLOBE_PANEL_URI,
name: 'globe',
title: "God's Eye View globe",
description: "Live God's Eye View, showing the view a tool returns.",
mimeType: MCP_APP_MIME_TYPE,
text: panelHtml(runtime),
_meta: {
ui: {
csp: {
resourceDomains: [...PROVIDER_ORIGINS],
connectDomains: [...PROVIDER_ORIGINS],
},
prefersBorder: true,
},
},
});
}
+110
View File
@@ -0,0 +1,110 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
GLOBE_PANEL_URI,
MCP_APP_MIME_TYPE,
createGlobePanelResource,
} from './globePanel.js';
import { panelRuntime } from '../app/globePanelRuntime.js';
import { composeCatalog, coreTools } from './index.js';
test('the globe panel is an MCP Apps resource that loads the app through its server', () => {
const resource = createGlobePanelResource({ runtime: panelRuntime });
assert.equal(resource.uri, GLOBE_PANEL_URI);
assert.equal(resource.mimeType, MCP_APP_MIME_TYPE);
assert.equal(MCP_APP_MIME_TYPE, 'text/html;profile=mcp-app');
const { csp } = resource._meta.ui;
// The panel reaches only map and font providers; the app's own files
// and data come through the MCP server, so no app address is declared.
assert.deepEqual(csp.connectDomains, csp.resourceDomains);
assert.ok(csp.resourceDomains.includes('https://tile.googleapis.com'));
assert.ok(
csp.resourceDomains.every((origin) => origin.startsWith('https://')),
);
assert.equal(csp.frameDomains, undefined);
assert.equal(csp.baseUriDomains, undefined);
assert.match(resource.text, /"toolName":"panel_request"/);
assert.match(resource.text, /"panelBase":"\/panel\/"/);
assert.match(resource.text, /'ui\/initialize'/);
assert.match(resource.text, /'ui\/notifications\/tool-result'/);
assert.match(resource.text, /"protocolVersion":"2026-01-26"/);
// The MCP Apps SDK's initialize parameters; hosts reject anything else.
assert.match(resource.text, /appInfo: \{/);
assert.doesNotMatch(resource.text, /clientInfo/);
// Fullscreen where the host offers it.
assert.match(resource.text, /'ui\/request-display-mode'/);
assert.match(resource.text, /<button id="expand" type="button" hidden>/);
assert.doesNotMatch(resource.text, /<iframe|createElement\('base'\)/);
const script = resource.text.match(/<script>([\s\S]*)<\/script>/)[1];
assert.doesNotThrow(() => new Function(script));
});
test('show_in_gods_eye_view names the panel and shows a view another answer returned', async () => {
const catalog = composeCatalog({
tools: coreTools,
services: { app: { baseUrl: 'http://localhost:5173/' } },
});
assert.deepEqual(catalog.get('show_in_gods_eye_view').ui, {
resourceUri: GLOBE_PANEL_URI,
});
const earlier = {
camera: {
lat: 25,
lon: 121,
altitude_m: 300000,
heading_deg: 0,
pitch_deg: -90,
},
layers: ['ais-live-vessels'],
style: null,
map: null,
follow: null,
annotations: [],
url: 'http://localhost:5173/#v=2',
};
const shown = await catalog.call('show_in_gods_eye_view', {
view: earlier,
layers: ['ais-live-vessels', 'military'],
style: 'thermal',
});
assert.deepEqual(shown.data.view.camera, earlier.camera);
assert.deepEqual(shown.data.view.layers, ['ais-live-vessels', 'military']);
assert.equal(shown.data.view.style, 'thermal');
await assert.rejects(
catalog.call('show_in_gods_eye_view', { view: { camera: { lat: 'x' } } }),
(error) => error.code === 'invalid_arguments',
);
});
test('a view that only follows an aircraft is framed where the aircraft is', async () => {
const { resolveViewArguments } = await import('./queries/share.js');
const tools = {
has: (name) => name === 'find_aircraft',
call: async (name, args) => ({
data: {
rows:
args.icao24 === 'ae1234'
? [{ id: 'ae1234', lat: 32.7, lon: -117.2 }]
: [],
},
}),
};
const { view, label } = await resolveViewArguments(
{ follow: { kind: 'military_aircraft', id: 'ae1234', cockpit: true } },
{ services: {}, tools },
);
assert.deepEqual(
[view.camera.lat, view.camera.lon, view.follow.cockpit],
[32.7, -117.2, true],
);
assert.equal(label, 'military aircraft ae1234');
await assert.rejects(
resolveViewArguments(
{ follow: { kind: 'aircraft', id: 'abc999' } },
{ services: {}, tools },
),
(error) =>
error.code === 'invalid_arguments' &&
/not reported now/.test(error.message),
);
});
+127
View File
@@ -0,0 +1,127 @@
/**
* Tools that answer questions from God's Eye View data, independent of the
* surface that exposes them. See docs/TOOLS.md.
*/
import {
aircraftInArea,
findAircraft,
getAircraftInfo,
getAircraftTrack,
} from './queries/aviation.js';
import { getWeatherMap, getWind } from './queries/atmosphere.js';
import { findSubmarineCables } from './queries/cables.js';
import {
getHudCaption,
militaryAwareness,
situationBrief,
} from './queries/brief.js';
import {
findMilitaryInstallations,
getCyclones,
getFirePerimeters,
getMapFeatures,
getRegionalBrief,
getTerrainHeight,
getWeather,
} from './queries/environment.js';
import { getBhoteKoshiFlood } from './queries/events.js';
import { getActiveFires, getEarthquakes } from './queries/hazards.js';
import { getRecentImagery } from './queries/imagery.js';
import { findInfrastructure } from './queries/infrastructure.js';
import {
findVessel,
getVesselTrack,
vesselsInArea,
} from './queries/maritime.js';
import {
getBikeShare,
getTrafficFlow,
getTransitVehicles,
} from './queries/mobility.js';
import { placesNearby, planRoute, searchPlaces } from './queries/places.js';
import { panelRequest } from './queries/panelRequest.js';
import { showInGodsEyeView } from './queries/share.js';
import { findAlprCameras } from './queries/surveillance.js';
import {
findCctvCameras,
findRadioStations,
getCctvSnapshot,
} from './queries/media.js';
import {
getRecentLaunches,
nextSatellitePass,
satellitesOverhead,
} from './queries/space.js';
export {
defineTool,
composeCatalog,
ToolError,
TOOL_ERROR_CODES,
} from './catalog.js';
export {
AREA_SCHEMA,
resolveArea,
areaCenter,
areaContains,
distanceKm,
} from './area.js';
export { LIMIT_SCHEMA, DEFAULT_LIMIT, MAX_LIMIT, capRows } from './results.js';
export { toFunctionOutput, toFunctionTools } from './functions.js';
export {
SURFACES,
TOOL_SURFACES,
catalogForSurface,
toolsForSurface,
} from './surfaces.js';
export {
createGeocodePlaceService,
createPlaceSearchService,
createRouteService,
placeFromGeocodeResult,
} from './places.js';
/** Every query Core defines, in a stable order. */
export const coreTools = Object.freeze([
getEarthquakes,
getActiveFires,
getRecentLaunches,
aircraftInArea,
findAircraft,
getAircraftTrack,
getAircraftInfo,
vesselsInArea,
findVessel,
getVesselTrack,
nextSatellitePass,
satellitesOverhead,
findCctvCameras,
getCctvSnapshot,
findAlprCameras,
findRadioStations,
searchPlaces,
placesNearby,
planRoute,
getBikeShare,
getTransitVehicles,
getTrafficFlow,
getWeather,
getWeatherMap,
getWind,
getRecentImagery,
findSubmarineCables,
findInfrastructure,
getBhoteKoshiFlood,
getRegionalBrief,
getCyclones,
getFirePerimeters,
getTerrainHeight,
findMilitaryInstallations,
getMapFeatures,
situationBrief,
militaryAwareness,
getHudCaption,
showInGodsEyeView,
panelRequest,
]);
+166
View File
@@ -0,0 +1,166 @@
import assert from 'node:assert/strict';
import { createServer, request as httpRequest } from 'node:http';
import test from 'node:test';
import { isLocalMcpRequest, localMcpPlugin } from '../../server/mcp/plugin.js';
test('only loopback connections with loopback hosts and origins are local', () => {
const local = { remoteAddress: '127.0.0.1', host: 'localhost:4173' };
assert.equal(isLocalMcpRequest(local), true);
assert.equal(
isLocalMcpRequest({ ...local, remoteAddress: '::1', host: '[::1]:4173' }),
true,
);
assert.equal(
isLocalMcpRequest({ ...local, origin: 'http://127.0.0.1:5173' }),
true,
);
for (const request of [
{ ...local, remoteAddress: '192.168.1.20' },
{ ...local, host: 'attacker.example:4173' },
{ ...local, host: 'localhost.attacker.example' },
{ ...local, host: '' },
{ ...local, origin: 'https://attacker.example' },
{ ...local, origin: 'null' },
{ ...local, origin: 'file:///tmp/page.html' },
])
assert.equal(isLocalMcpRequest(request), false, JSON.stringify(request));
});
test('the /mcp route answers local MCP requests and refuses others', async (t) => {
let middleware;
const created = [];
const plugin = localMcpPlugin({
createServer: ({ apiBase }) => {
created.push(apiBase);
return {
handle: async (message) =>
message.id === undefined
? null
: { jsonrpc: '2.0', id: message.id, result: { apiBase } },
};
},
});
plugin.configureServer({
middlewares: { use: (path, handler) => (middleware = handler) },
});
const http = createServer((req, res) => middleware(req, res));
await new Promise((resolve) => http.listen(0, '127.0.0.1', resolve));
t.after(() => http.close());
const { port } = http.address();
const send = (method, body, headers = {}) =>
new Promise((resolve, reject) => {
const request = httpRequest(
{
host: '127.0.0.1',
port,
path: '/mcp',
method,
headers: {
'Content-Type': 'application/json',
Host: `localhost:${port}`,
...headers,
},
},
(response) => {
let text = '';
response.on('data', (chunk) => (text += chunk));
response.on('end', () =>
resolve({
status: response.statusCode,
json: () => JSON.parse(text),
}),
);
},
);
request.on('error', reject);
request.end(
body === undefined
? undefined
: typeof body === 'string'
? body
: JSON.stringify(body),
);
});
const post = (body, headers) => send('POST', body, headers);
const ok = await post({ jsonrpc: '2.0', id: 1, method: 'ping' });
assert.equal(ok.status, 200);
assert.deepEqual(await ok.json(), {
jsonrpc: '2.0',
id: 1,
result: { apiBase: `http://localhost:${port}` },
});
assert.equal(
(await post({ jsonrpc: '2.0', method: 'notifications/initialized' }))
.status,
202,
);
await post({ jsonrpc: '2.0', id: 2, method: 'ping' });
assert.deepEqual(created, [`http://localhost:${port}`]);
const foreign = await post(
{ jsonrpc: '2.0', id: 3, method: 'ping' },
{ Origin: 'https://attacker.example' },
);
assert.equal(foreign.status, 403);
assert.deepEqual(await foreign.json(), {
error: 'The MCP server only accepts local requests',
});
assert.equal((await post({}, { Host: 'attacker.example' })).status, 403);
assert.equal(
(await post({}, { Host: `localhost.attacker.example:${port}` })).status,
403,
);
assert.equal((await post('x'.repeat(1024 * 1024 + 1))).status, 413);
assert.equal(
(
await fetch(`http://127.0.0.1:${port}/mcp`, {
headers: { Host: `localhost:${port}` },
})
).status,
405,
);
});
test('a client that disconnects cancels its tool call', async (t) => {
let middleware;
let seen;
const started = Promise.withResolvers();
const aborted = Promise.withResolvers();
const plugin = localMcpPlugin({
createServer: () => ({
handle: (message, { signal }) => {
seen = signal;
signal.addEventListener('abort', () => aborted.resolve(), {
once: true,
});
started.resolve();
return new Promise(() => {});
},
}),
});
plugin.configureServer({
middlewares: { use: (path, handler) => (middleware = handler) },
});
const http = createServer((req, res) => middleware(req, res));
await new Promise((resolve) => http.listen(0, '127.0.0.1', resolve));
t.after(() => http.close());
const { port } = http.address();
const request = httpRequest({
host: '127.0.0.1',
port,
path: '/mcp',
method: 'POST',
headers: {
'Content-Type': 'application/json',
Host: `localhost:${port}`,
},
});
request.on('error', () => {});
request.end(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call' }));
await started.promise;
assert.equal(seen.aborted, false);
request.destroy();
await aborted.promise;
assert.equal(seen.aborted, true);
});
+133
View File
@@ -0,0 +1,133 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { PassThrough } from 'node:stream';
import { createLocalMcpServer } from '../../server/mcp/server.js';
import { createApiFetch } from '../../server/mcp/services.js';
import { parseArgs, serveStdio } from '../../server/mcp/stdio.js';
import { coreTools, toolsForSurface } from './index.js';
const usgs = {
type: 'FeatureCollection',
features: [
{
id: 'us1',
geometry: { type: 'Point', coordinates: [121.5, 24, 10] },
properties: { mag: 5.1, place: 'near Hualien', time: 1767229200000 },
},
],
};
test('relative API requests resolve against the configured base', async () => {
const seen = [];
const apiFetch = createApiFetch({
apiBase: 'http://127.0.0.1:5000',
fetchImpl: async (url) => seen.push(String(url)),
});
await apiFetch('/api/launches');
await apiFetch('https://earthquake.usgs.gov/feed.geojson');
assert.deepEqual(seen, [
'http://127.0.0.1:5000/api/launches',
'https://earthquake.usgs.gov/feed.geojson',
]);
assert.throws(
() => createApiFetch({ apiBase: 'file:///etc' }),
/http\(s\) URL/,
);
});
test('the stdio server answers newline-delimited requests using only its data sources', async () => {
const contacted = [];
const server = createLocalMcpServer({
apiBase: 'http://127.0.0.1:5000',
fetchImpl: async (url) => {
contacted.push(new URL(url).origin);
return Response.json(usgs);
},
});
const input = new PassThrough();
const output = new PassThrough();
let written = '';
output.on('data', (chunk) => (written += chunk));
const served = serveStdio(server, { input, output });
input.write(
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}\n',
);
input.write('{"jsonrpc":"2.0","method":"notifications/initialized"}\n\n');
input.write('not json\n');
input.write('{"jsonrpc":"2.0","id":2,"method":"tools/list"}\n');
input.end(
'{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_earthquakes","arguments":{}}}\n',
);
await served;
const responses = written
.trim()
.split('\n')
.map((line) => JSON.parse(line));
const byId = new Map(responses.map((response) => [response.id, response]));
assert.equal(responses.length, 4);
assert.equal(byId.get(1).result.serverInfo.name, 'gods-eye-view');
assert.equal(byId.get(null).error.code, -32700);
// Every Core tool's services are composed locally.
assert.deepEqual(
byId.get(2).result.tools.map((tool) => tool.name),
toolsForSurface(coreTools, 'mcp').map((tool) => tool.name),
);
assert.equal(
byId.get(3).result.content[0].text,
'1 earthquake of M2.5+ in the last 24 hours worldwide; strongest M5.1 near Hualien.',
);
// The earthquake feed is fetched directly; nothing else was contacted.
assert.deepEqual(contacted, ['https://earthquake.usgs.gov']);
});
test('command-line arguments are strict', () => {
assert.deepEqual(parseArgs([]), {});
assert.deepEqual(parseArgs(['--api-base', 'http://localhost:5173']), {
apiBase: 'http://localhost:5173',
});
assert.throws(() => parseArgs(['--port', '1']), /Unknown argument/);
});
test('the stdio log names methods, tools and resources but never arguments', async () => {
const { describeRequest } = await import('../../server/mcp/stdio.js');
assert.equal(describeRequest({ method: 'tools/list' }), 'tools/list');
assert.equal(
describeRequest({
method: 'tools/call',
params: { name: 'get_weather', arguments: { location: { place: 'x' } } },
}),
'tools/call get_weather',
);
assert.equal(
describeRequest({ method: 'resources/read', params: { uri: 'ui://a/b' } }),
'resources/read ui://a/b',
);
assert.equal(describeRequest({ result: {} }), null);
});
test('a failed tool call is logged with its reason', async () => {
const server = {
handle: async (message) => ({
jsonrpc: '2.0',
id: message.id,
result: { isError: true, content: [{ type: 'text', text: 'bad area' }] },
}),
};
const input = new PassThrough();
const output = new PassThrough();
output.resume();
const logged = [];
const served = serveStdio(server, {
input,
output,
log: (line) => logged.push(line),
});
input.end(
'{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"show_in_gods_eye_view"}}\n',
);
await served;
assert.deepEqual(logged, [
'tools/call show_in_gods_eye_view',
' tools/call show_in_gods_eye_view failed: bad area',
]);
});
+78
View File
@@ -0,0 +1,78 @@
/**
* Stateless Streamable HTTP transport for an MCP server: one JSON-RPC message
* per POST, answered with a JSON body. Uses standard Request and Response so
* any web-standard runtime can mount it. The host owns routing and any access
* control in front of it.
*/
import { MCP_PROTOCOL_VERSIONS, parseErrorResponse } from './protocol.js';
const MAX_BODY_BYTES = 1024 * 1024;
/** Return `(request) => Promise<Response>` for an MCP server. */
export function createMcpHttpHandler(server) {
return async function handleMcpRequest(request) {
if (request.method !== 'POST')
return new Response(null, { status: 405, headers: { Allow: 'POST' } });
const version = request.headers.get('mcp-protocol-version');
if (version && !MCP_PROTOCOL_VERSIONS.includes(version))
return json(400, {
error: `Unsupported MCP protocol version: ${version}`,
});
const type = request.headers.get('content-type') || '';
if (!/^application\/json\b/i.test(type))
return json(415, { error: 'Content-Type must be application/json' });
const declared = Number(request.headers.get('content-length'));
if (declared > MAX_BODY_BYTES)
return json(413, { error: 'Request too large' });
const text = await readCapped(request, MAX_BODY_BYTES);
if (text === null) return json(413, { error: 'Request too large' });
let message;
try {
message = JSON.parse(text);
} catch {
return json(400, parseErrorResponse());
}
const response = await server.handle(message, { signal: request.signal });
return response ? json(200, response) : new Response(null, { status: 202 });
};
}
/**
* Read a request body as text, stopping as soon as it passes `limit` bytes,
* so a body without a Content-Length cannot be buffered without bound.
* Returns null when the body is too large.
*/
async function readCapped(request, limit) {
if (!request.body) return '';
const reader = request.body.getReader();
const chunks = [];
let size = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
size += value.byteLength;
if (size > limit) {
await reader.cancel().catch(() => {});
return null;
}
chunks.push(value);
}
const bytes = new Uint8Array(size);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return new TextDecoder().decode(bytes);
}
function json(status, body) {
return new Response(JSON.stringify(body), {
status,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
},
});
}
+9
View File
@@ -0,0 +1,9 @@
/** Model Context Protocol adapter for a tool catalog. See docs/TOOLS.md. */
export {
createMcpServer,
toMcpTools,
parseErrorResponse,
MCP_PROTOCOL_VERSIONS,
} from './protocol.js';
export { createMcpHttpHandler } from './http.js';
+315
View File
@@ -0,0 +1,315 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { composeCatalog, defineTool, ToolError } from '../catalog.js';
import {
createMcpHttpHandler,
createMcpServer,
MCP_PROTOCOL_VERSIONS,
toMcpTools,
} from './index.js';
const tool = (name, run) =>
defineTool({
name,
title: 'Count',
description: 'Counts things.',
inputSchema: {
type: 'object',
properties: { n: { type: 'integer' } },
additionalProperties: false,
},
run,
});
const catalog = composeCatalog({
tools: [
tool('count', async ({ n = 1 }) => ({
summary: `${n} things.`,
data: { n },
})),
tool('busy', async () => {
throw new ToolError('retry_later', 'Upstream is busy', {
retryAfterSeconds: 10,
});
}),
tool('crash', async () => {
throw new Error('database password is hunter2');
}),
],
});
const server = createMcpServer({
catalog,
name: 'test',
version: '1.0.0',
instructions: 'Be brief.',
});
const request = (method, params, id = 1) =>
server.handle({ jsonrpc: '2.0', id, method, params });
test('initialize negotiates a supported protocol version', async () => {
const known = await request('initialize', { protocolVersion: '2025-06-18' });
assert.deepEqual(known.result, {
protocolVersion: '2025-06-18',
capabilities: { tools: { listChanged: false } },
serverInfo: { name: 'test', version: '1.0.0' },
instructions: 'Be brief.',
});
const unknown = await request('initialize', {
protocolVersion: '1999-01-01',
});
assert.equal(unknown.result.protocolVersion, MCP_PROTOCOL_VERSIONS[0]);
assert.deepEqual((await request('ping')).result, {});
});
test('notifications and client responses get no reply', async () => {
assert.equal(
await server.handle({
jsonrpc: '2.0',
method: 'notifications/initialized',
}),
null,
);
assert.equal(
await server.handle({ jsonrpc: '2.0', id: 9, result: {} }),
null,
);
});
test('invalid messages and unknown methods are JSON-RPC errors', async () => {
assert.equal(
(await server.handle({ id: 1, method: 'ping' })).error.code,
-32600,
);
assert.equal(
(await server.handle([{ jsonrpc: '2.0', id: 1, method: 'ping' }])).error
.code,
-32600,
);
assert.equal(
(await server.handle({ jsonrpc: '2.0', id: 1 })).error.code,
-32600,
);
assert.equal((await request('tools/remove')).error.code, -32601);
assert.equal((await request('toString')).error.code, -32601);
assert.equal((await request('tools/list', [])).error.code, -32602);
assert.equal(
(await request('tools/call', { name: 'missing' })).error.code,
-32602,
);
});
test('tools are listed with titles, schemas and annotations, with surface overrides', async () => {
const { tools } = (await request('tools/list')).result;
assert.deepEqual(tools[0], {
name: 'count',
title: 'Count',
description: 'Counts things.',
inputSchema: {
type: 'object',
properties: { n: { type: 'integer' } },
additionalProperties: false,
},
annotations: { title: 'Count', readOnlyHint: true },
});
const [custom] = toMcpTools(catalog, {
descriptions: { count: { description: 'Counts for this surface.' } },
decorate: (definition, source) => ({
_meta: { kind: source.kind, seen: definition.name },
}),
});
assert.equal(custom.description, 'Counts for this surface.');
assert.deepEqual(custom._meta, { kind: 'query', seen: 'count' });
});
test('tool results carry text and structured content; failures stay generic', async () => {
assert.deepEqual(
(await request('tools/call', { name: 'count', arguments: { n: 3 } }))
.result,
{
content: [
{ type: 'text', text: '3 things.' },
{ type: 'text', text: '{"n":3}' },
],
structuredContent: { n: 3 },
isError: false,
},
);
assert.deepEqual((await request('tools/call', { name: 'busy' })).result, {
content: [{ type: 'text', text: 'Upstream is busy' }],
structuredContent: { error: 'retry_later', retry_after_seconds: 10 },
isError: true,
});
const invalid = (
await request('tools/call', { name: 'count', arguments: { n: 'x' } })
).result;
assert.equal(invalid.isError, true);
assert.equal(invalid.structuredContent.error, 'invalid_arguments');
const crash = (await request('tools/call', { name: 'crash' })).result;
assert.equal(crash.isError, true);
assert.equal(JSON.stringify(crash).includes('hunter2'), false);
});
test('the HTTP transport accepts one JSON message per POST', async () => {
const handle = createMcpHttpHandler(server);
const post = (body, headers = {}) =>
handle(
new Request('http://localhost/mcp', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
body: typeof body === 'string' ? body : JSON.stringify(body),
}),
);
const ok = await post({ jsonrpc: '2.0', id: 1, method: 'ping' });
assert.equal(ok.status, 200);
assert.equal(ok.headers.get('content-type'), 'application/json');
assert.deepEqual(await ok.json(), { jsonrpc: '2.0', id: 1, result: {} });
assert.equal(
(await post({ jsonrpc: '2.0', method: 'notifications/initialized' }))
.status,
202,
);
const parse = await post('{');
assert.equal(parse.status, 400);
assert.equal((await parse.json()).error.code, -32700);
assert.equal(
(await post({}, { 'MCP-Protocol-Version': '1999-01-01' })).status,
400,
);
assert.equal((await post({}, { 'Content-Type': 'text/plain' })).status, 415);
assert.equal((await post('x'.repeat(1024 * 1024 + 1))).status, 413);
// A streamed body without Content-Length stops being read past the limit.
let produced = 0;
const chunk = new Uint8Array(64 * 1024).fill(32);
const body = new ReadableStream({
pull(controller) {
if (produced >= 8 * 1024 * 1024) return controller.close();
produced += chunk.byteLength;
controller.enqueue(chunk);
},
});
const streamed = await handle(
new Request('http://localhost/mcp', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
duplex: 'half',
}),
);
assert.equal(streamed.status, 413);
assert.ok(produced <= 1024 * 1024 + 4 * chunk.byteLength, `read ${produced}`);
const get = await handle(new Request('http://localhost/mcp'));
assert.equal(get.status, 405);
assert.equal(get.headers.get('allow'), 'POST');
});
test('resources are listed and read, and tools name their UI resource', async () => {
const shown = defineTool({
name: 'show',
title: 'Show',
description: 'Shows something in a view.',
inputSchema: { type: 'object', properties: {} },
ui: { resourceUri: 'ui://fixture/view' },
run: async () => ({ summary: 'shown', data: {} }),
});
const withUi = createMcpServer({
catalog: composeCatalog({ tools: [shown] }),
name: 'fixture',
version: '1',
resources: [
{
uri: 'ui://fixture/view',
name: 'view',
mimeType: 'text/html;profile=mcp-app',
text: '<!doctype html><p>view</p>',
_meta: { ui: { csp: { frameDomains: ['https://a.example'] } } },
},
],
});
const call = async (method, params) =>
(await withUi.handle({ jsonrpc: '2.0', id: 1, method, params })).result ??
(await withUi.handle({ jsonrpc: '2.0', id: 1, method, params })).error;
assert.deepEqual((await call('initialize', {})).capabilities, {
tools: { listChanged: false },
resources: { listChanged: false },
});
assert.deepEqual((await call('tools/list')).tools[0]._meta, {
ui: { resourceUri: 'ui://fixture/view' },
'ui/resourceUri': 'ui://fixture/view',
});
assert.deepEqual((await call('resources/list')).resources, [
{
uri: 'ui://fixture/view',
name: 'view',
mimeType: 'text/html;profile=mcp-app',
_meta: { ui: { csp: { frameDomains: ['https://a.example'] } } },
},
]);
assert.deepEqual(
(await call('resources/read', { uri: 'ui://fixture/view' })).contents,
[
{
uri: 'ui://fixture/view',
mimeType: 'text/html;profile=mcp-app',
text: '<!doctype html><p>view</p>',
_meta: { ui: { csp: { frameDomains: ['https://a.example'] } } },
},
],
);
assert.equal(
(await call('resources/read', { uri: 'ui://fixture/other' })).code,
-32002,
);
// A server without resources does not advertise them.
assert.equal(
(await server.handle({ jsonrpc: '2.0', id: 1, method: 'initialize' }))
.result.capabilities.resources,
undefined,
);
assert.throws(
() =>
defineTool({
name: 'bad',
title: 'Bad',
description: 'Points at a web page.',
inputSchema: { type: 'object' },
ui: { resourceUri: 'https://a.example' },
run: async () => ({ summary: '', data: {} }),
}),
/ui:\/\/ URI/,
);
assert.throws(
() =>
defineTool({
name: 'bad',
title: 'Bad',
description: 'Visible to nobody.',
inputSchema: { type: 'object' },
ui: { visibility: ['user'] },
run: async () => ({ summary: '', data: {} }),
}),
/visibility/,
);
});
test('a tool only an app may call says so, and answers it without JSON text', async () => {
const loader = defineTool({
name: 'load',
title: 'Load',
description: 'Loads a file for the view.',
inputSchema: { type: 'object', properties: {} },
ui: { visibility: ['app'] },
run: async () => ({ summary: 'loaded', data: { body: 'abc' } }),
});
const appServer = createMcpServer({
catalog: composeCatalog({ tools: [loader] }),
name: 'fixture',
version: '1',
});
const call = async (method, params) =>
(await appServer.handle({ jsonrpc: '2.0', id: 1, method, params })).result;
assert.deepEqual((await call('tools/list')).tools[0]._meta, {
ui: { visibility: ['app'] },
});
const result = await call('tools/call', { name: 'load', arguments: {} });
assert.deepEqual(result.content, [{ type: 'text', text: 'loaded' }]);
assert.deepEqual(result.structuredContent, { body: 'abc' });
});
+217
View File
@@ -0,0 +1,217 @@
/**
* Model Context Protocol server for a tool catalog: JSON-RPC 2.0 handling of
* initialize, ping, tools/list and tools/call, and resources/list and
* resources/read for the resources an application supplies, such as MCP Apps
* views (`ui://` resources) that tools name in `_meta.ui.resourceUri`. Transports (HTTP, stdio) pass
* parsed messages in and send the returned responses out.
*/
import { ToolError } from '../catalog.js';
/** Protocol revisions this server implements, newest first. */
export const MCP_PROTOCOL_VERSIONS = Object.freeze([
'2025-11-25',
'2025-06-18',
'2025-03-26',
]);
const PARSE_ERROR = -32700;
const INVALID_REQUEST = -32600;
const METHOD_NOT_FOUND = -32601;
const INVALID_PARAMS = -32602;
const INTERNAL_ERROR = -32603;
const RESOURCE_NOT_FOUND = -32002;
/**
* Describe catalog tools as MCP tool definitions.
*
* `descriptions` may override a tool's `title` and `description` by name.
* `decorate(definition, tool)` may return extra fields to merge into each
* definition, such as application metadata.
*/
export function toMcpTools(catalog, { descriptions = {}, decorate } = {}) {
return catalog.list().map((tool) => {
const override = descriptions[tool.name] || {};
const title = override.title ?? tool.title;
const definition = {
name: tool.name,
title,
description: override.description ?? tool.description,
inputSchema: tool.inputSchema,
annotations: { title, ...tool.annotations },
...(tool.ui
? {
_meta: {
ui: { ...tool.ui },
// The flat key is the earlier form of the same field, which
// some hosts still read; the MCP Apps SDK sends both.
...(tool.ui.resourceUri
? { 'ui/resourceUri': tool.ui.resourceUri }
: {}),
},
}
: {}),
};
return decorate
? { ...definition, ...decorate(definition, tool) }
: definition;
});
}
/**
* Create a protocol handler. `handle(message, { signal })` resolves to the
* JSON-RPC response, or null for notifications.
*/
export function createMcpServer({
catalog,
name,
version,
instructions,
descriptions,
decorate,
resources = [],
}) {
const tools = () => toMcpTools(catalog, { descriptions, decorate });
const resourceByUri = new Map(
resources.map((resource) => [resource.uri, resource]),
);
const methods = {
initialize(params) {
const requested = params?.protocolVersion;
return {
protocolVersion: MCP_PROTOCOL_VERSIONS.includes(requested)
? requested
: MCP_PROTOCOL_VERSIONS[0],
capabilities: {
tools: { listChanged: false },
...(resources.length ? { resources: { listChanged: false } } : {}),
},
serverInfo: { name, version },
...(instructions ? { instructions } : {}),
};
},
ping: () => ({}),
'tools/list': () => ({ tools: tools() }),
'resources/list': () => ({
resources: resources.map(({ text, ...entry }) => entry),
}),
'resources/read'(params) {
const resource = resourceByUri.get(params?.uri);
if (!resource)
throw rpcError(RESOURCE_NOT_FOUND, `Unknown resource: ${params?.uri}`);
const { uri, mimeType, text, _meta } = resource;
return {
contents: [{ uri, mimeType, text, ...(_meta ? { _meta } : {}) }],
};
},
async 'tools/call'(params, { signal }) {
if (typeof params?.name !== 'string')
throw rpcError(INVALID_PARAMS, 'tools/call needs a tool name');
if (!catalog.get(params.name))
throw rpcError(INVALID_PARAMS, `Unknown tool: ${params.name}`);
try {
const result = await catalog.call(params.name, params.arguments ?? {}, {
signal,
});
// Only an app reads a tool the model cannot call, and apps read
// structuredContent.
const appOnly =
catalog.get(params.name).ui?.visibility?.includes('model') === false;
return {
// The data is repeated as JSON text for clients that do not read
// structuredContent.
content: [
{ type: 'text', text: result.summary },
...(appOnly
? []
: [{ type: 'text', text: JSON.stringify(result.data) }]),
...(result.images || []).map((item) => ({
type: 'image',
data: item.data,
mimeType: item.mimeType,
})),
],
structuredContent: result.data,
isError: false,
};
} catch (error) {
if (signal?.aborted) throw error;
// Expected failures are reported to the model so it can adjust; other
// errors are not described, so internal details never leave the server.
const known = error instanceof ToolError;
return {
content: [
{
type: 'text',
text: known ? error.message : 'The tool failed; try again later',
},
],
structuredContent: {
error: known ? error.code : 'unavailable',
...(known && error.retryAfterSeconds != null
? { retry_after_seconds: error.retryAfterSeconds }
: {}),
},
isError: true,
};
}
},
};
return Object.freeze({
async handle(message, { signal } = {}) {
if (!isObject(message) || message.jsonrpc !== '2.0')
return failure(
message?.id ?? null,
INVALID_REQUEST,
'Invalid JSON-RPC message',
);
const isRequest = message.id !== undefined;
if (typeof message.method !== 'string')
// Responses from the client are accepted and ignored: this server
// sends no requests of its own.
return isRequest &&
message.result === undefined &&
message.error === undefined
? failure(message.id, INVALID_REQUEST, 'Missing method')
: null;
if (!isRequest) return null;
const method = Object.hasOwn(methods, message.method)
? methods[message.method]
: null;
if (!method)
return failure(
message.id,
METHOD_NOT_FOUND,
`Unknown method: ${message.method}`,
);
if (message.params !== undefined && !isObject(message.params))
return failure(message.id, INVALID_PARAMS, 'params must be an object');
try {
const result = await method(message.params, { signal });
return { jsonrpc: '2.0', id: message.id, result };
} catch (error) {
if (error?.rpcCode)
return failure(message.id, error.rpcCode, error.message);
return failure(message.id, INTERNAL_ERROR, 'Internal error');
}
},
});
}
/** A JSON-RPC parse-error response, for transports that cannot parse input. */
export function parseErrorResponse() {
return failure(null, PARSE_ERROR, 'Parse error');
}
function failure(id, code, message) {
return { jsonrpc: '2.0', id, error: { code, message } };
}
function rpcError(code, message) {
return Object.assign(new Error(message), { rpcCode: code });
}
function isObject(value) {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
+210
View File
@@ -0,0 +1,210 @@
/**
* Place services backed by the app's own routes. Applications with different
* providers can supply any objects with the same contracts:
*
* - `places.resolve(name, { signal })` turns a name into bounds (`/api/geocode`).
* - `placeSearch.search(query, near, { signal })` and
* `placeSearch.nearby(near, { signal })` find points of interest
* (`/api/google/*`), reporting whether search is configured.
* - `routing.route(points, profile, { signal })` plans a route (`/api/route`).
*/
import { ToolError } from './catalog.js';
const POINT_RADIUS_DEGREES = 0.25;
/**
* Resolve place names to `{ name, bounds: { west, south, east, north }, point }`
* or null. `point` is the place's own location, which can lie far from the
* middle of its bounds (a region with remote islands, for example).
*/
export function createGeocodePlaceService({
fetchImpl = (...args) => globalThis.fetch(...args),
cacheSize = 200,
cacheMs = 10 * 60_000,
timeoutMs = 15_000,
now = () => Date.now(),
} = {}) {
// Recent answers by name, so tools that combine others resolve a place to
// the same point once instead of once per section. A pending lookup is
// shared by its callers: it has its own deadline, is cancelled when every
// caller has given up, and only a successful answer stays cached.
const cache = new Map();
async function lookup(name, signal) {
const query = new URLSearchParams({ q: name });
const response = await fetchImpl(`/api/geocode?${query}`, { signal });
if (!response.ok) throw new Error(`Geocode HTTP ${response.status}`);
const payload = await response.json();
return placeFromGeocodeResult(payload?.results?.[0]);
}
function start(key, name) {
const controller = new AbortController();
const entry = {
at: now(),
controller,
waiters: 0,
settled: false,
place: lookup(
name,
AbortSignal.any([controller.signal, AbortSignal.timeout(timeoutMs)]),
),
};
entry.place.then(
() => {
entry.settled = true;
},
() => {
entry.settled = true;
if (cache.get(key) === entry) cache.delete(key);
},
);
cache.set(key, entry);
while (cache.size > cacheSize) cache.delete(cache.keys().next().value);
return entry;
}
function join(key, entry, signal) {
if (!signal) {
// A caller that cannot cancel keeps the lookup alive.
entry.waiters = Infinity;
return entry.place;
}
signal.throwIfAborted();
entry.waiters += 1;
return new Promise((resolve, reject) => {
const leave = () => {
entry.waiters -= 1;
reject(signal.reason);
if (entry.waiters <= 0 && !entry.settled) {
entry.controller.abort(signal.reason);
if (cache.get(key) === entry) cache.delete(key);
}
};
signal.addEventListener('abort', leave, { once: true });
entry.place
.then(resolve, reject)
.finally(() => signal.removeEventListener('abort', leave));
});
}
return {
async resolve(name, { signal } = {}) {
signal?.throwIfAborted();
const key = name.trim().toLowerCase();
let entry = cache.get(key);
if (entry && now() - entry.at < cacheMs) {
cache.delete(key);
cache.set(key, entry);
} else entry = start(key, name);
return join(key, entry, signal);
},
};
}
/** Convert one geocode result to a place with bounds, or null. */
export function placeFromGeocodeResult(result) {
const location = result?.geometry?.location;
if (!Number.isFinite(location?.lat) || !Number.isFinite(location?.lng))
return null;
const viewport = result.geometry.viewport;
const bounds =
viewport &&
[
viewport.southwest?.lat,
viewport.southwest?.lng,
viewport.northeast?.lat,
viewport.northeast?.lng,
].every(Number.isFinite)
? {
west: viewport.southwest.lng,
south: viewport.southwest.lat,
east: viewport.northeast.lng,
north: viewport.northeast.lat,
}
: {
west: Math.max(-180, location.lng - POINT_RADIUS_DEGREES),
south: Math.max(-90, location.lat - POINT_RADIUS_DEGREES),
east: Math.min(180, location.lng + POINT_RADIUS_DEGREES),
north: Math.min(90, location.lat + POINT_RADIUS_DEGREES),
};
return {
name: result.formatted_address || null,
bounds,
point: { lat: location.lat, lon: location.lng },
};
}
/** Search points of interest; `configured` is false when the server has no search key. */
export function createPlaceSearchService({
fetchImpl = (...args) => globalThis.fetch(...args),
} = {}) {
async function read(path, params, signal) {
const response = await fetchImpl(`${path}?${new URLSearchParams(params)}`, {
signal,
});
let payload = null;
try {
payload = await response.json();
} catch {
/* the status below is authoritative */
}
if (!response.ok) throw httpFailure(response, 'Place search');
return {
configured: payload?.configured !== false,
places: Array.isArray(payload?.places) ? payload.places : [],
};
}
return {
search(query, { latitude, longitude, radiusM }, { signal } = {}) {
return read(
'/api/google/text-search',
{ q: query, lat: latitude, lon: longitude, radiusM },
signal,
);
},
nearby({ latitude, longitude, radiusM }, { signal } = {}) {
return read(
'/api/google/nearby-places',
{ lat: latitude, lon: longitude, radiusM },
signal,
);
},
};
}
/** Plan routes between `[{ lat, lon }]` points; resolves `{ ok, ... }`. */
export function createRouteService({
fetchImpl = (...args) => globalThis.fetch(...args),
} = {}) {
return {
async route(points, profile, { signal } = {}) {
const coords = points
.map((point) => `${point.lon.toFixed(6)},${point.lat.toFixed(6)}`)
.join(';');
const response = await fetchImpl(
`/api/route?${new URLSearchParams({ profile, coords })}`,
{ signal },
);
let payload = null;
try {
payload = await response.json();
} catch {
/* the status below is authoritative */
}
if (response.status === 429) throw httpFailure(response, 'Routing');
if (payload?.ok === false || response.status === 400)
return { ok: false, error: payload?.error || 'no route found' };
if (!response.ok) throw httpFailure(response, 'Routing');
return payload;
},
};
}
function httpFailure(response, label) {
if (response.status === 429) {
const seconds = Number(response.headers.get('retry-after'));
return new ToolError('retry_later', `${label} is rate limited`, {
retryAfterSeconds:
Number.isFinite(seconds) && seconds > 0 ? seconds : null,
});
}
return new Error(`${label} HTTP ${response.status}`);
}
+95
View File
@@ -0,0 +1,95 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createGeocodePlaceService } from './places.js';
const answer = {
results: [
{
formatted_address: 'Tokyo, Japan',
geometry: { location: { lat: 35.68, lng: 139.76 } },
},
],
};
test('place lookups are cached by name for a while, and failures are not', async () => {
let clock = 0;
const requests = [];
let fail = true;
const places = createGeocodePlaceService({
now: () => clock,
cacheMs: 1000,
fetchImpl: async (url) => {
requests.push(url);
if (fail) return new Response(null, { status: 503 });
return Response.json(answer);
},
});
await assert.rejects(places.resolve('Tokyo'), /Geocode HTTP 503/);
fail = false;
const first = await places.resolve('Tokyo');
assert.deepEqual(first.point, { lat: 35.68, lon: 139.76 });
assert.equal(await places.resolve(' tokyo '), first);
assert.equal(requests.length, 2);
clock = 2000;
await places.resolve('Tokyo');
assert.equal(requests.length, 3);
const aborted = new AbortController();
aborted.abort();
await assert.rejects(places.resolve('Tokyo', { signal: aborted.signal }));
});
test('a lookup every caller abandoned is cancelled and not reused', async () => {
const signals = [];
const places = createGeocodePlaceService({
fetchImpl: (url, { signal }) => {
signals.push(signal);
if (signals.length === 1)
return new Promise((resolve, reject) =>
signal.addEventListener('abort', () => reject(signal.reason)),
);
return Promise.resolve(Response.json(answer));
},
});
const caller = new AbortController();
const first = places.resolve('Tokyo', { signal: caller.signal });
caller.abort();
await assert.rejects(first);
assert.equal(signals[0].aborted, true);
const again = await places.resolve('Tokyo');
assert.deepEqual(again.point, { lat: 35.68, lon: 139.76 });
assert.equal(signals.length, 2);
});
test('a shared lookup outlives one cancelled caller, and times out on its own', async () => {
let release;
const signals = [];
const places = createGeocodePlaceService({
timeoutMs: 50,
fetchImpl: (url, { signal }) => {
signals.push(signal);
if (url.includes('Slow'))
return new Promise((resolve, reject) =>
signal.addEventListener('abort', () => reject(signal.reason)),
);
return new Promise((resolve) => {
release = () => resolve(Response.json(answer));
});
},
});
const leaving = new AbortController();
const gone = places.resolve('Tokyo', { signal: leaving.signal });
const staying = places.resolve('Tokyo', {
signal: new AbortController().signal,
});
leaving.abort();
await assert.rejects(gone);
assert.equal(signals[0].aborted, false);
release();
assert.deepEqual((await staying).point, { lat: 35.68, lon: 139.76 });
await assert.rejects(
places.resolve('Slow'),
(error) => error.name === 'TimeoutError',
);
await places.resolve('Slow').catch(() => {});
assert.equal(signals.length, 3, 'a timed-out lookup is not cached');
});
+230
View File
@@ -0,0 +1,230 @@
/** Weather map imagery (radar, satellite, lightning) and wind at a point. */
import { suggestView } from '../views.js';
import { windFrom } from '../../layers/wind/inspection.js';
import { sampleWind, windSpeed } from '../../layers/wind/model.js';
import { defineTool, ToolError } from '../catalog.js';
import {
AREA_SCHEMA,
POINT_SCHEMA,
areaCenter,
areaRadiusKm,
resolveArea,
resolvePoint,
} from '../area.js';
import { toBase64 } from '../results.js';
/** Map names offered to callers, and the weather products behind them. */
const MAPS = {
radar: 'radar',
satellite: 'clouds-regional',
global_satellite: 'clouds',
lightning: 'lightning',
};
/** The app layer that shows each map. */
const MAP_LAYERS = {
radar: 'weather-radar',
satellite: 'weather-satellite',
global_satellite: 'weather-satellite',
lightning: 'weather-lightning',
};
const IMAGE_SIZE = { width: 1024, height: 512 };
const SNAP_DEGREES = 0.25;
const MIN_HALF_HEIGHT_DEGREES = 0.5;
const KM_PER_DEGREE = 111.2;
const MAX_IMAGE_BYTES = 4 * 1024 * 1024;
const snapDown = (value) => Math.floor(value / SNAP_DEGREES) * SNAP_DEGREES;
const snapUp = (value) => Math.ceil(value / SNAP_DEGREES) * SNAP_DEGREES;
const round = (value, digits) =>
Number.isFinite(value) ? Number(value.toFixed(digits)) : null;
/**
* A 2:1 box snapped to 0.25° around the area, moved inside the product's
* bounds. Returns null when no such box covers the whole area (it would not
* fit, or the area crosses the antimeridian), meaning the whole extent
* should be shown instead.
*/
export function weatherWindow(area, bounds) {
if (area.west > area.east) return null;
const center = areaCenter(area);
const halfHeight = Math.max(
MIN_HALF_HEIGHT_DEGREES,
areaRadiusKm(area) / KM_PER_DEGREE,
);
const height = snapUp(halfHeight * 2);
const width = height * 2;
if (width > bounds.east - bounds.west || height > bounds.north - bounds.south)
return null;
let west = snapDown(center.lon - width / 2);
let south = snapDown(center.lat - height / 2);
west = Math.min(
Math.max(west, snapUp(bounds.west)),
snapDown(bounds.east - width),
);
south = Math.min(
Math.max(south, snapUp(bounds.south)),
snapDown(bounds.north - height),
);
const mapBox = { west, south, east: west + width, north: south + height };
const insideProduct =
mapBox.west >= bounds.west &&
mapBox.east <= bounds.east &&
mapBox.south >= bounds.south &&
mapBox.north <= bounds.north;
// Moving the box inside the product can uncover part of the area; only a
// box that still holds the whole area (clipped to the product) will do.
const coversArea =
mapBox.west <= Math.max(area.west, bounds.west) &&
mapBox.east >= Math.min(area.east, bounds.east) &&
mapBox.south <= Math.max(area.south, bounds.south) &&
mapBox.north >= Math.min(area.north, bounds.north);
return insideProduct && coversArea ? mapBox : null;
}
export const getWeatherMap = defineTool({
name: 'get_weather_map',
title: 'Weather map',
description:
'The latest NOAA weather map image over an area: precipitation radar ' +
'(contiguous US), regional or global infrared satellite clouds, or ' +
'lightning density (the Americas). Returns the image itself.',
inputSchema: {
type: 'object',
properties: {
map: { type: 'string', enum: Object.keys(MAPS) },
area: AREA_SCHEMA,
},
required: ['map'],
additionalProperties: false,
},
requires: ['weatherMaps'],
async run(args, { services, signal }) {
const product = MAPS[args.map];
const snapshot = await services.weatherMaps.getSnapshot({
product,
signal,
});
if (snapshot.unavailable)
throw new ToolError(
'unavailable',
`The ${args.map} map is unavailable right now`,
);
const area = args.area
? await resolveArea(args.area, { services, signal })
: null;
let mapBox = null;
if (area) {
const { bounds } = snapshot;
const center = areaCenter(area);
if (
center.lon < bounds.west ||
center.lon > bounds.east ||
center.lat < bounds.south ||
center.lat > bounds.north
)
throw new ToolError(
'invalid_arguments',
`${area.label} is outside the ${args.map} map's coverage: ${snapshot.coverage || 'see its bounds'}`,
);
mapBox = weatherWindow(area, bounds);
}
const image = await services.weatherMaps.getImage({
product,
time: snapshot.latest,
size: IMAGE_SIZE,
bbox: mapBox,
signal,
});
if (!/^image\/(png|jpeg)$/.test(image.contentType))
throw new ToolError('unavailable', 'The weather map returned no image');
if (image.bytes.byteLength > MAX_IMAGE_BYTES)
throw new ToolError('unavailable', 'The weather map image is too large');
const where = area ? ` over ${area.label}` : '';
return {
summary: `${snapshot.title || args.map} at ${snapshot.latest}${where}.`,
data: {
view: suggestView(
services,
area
? { area, layers: [MAP_LAYERS[args.map]] }
: {
point: {
lat: (snapshot.bounds.south + snapshot.bounds.north) / 2,
lon: (snapshot.bounds.west + snapshot.bounds.east) / 2,
},
altitudeM: 8_000_000,
layers: [MAP_LAYERS[args.map]],
},
),
map: args.map,
title: snapshot.title ?? null,
description: snapshot.description ?? null,
coverage: snapshot.coverage ?? null,
time: snapshot.latest,
box: mapBox ?? snapshot.bounds,
source: snapshot.source ?? null,
attribution: snapshot.attribution ?? null,
},
images: [{ mimeType: image.contentType, data: toBase64(image.bytes) }],
};
},
});
export const getWind = defineTool({
name: 'get_wind',
title: 'Wind',
description:
'Forecast wind 10 m above ground at a location from the GFS or IFS ' +
'global model: speed and the direction it blows from.',
inputSchema: {
type: 'object',
properties: {
location: POINT_SCHEMA,
model: { type: 'string', enum: ['gfs', 'ifs'] },
},
required: ['location'],
additionalProperties: false,
},
requires: ['wind'],
async run(args, { services, signal }) {
const point = await resolvePoint(args.location, { services, signal });
const model = args.model ?? 'gfs';
const snapshot = await services.wind.getSnapshot({ model, signal });
if (snapshot.unavailable || !snapshot.u || !snapshot.v)
throw new ToolError(
'unavailable',
'Wind forecasts are unavailable right now',
);
const vector = sampleWind(
{ ...snapshot.grid, u: snapshot.u, v: snapshot.v },
point.lon,
point.lat,
);
const speed = windSpeed(vector.u, vector.v);
const from = windFrom(vector.u, vector.v);
const kph = speed * 3.6;
return {
summary:
`Wind at ${point.label}: ${kph.toFixed(0)} km/h` +
(from === 'Calm' ? ', calm.' : ` from the ${from}.`),
data: {
view: suggestView(services, {
point,
altitudeM: 2_000_000,
layers: ['wind'],
}),
location: point,
speed_mps: round(speed, 1),
speed_kph: round(kph, 1),
from: from === 'Calm' ? null : from,
calm: from === 'Calm',
u_mps: round(vector.u, 2),
v_mps: round(vector.v, 2),
level: snapshot.level ?? '10 m above ground',
model,
cycle: snapshot.cycle ?? null,
},
};
},
});
+174
View File
@@ -0,0 +1,174 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { composeCatalog, coreTools } from '../index.js';
import { weatherWindow } from './atmosphere.js';
import { resolveArea } from '../area.js';
const CONUS = { west: -130, south: 20, east: -60, north: 55 };
const PNG = Uint8Array.from([0x89, 0x50, 0x4e, 0x47]);
const weatherMaps = (snapshot = {}) => ({
requests: [],
async getSnapshot({ product }) {
return {
schemaVersion: 1,
product,
title: 'MRMS radar',
coverage: 'Contiguous United States',
source: 'NOAA nowCOAST',
attribution: 'NOAA',
bounds: CONUS,
times: ['2026-10-01T19:00:00.000Z'],
latest: '2026-10-01T19:00:00.000Z',
...snapshot,
};
},
async getImage(request) {
this.requests.push(request);
return { contentType: 'image/png', bytes: PNG };
},
});
test('weather windows are 2:1, snapped to 0.25° and kept inside the bounds', () => {
const austin = weatherWindow(
{
center: { lat: 30.27, lon: -97.74, radiusKm: 50 },
west: -98.3,
east: -97.2,
south: 29.8,
north: 30.7,
},
CONUS,
);
assert.deepEqual(austin, {
west: -98.75,
south: 29.75,
east: -96.75,
north: 30.75,
});
const edge = weatherWindow(
{
center: { lat: 21, lon: -129, radiusKm: 200 },
west: -131,
east: -127,
south: 19,
north: 23,
},
CONUS,
);
assert.equal(edge.west, -130);
assert.equal(edge.south, 20);
assert.equal((edge.east - edge.west) / (edge.north - edge.south), 2);
assert.equal(
weatherWindow(
{ bbox: true, west: -180, east: 180, south: -80, north: 80 },
CONUS,
),
null,
);
});
test('weather maps return the latest frame as an image over the area', async () => {
const maps = weatherMaps();
const catalog = composeCatalog({
tools: coreTools,
services: { weatherMaps: maps },
});
const result = await catalog.call('get_weather_map', {
map: 'radar',
area: { lat: 30.27, lon: -97.74, radius_km: 50 },
});
assert.equal(
result.summary,
'MRMS radar at 2026-10-01T19:00:00.000Z over 50 km around 30.270, -97.740.',
);
assert.deepEqual(result.images, [
{ mimeType: 'image/png', data: 'iVBORw==' },
]);
assert.deepEqual(maps.requests[0], {
product: 'radar',
time: '2026-10-01T19:00:00.000Z',
size: { width: 1024, height: 512 },
bbox: { west: -98.75, south: 29.75, east: -96.75, north: 30.75 },
signal: undefined,
});
const whole = await catalog.call('get_weather_map', { map: 'satellite' });
assert.equal(maps.requests[1].product, 'clouds-regional');
assert.equal(maps.requests[1].bbox, null);
assert.deepEqual(whole.data.box, CONUS);
await assert.rejects(
catalog.call('get_weather_map', {
map: 'radar',
area: { lat: 51.5, lon: -0.1, radius_km: 50 },
}),
/outside the radar map's coverage: Contiguous United States/,
);
const down = composeCatalog({
tools: coreTools,
services: { weatherMaps: weatherMaps({ unavailable: true }) },
});
await assert.rejects(
down.call('get_weather_map', { map: 'lightning' }),
(error) => error.code === 'unavailable',
);
});
test('wind is sampled from the model grid at the location', async () => {
// A 1° global grid where every cell blows 10 m/s toward the east (from the west).
const nx = 360;
const ny = 181;
const u = new Float32Array(nx * ny).fill(10);
const v = new Float32Array(nx * ny).fill(0);
const wind = {
requested: [],
async getSnapshot({ model }) {
this.requested.push(model);
return {
model,
cycle: '2026-10-01T12:00:00Z',
level: '10 m above ground',
grid: { nx, ny, lo1: 0, la1: 90, dx: 1, dy: 1 },
u,
v,
};
},
};
const catalog = composeCatalog({ tools: coreTools, services: { wind } });
const result = await catalog.call('get_wind', {
location: { lat: 30.27, lon: -97.74 },
model: 'ifs',
});
assert.equal(
result.summary,
'Wind at 30.2700, -97.7400: 36 km/h from the W.',
);
assert.equal(result.data.speed_mps, 10);
assert.equal(result.data.from, 'W');
assert.deepEqual(wind.requested, ['ifs']);
u.fill(0);
const calm = await catalog.call('get_wind', { location: { lat: 0, lon: 0 } });
assert.equal(calm.summary, 'Wind at 0.0000, 0.0000: 0 km/h, calm.');
assert.equal(calm.data.calm, true);
const down = composeCatalog({
tools: coreTools,
services: { wind: { getSnapshot: async () => ({ unavailable: true }) } },
});
await assert.rejects(
down.call('get_wind', { location: { lat: 0, lon: 0 } }),
(error) => error.code === 'unavailable',
);
});
test('a weather window that cannot cover the area falls back to the whole map', async () => {
const world = { west: -180, south: -90, east: 180, north: 90 };
// 100 km around 179.8° crosses the antimeridian.
const dateline = await resolveArea({ lat: 0, lon: 179.8, radius_km: 100 });
assert.equal(weatherWindow(dateline, world), null);
// Near a product edge the window moves inside and still covers the area.
const conus = { west: -130, south: 20, east: -60, north: 55 };
const edge = await resolveArea({ lat: 40, lon: -129.5, radius_km: 50 });
const box = weatherWindow(edge, conus);
assert.ok(box);
assert.ok(box.west <= Math.max(edge.west, conus.west));
assert.ok(box.east >= edge.east);
assert.ok(box.south <= edge.south && box.north >= edge.north);
});
+387
View File
@@ -0,0 +1,387 @@
/** Aviation queries over the live aircraft sources (src/sources/live). */
import { suggestView } from '../views.js';
import { defineTool, ToolError } from '../catalog.js';
import {
AREA_SCHEMA,
areaCenter,
areaContains,
distanceKm,
resolveArea,
} from '../area.js';
import {
LIMIT_SCHEMA,
capRows,
countNoun,
isoTime,
thinEvenly,
} from '../results.js';
const ICAO24_SCHEMA = Object.freeze({
type: 'string',
pattern: '^[0-9a-fA-F]{6}$',
description: 'ICAO 24-bit address as six hex characters, such as "a1b2c3".',
});
const CALLSIGN_SCHEMA = Object.freeze({
type: 'string',
pattern: '^[A-Za-z0-9]{2,8}$',
description: 'Flight callsign, such as "UAL123".',
});
const MILITARY_SCHEMA = Object.freeze({
type: 'boolean',
description: 'Use the military aircraft feed instead of the general feed.',
});
const MAX_TRACK_POINTS = 200;
const round = (value, digits) =>
Number.isFinite(value) ? Number(value.toFixed(digits)) : null;
/** The common row for an aircraft record from any live aircraft source. */
function aircraftRow(record, from) {
const point = { lat: record.latitude, lon: record.longitude };
return {
id: record.id,
callsign: record.callsign || null,
lat: round(record.latitude, 5),
lon: round(record.longitude, 5),
altitude_m: round(record.baroAltitudeM ?? record.ellipsoidAltitudeM, 0),
on_ground: record.onGround === true,
speed_mps: round(record.speedMps, 1),
course_deg: round(record.courseDeg, 0),
vertical_rate_mps: round(record.verticalRateMps, 1),
type_code: record.typeCode || null,
registration: record.registration || null,
operator: record.operator || null,
origin_country: record.originCountry || null,
last_contact: isoTime(record.contactTimeMs ?? record.positionTimeMs),
...(from ? { distance_km: round(distanceKm(from, point), 1) } : {}),
};
}
function snapshotInfo(snapshot) {
return {
source: snapshot.source ?? null,
coverage: snapshot.coverage ?? null,
observed_at: isoTime(snapshot.observedAtMs),
freshness: snapshot.freshness ?? 'unknown',
};
}
function feedFor(services, military) {
if (!military) return services.aircraft;
if (!services.military)
throw new ToolError(
'unsupported',
'The military aircraft feed is not available here',
);
return services.military;
}
export const aircraftInArea = defineTool({
name: 'aircraft_in_area',
title: 'Aircraft in an area',
description:
'Aircraft currently reported inside an area, nearest the center first. ' +
'Optionally military aircraft only, airborne only, or within an altitude band.',
inputSchema: {
type: 'object',
properties: {
area: AREA_SCHEMA,
military: MILITARY_SCHEMA,
airborne_only: { type: 'boolean' },
min_altitude_m: { type: 'number', minimum: -500, maximum: 30000 },
max_altitude_m: { type: 'number', minimum: -500, maximum: 30000 },
limit: LIMIT_SCHEMA,
},
required: ['area'],
additionalProperties: false,
},
requires: ['aircraft'],
async run(args, { services, signal }) {
const area = await resolveArea(args.area, { services, signal });
const center = areaCenter(area);
const snapshot = await feedFor(services, args.military).getSnapshot(
{ latitude: center.lat, longitude: center.lon },
{ signal },
);
const altitude = (record) =>
record.baroAltitudeM ?? record.ellipsoidAltitudeM;
const rows = snapshot.records
.filter(
(record) =>
areaContains(area, { lat: record.latitude, lon: record.longitude }) &&
!(args.airborne_only && record.onGround) &&
(args.min_altitude_m == null ||
altitude(record) >= args.min_altitude_m) &&
(args.max_altitude_m == null ||
altitude(record) <= args.max_altitude_m),
)
.map((record) => aircraftRow(record, center))
.sort((a, b) => a.distance_km - b.distance_km);
const kind = args.military ? 'military aircraft' : 'aircraft';
// A regional fallback feed covers only part of the world around the
// area's center; say so rather than imply a worldwide answer.
const regional = /regional/i.test(snapshot.coverage || '');
const notes = [
...(regional ? [`regional feed: ${snapshot.coverage}`] : []),
...(snapshot.freshness === 'stale' ? ['data may be stale'] : []),
];
return {
summary:
`${rows.length} ${kind} in ${area.label}` +
(notes.length ? ` (${notes.join('; ')}).` : '.'),
data: {
view: suggestView(services, {
area,
layers: [args.military ? 'military' : 'flights'],
}),
...capRows(rows, args.limit),
...snapshotInfo(snapshot),
},
};
},
});
export const findAircraft = defineTool({
name: 'find_aircraft',
title: 'Find an aircraft',
description:
'Find aircraft currently reported anywhere by callsign, ICAO 24-bit ' +
'address or registration. Registrations are known only for aircraft in ' +
'the military feed.',
inputSchema: {
type: 'object',
properties: {
callsign: CALLSIGN_SCHEMA,
icao24: ICAO24_SCHEMA,
registration: { type: 'string', minLength: 2, maxLength: 12 },
limit: LIMIT_SCHEMA,
},
additionalProperties: false,
},
requires: ['aircraft'],
async run(args, { services, signal }) {
const given = ['callsign', 'icao24', 'registration'].filter(
(key) => args[key] != null,
);
if (given.length !== 1)
throw new ToolError(
'invalid_arguments',
'Give exactly one of callsign, icao24 or registration',
);
const [key] = given;
const wanted = args[key].trim().toUpperCase();
const field = {
callsign: 'callsign',
icao24: 'id',
registration: 'registration',
}[key];
// Only the military feed reports registrations.
const feeds = [
...(key === 'registration'
? []
: [{ name: 'civil', feed: services.aircraft }]),
{ name: 'military', feed: services.military },
].filter(({ feed }) => feed);
if (!feeds.length)
throw new ToolError(
'unavailable',
'The military aircraft feed is not available here',
);
const settled = await Promise.allSettled(
feeds.map(({ feed }) => feed.getSnapshot({}, { signal })),
);
signal?.throwIfAborted();
// A search succeeds when any feed answers; missing feeds are named.
const unavailable = feeds
.filter((_, index) => settled[index].status === 'rejected')
.map(({ name }) => name);
if (unavailable.length === feeds.length) throw settled[0].reason;
const seen = new Set();
const matches = settled
.flatMap((result, index) =>
result.status === 'fulfilled'
? result.value.records.map((record) => ({
record,
feed: feeds[index].name,
}))
: [],
)
.filter(
({ record }) =>
String(record[field] || '')
.trim()
.toUpperCase() === wanted,
)
.filter(({ record }) => !seen.has(record.id) && seen.add(record.id));
const rows = matches.map(({ record }) => aircraftRow(record));
// One match is shown followed; several are not framed.
const only = matches.length === 1 ? matches[0] : null;
// Each feed that answered, with its own freshness and coverage.
const answered = feeds.flatMap(({ name }, index) =>
settled[index].status === 'fulfilled'
? [{ feed: name, ...snapshotInfo(settled[index].value) }]
: [],
);
const stale = answered
.filter((feed) => feed.freshness === 'stale')
.map((feed) => feed.feed);
return {
summary:
(rows.length
? `Found ${countNoun(rows.length, 'aircraft', 'aircraft')} with ${key} ${wanted}.`
: `No aircraft with ${key} ${wanted} is currently reported.`) +
(unavailable.length
? ` The ${unavailable.join(' and ')} feed did not answer.`
: '') +
(stale.length
? ` The ${stale.join(' and ')} feed data may be stale.`
: ''),
data: {
view: only
? suggestView(services, {
point: { lat: only.record.latitude, lon: only.record.longitude },
follow: {
kind:
only.feed === 'military' ? 'military_aircraft' : 'aircraft',
id: only.record.id,
},
})
: null,
...capRows(rows, args.limit),
unavailable_feeds: unavailable,
stale_feeds: stale,
feeds: answered,
},
};
},
});
export const getAircraftTrack = defineTool({
name: 'get_aircraft_track',
title: 'Aircraft track',
description:
'Recent positions of one aircraft by ICAO 24-bit address, oldest first. ' +
`Long tracks are evenly thinned to at most ${MAX_TRACK_POINTS} points.`,
inputSchema: {
type: 'object',
properties: { icao24: ICAO24_SCHEMA, military: MILITARY_SCHEMA },
required: ['icao24'],
additionalProperties: false,
},
requires: ['aircraft'],
async run(args, { services, signal }) {
const icao24 = args.icao24.toLowerCase();
// A track server answers 404 for an aircraft it has no positions for.
const track = await feedFor(services, args.military)
.getTrack(icao24, { signal })
.catch((error) => {
if (error?.status === 404) return { records: [] };
throw error;
});
const points = [...track.records].sort(
(a, b) => a.observedAtMs - b.observedAtMs,
);
const kept = thinEvenly(points, MAX_TRACK_POINTS);
const first = points[0];
const last = points.at(-1);
return {
summary: points.length
? `${countNoun(points.length, 'position')} for ${icao24} from ${isoTime(first.observedAtMs)} to ${isoTime(last.observedAtMs)}.`
: `No recent track is available for ${icao24}.`,
data: {
view: last
? suggestView(services, {
point: { lat: last.latitude, lon: last.longitude },
follow: {
kind: args.military ? 'military_aircraft' : 'aircraft',
id: icao24,
},
})
: null,
icao24,
total: points.length,
returned: kept.length,
thinned: kept.length < points.length,
points: kept.map((point) => ({
time: isoTime(point.observedAtMs),
lat: round(point.latitude, 5),
lon: round(point.longitude, 5),
altitude_m: round(point.baroAltitudeM, 0),
on_ground: point.onGround === true,
})),
},
};
},
});
export const getAircraftInfo = defineTool({
name: 'get_aircraft_info',
title: 'Aircraft type and route',
description:
'Look up an aircraft type and registration by ICAO 24-bit address, and ' +
'a flight route by callsign, from adsbdb. Give either or both.',
inputSchema: {
type: 'object',
properties: { icao24: ICAO24_SCHEMA, callsign: CALLSIGN_SCHEMA },
additionalProperties: false,
},
requires: ['aircraft'],
async run(args, { services, signal }) {
if (args.icao24 == null && args.callsign == null)
throw new ToolError('invalid_arguments', 'Give icao24, callsign or both');
const lookup = (kind, id) =>
id == null
? null
: services.aircraft.getEnrichment({ kind, id }, { signal });
const icao24 = args.icao24?.toLowerCase();
const callsign = args.callsign?.toUpperCase();
const [type, route] = await Promise.all([
lookup('type', icao24),
lookup('route', callsign),
]);
const aircraft = type?.found
? {
icao24,
type_code: type.typeCode ?? null,
type_name: type.typeName ?? null,
registration: type.registration ?? null,
}
: null;
const airport = (value) =>
value
? {
code: value.code || null,
name: value.name || null,
lat: value.lat ?? null,
lon: value.lon ?? null,
}
: null;
const flight = route?.found
? {
callsign,
airline: route.airline ?? null,
origin: airport(route.origin),
destination: airport(route.destination),
}
: null;
const parts = [];
if (icao24)
parts.push(
aircraft
? `aircraft ${icao24} is ${aircraft.type_name || aircraft.type_code || 'an aircraft of unknown type'}${aircraft.registration ? ` (${aircraft.registration})` : ''}`
: `no type is known for ${icao24}`,
);
if (callsign)
parts.push(
flight
? `flight ${callsign} flies ${flight.origin?.code || flight.origin?.name} to ${flight.destination?.code || flight.destination?.name}${flight.airline ? ` (${flight.airline})` : ''}`
: `no route is known for ${callsign}`,
);
const summary = parts.join('; ');
return {
summary: `${summary.charAt(0).toUpperCase()}${summary.slice(1)}.`,
data: { aircraft, route: flight },
};
},
});
+390
View File
@@ -0,0 +1,390 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { composeCatalog, coreTools } from '../index.js';
import { LiveSourceError } from '../../sources/live/contract.js';
const record = (id, latitude, longitude, extra = {}) => ({
id,
reference: id,
latitude,
longitude,
callsign: null,
originCountry: null,
positionTimeMs: Date.UTC(2026, 0, 1),
contactTimeMs: Date.UTC(2026, 0, 1, 0, 0, 5),
baroAltitudeM: 10000,
ellipsoidAltitudeM: null,
onGround: false,
speedMps: 230.04,
courseDeg: 90.4,
verticalRateMps: 0,
category: null,
typeCode: null,
registration: null,
operator: null,
...extra,
});
const snapshot = (records, extra = {}) => ({
records,
complete: true,
source: 'Test feed',
coverage: 'test coverage',
observedAtMs: Date.UTC(2026, 0, 1),
freshness: 'current',
...extra,
});
const feed = (records, { track = [], enrichment = {}, ...extra } = {}) => {
const calls = [];
return {
calls,
async getSnapshot(query) {
calls.push(query);
return snapshot(records, extra);
},
async getTrack(reference) {
calls.push(reference);
return { records: track, complete: false };
},
async getEnrichment({ kind, id }) {
calls.push(`${kind}:${id}`);
return enrichment[`${kind}:${id}`] ?? { found: false };
},
};
};
const civil = [
record('aaa111', 37.7, -122.4, {
callsign: 'UAL123',
originCountry: 'United States',
}),
record('bbb222', 37.58, -122.28, { onGround: true, baroAltitudeM: 0 }),
record('ccc333', 51.5, -0.1, { callsign: 'BAW1' }),
];
const military = [
record('ae0001', 37.6, -122.3, {
callsign: 'RCH123',
registration: '07-7175',
typeCode: 'C17',
}),
];
const bay = { lat: 37.6, lon: -122.3, radius_km: 60 };
test('aircraft in an area are filtered, nearest first, with source details', async () => {
const aircraft = feed(civil);
const catalog = composeCatalog({ tools: coreTools, services: { aircraft } });
const result = await catalog.call('aircraft_in_area', { area: bay });
assert.equal(result.summary, '2 aircraft in 60 km around 37.600, -122.300.');
assert.deepEqual(
result.data.rows.map((row) => row.id),
['bbb222', 'aaa111'],
);
assert.deepEqual(aircraft.calls[0], { latitude: 37.6, longitude: -122.3 });
assert.deepEqual(result.data.rows[1], {
id: 'aaa111',
callsign: 'UAL123',
lat: 37.7,
lon: -122.4,
altitude_m: 10000,
on_ground: false,
speed_mps: 230,
course_deg: 90,
vertical_rate_mps: 0,
type_code: null,
registration: null,
operator: null,
origin_country: 'United States',
last_contact: '2026-01-01T00:00:05.000Z',
distance_km: 14.2,
});
assert.equal(result.data.source, 'Test feed');
assert.equal(result.data.observed_at, '2026-01-01T00:00:00.000Z');
const airborne = await catalog.call('aircraft_in_area', {
area: bay,
airborne_only: true,
});
assert.deepEqual(
airborne.data.rows.map((row) => row.id),
['aaa111'],
);
const low = await catalog.call('aircraft_in_area', {
area: bay,
max_altitude_m: 100,
});
assert.deepEqual(
low.data.rows.map((row) => row.id),
['bbb222'],
);
});
test('the military feed is used on request and reported when missing', async () => {
const catalog = composeCatalog({
tools: coreTools,
services: {
aircraft: feed(civil),
military: feed(military, { freshness: 'stale' }),
},
});
const result = await catalog.call('aircraft_in_area', {
area: bay,
military: true,
});
assert.equal(
result.summary,
'1 military aircraft in 60 km around 37.600, -122.300 (data may be stale).',
);
assert.equal(result.data.rows[0].registration, '07-7175');
const civilOnly = composeCatalog({
tools: coreTools,
services: { aircraft: feed(civil) },
});
await assert.rejects(
civilOnly.call('aircraft_in_area', { area: bay, military: true }),
(error) => error.code === 'unsupported',
);
});
test('aircraft are found by exactly one identifier across feeds', async () => {
const catalog = composeCatalog({
tools: coreTools,
services: { aircraft: feed(civil), military: feed(military) },
});
const byCallsign = await catalog.call('find_aircraft', {
callsign: 'ual123',
});
assert.equal(byCallsign.summary, 'Found 1 aircraft with callsign UAL123.');
assert.deepEqual(
byCallsign.data.rows.map((row) => row.id),
['aaa111'],
);
assert.equal(byCallsign.data.rows[0].distance_km, undefined);
const byRegistration = await catalog.call('find_aircraft', {
registration: '07-7175',
});
assert.deepEqual(
byRegistration.data.rows.map((row) => row.id),
['ae0001'],
);
const none = await catalog.call('find_aircraft', { icao24: 'ABCDEF' });
assert.equal(
none.summary,
'No aircraft with icao24 ABCDEF is currently reported.',
);
await assert.rejects(catalog.call('find_aircraft', {}), /exactly one/);
await assert.rejects(
catalog.call('find_aircraft', { callsign: 'UAL123', icao24: 'aaa111' }),
/exactly one/,
);
await assert.rejects(
catalog.call('find_aircraft', { icao24: 'xyz' }),
/must match/,
);
});
test('tracks are ordered and thinned to 200 points', async () => {
const start = Date.UTC(2026, 0, 1);
const track = Array.from({ length: 450 }, (_, index) => ({
latitude: 37 + index / 1000,
longitude: -122,
observedAtMs: start + (449 - index) * 1000,
baroAltitudeM: 1000,
onGround: false,
}));
const aircraft = feed(civil, { track });
const catalog = composeCatalog({ tools: coreTools, services: { aircraft } });
const result = await catalog.call('get_aircraft_track', { icao24: 'AAA111' });
assert.equal(aircraft.calls[0], 'aaa111');
assert.equal(
result.summary,
'450 positions for aaa111 from 2026-01-01T00:00:00.000Z to 2026-01-01T00:07:29.000Z.',
);
assert.deepEqual(
{
total: result.data.total,
returned: result.data.returned,
thinned: result.data.thinned,
},
{ total: 450, returned: 200, thinned: true },
);
assert.equal(result.data.points[0].time, '2026-01-01T00:00:00.000Z');
assert.equal(result.data.points.at(-1).time, '2026-01-01T00:07:29.000Z');
const empty = composeCatalog({
tools: coreTools,
services: { aircraft: feed(civil) },
});
assert.equal(
(await empty.call('get_aircraft_track', { icao24: 'aaa111' })).summary,
'No recent track is available for aaa111.',
);
const failing = (status) => ({
getSnapshot: async () => snapshot([]),
getTrack: async () => {
throw new LiveSourceError('unavailable', `OpenSky HTTP ${status}`, {
status,
});
},
});
const unknown = composeCatalog({
tools: coreTools,
services: { aircraft: failing(404) },
});
assert.equal(
(await unknown.call('get_aircraft_track', { icao24: 'abcdef' })).summary,
'No recent track is available for abcdef.',
);
const down = composeCatalog({
tools: coreTools,
services: { aircraft: failing(502) },
});
await assert.rejects(
down.call('get_aircraft_track', { icao24: 'abcdef' }),
(error) => error.code === 'unavailable',
);
});
test('aircraft info combines type and route lookups', async () => {
const aircraft = feed(civil, {
enrichment: {
'type:aaa111': {
found: true,
typeCode: 'B738',
typeName: 'Boeing 737-800',
registration: 'N12345',
},
'route:UAL123': {
found: true,
airline: 'United Airlines',
origin: { code: 'SFO', name: 'San Francisco', lat: 37.6, lon: -122.4 },
destination: { code: 'EWR', name: 'Newark', lat: 40.7, lon: -74.2 },
},
},
});
const catalog = composeCatalog({ tools: coreTools, services: { aircraft } });
const both = await catalog.call('get_aircraft_info', {
icao24: 'AAA111',
callsign: 'ual123',
});
assert.equal(
both.summary,
'Aircraft aaa111 is Boeing 737-800 (N12345); flight UAL123 flies SFO to EWR (United Airlines).',
);
assert.equal(both.data.route.destination.name, 'Newark');
const unknown = await catalog.call('get_aircraft_info', { callsign: 'XYZ9' });
assert.equal(unknown.summary, 'No route is known for XYZ9.');
assert.deepEqual(unknown.data, { aircraft: null, route: null });
await assert.rejects(
catalog.call('get_aircraft_info', {}),
/icao24, callsign or both/,
);
});
test('live source failures become tool errors with retry guidance', async () => {
const limited = {
getSnapshot: async () => {
throw new LiveSourceError('limited', 'OpenSky rate limited', {
retryAfterMs: 45000,
});
},
};
const catalog = composeCatalog({
tools: coreTools,
services: { aircraft: limited },
});
await assert.rejects(
catalog.call('aircraft_in_area', { area: bay }),
(error) =>
error.name === 'ToolError' &&
error.code === 'retry_later' &&
error.retryAfterSeconds === 45 &&
error.message === 'OpenSky rate limited',
);
});
test('aircraft searches keep the feeds that answered and name the others', async () => {
const down = {
getSnapshot: async () => {
throw new LiveSourceError('unavailable', 'OpenSky HTTP 503');
},
};
const military = feed([
record('ae1234', 38, -77, { callsign: 'RCH123', registration: '05-5140' }),
]);
const catalog = composeCatalog({
tools: coreTools,
services: { aircraft: down, military },
});
const byCallsign = await catalog.call('find_aircraft', {
callsign: 'rch123',
});
assert.equal(
byCallsign.summary,
'Found 1 aircraft with callsign RCH123. The civil feed did not answer.',
);
assert.deepEqual(byCallsign.data.unavailable_feeds, ['civil']);
const civil = feed([]);
const byRegistration = await composeCatalog({
tools: coreTools,
services: { aircraft: civil, military },
}).call('find_aircraft', { registration: '05-5140' });
assert.equal(byRegistration.data.rows[0].id, 'ae1234');
assert.deepEqual(byRegistration.data.unavailable_feeds, []);
assert.equal(civil.calls.length, 0);
await assert.rejects(
composeCatalog({
tools: coreTools,
services: { aircraft: down, military: down },
}).call('find_aircraft', { callsign: 'RCH123' }),
(error) =>
error.code === 'unavailable' && error.message === 'OpenSky HTTP 503',
);
});
test('a regional fallback feed is named in the answer', async () => {
const regional = feed([record('abc123', 37.6, -122.3)], {
source: 'adsb.lol',
coverage: '250nm regional fallback',
});
const result = await composeCatalog({
tools: coreTools,
services: { aircraft: regional },
}).call('aircraft_in_area', {
area: { lat: 37.6, lon: -122.3, radius_km: 20 },
});
assert.match(result.summary, /\(regional feed: 250nm regional fallback\)\.$/);
});
test('aircraft searches say when a feed that answered is stale', async () => {
const stale = feed([], { freshness: 'stale' });
const result = await composeCatalog({
tools: coreTools,
services: { aircraft: stale, military: feed([]) },
}).call('find_aircraft', { callsign: 'UAL1' });
assert.equal(
result.summary,
'No aircraft with callsign UAL1 is currently reported. The civil feed data may be stale.',
);
assert.deepEqual(result.data.stale_feeds, ['civil']);
assert.deepEqual(
result.data.feeds.map((entry) => [entry.feed, entry.freshness]),
[
['civil', 'stale'],
['military', 'current'],
],
);
});
test('the real OpenSky source marks an hour-old snapshot stale for searches', async () => {
const { createOpenSkySource } =
await import('../../sources/live/standalone.js');
const now = Date.UTC(2026, 0, 1, 12);
const opensky = createOpenSkySource({
now: () => now,
fetchImpl: async () =>
Response.json({ time: now / 1000 - 3600, states: [] }),
});
const result = await composeCatalog({
tools: coreTools,
services: { aircraft: opensky },
}).call('find_aircraft', { callsign: 'UAL1' });
assert.deepEqual(result.data.stale_feeds, ['civil']);
assert.match(result.summary, /may be stale\.$/);
});
+305
View File
@@ -0,0 +1,305 @@
/** Composite queries that combine other tools' answers for one area. */
import { suggestView } from '../views.js';
import { feedProvenanceEnvelope } from '../../data/layerSnapshot.js';
import { defineTool, ToolError } from '../catalog.js';
import {
AREA_SCHEMA,
POINT_SCHEMA,
areaCenter,
resolveArea,
resolvePoint,
} from '../area.js';
const SECTION_LIMIT = 5;
const AWARENESS_LIMIT = 10;
const AWARENESS_RADIUS_KM = 250;
/** Brief sections: the tool each one calls and the arguments it passes. */
const SECTIONS = [
{
key: 'weather',
label: 'Weather',
tool: 'get_weather',
args: (area, center) => ({
location: area.argument.place
? { place: area.argument.place }
: { lat: center.lat, lon: center.lon },
}),
},
{
key: 'earthquakes',
layer: 'earthquakes',
label: 'Earthquakes',
tool: 'get_earthquakes',
args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }),
},
{
key: 'fires',
layer: 'local-firms',
label: 'Active fires',
tool: 'get_active_fires',
args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }),
},
{
key: 'aircraft',
layer: 'flights',
label: 'Aircraft',
tool: 'aircraft_in_area',
args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }),
},
{
key: 'vessels',
layer: 'ais-live-vessels',
label: 'Ships',
tool: 'vessels_in_area',
args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }),
},
{
key: 'cyclones',
layer: 'weather-cyclones',
label: 'Tropical cyclones',
tool: 'get_cyclones',
args: (area) => ({ area: area.argument }),
},
];
/** Military contacts around a point, as the app's military awareness panel. */
const AWARENESS_SECTIONS = [
{
key: 'military_aircraft',
tool: 'aircraft_in_area',
args: (area) => ({
area: area.argument,
military: true,
limit: AWARENESS_LIMIT,
}),
},
{
key: 'aircraft',
tool: 'aircraft_in_area',
args: (area) => ({ area: area.argument, limit: AWARENESS_LIMIT }),
},
{
key: 'vessels',
tool: 'vessels_in_area',
args: (area) => ({ area: area.argument, limit: AWARENESS_LIMIT }),
},
{
key: 'installations',
tool: 'find_military_installations',
args: (area) => ({ area: area.argument, limit: AWARENESS_LIMIT }),
},
];
/** A section's feed state for the caption, from what its result reported. */
function sectionFeedState(section) {
if (section.unavailable) return 'unavailable';
const data = section.data || {};
if (data.freshness === 'stale' || data.stale === true) return 'stale';
// Answered, but some of the feeds or satellites behind it did not.
if (data.missing_sources?.length || data.unavailable_feeds?.length)
return 'degraded';
return 'nominal';
}
/**
* The resolved area, plus the argument sections receive. A named place is
* passed by name, so sections resolve it to the same point and label as a
* direct query does (place lookups are cached).
*/
function sectionArea(resolved, original) {
return {
...resolved,
argument: original?.place
? { place: original.place }
: resolved.center
? {
lat: resolved.center.lat,
lon: resolved.center.lon,
radius_km: resolved.center.radiusKm,
}
: {
bbox: [
resolved.west,
resolved.south,
resolved.east,
resolved.north,
],
},
};
}
/**
* Run every section whose tool the catalog offers, through the catalog so
* replaced tools and interceptors apply. A failing section is reported as
* unavailable instead of failing the whole answer.
*/
async function runSections(all, area, { tools, signal }) {
const center = areaCenter(area);
const sections = all.filter(({ tool }) => tools.has(tool));
const results = await Promise.allSettled(
sections.map(({ tool, args: build }) =>
tools.call(tool, build(area, center)),
),
);
signal?.throwIfAborted();
const answers = {};
const lines = [];
sections.forEach(({ key }, index) => {
const result = results[index];
if (result.status === 'fulfilled') {
answers[key] = { summary: result.value.summary, data: result.value.data };
lines.push(result.value.summary);
} else {
const known = result.reason instanceof ToolError;
answers[key] = {
unavailable: true,
reason: known ? result.reason.message : 'unavailable right now',
};
}
});
return { center, answers, lines };
}
async function buildBrief(args, { services, signal, tools }) {
// Sections receive the resolved box so a place name is looked up once.
const area = sectionArea(
await resolveArea(args.area, { services, signal }),
args.area,
);
const { center, answers, lines } = await runSections(SECTIONS, area, {
tools,
signal,
});
return { area, center, brief: answers, lines };
}
export const situationBrief = defineTool({
name: 'situation_brief',
title: 'Situation brief',
description:
'One overview of an area: current weather, recent earthquakes, active ' +
'fires, aircraft overhead, ships and tropical cyclones, each summarized with ' +
'its top items. Sections that are unavailable are marked as such.',
inputSchema: {
type: 'object',
properties: { area: AREA_SCHEMA },
required: ['area'],
additionalProperties: false,
},
requires: ['weather'],
async run(args, context) {
const { area, brief, lines } = await buildBrief(args, context);
return {
summary: `Situation in ${area.label}: ${lines.join(' ')}`,
data: {
view: suggestView(context.services, {
area,
layers: SECTIONS.filter(({ key }) => brief[key])
.map(({ layer }) => layer)
.filter(Boolean),
}),
area: area.label,
sections: brief,
},
};
},
});
export const getHudCaption = defineTool({
name: 'get_hud_caption',
title: 'Heads-up display caption',
description:
"The short heads-up display caption God's Eye View would show for an " +
'area, written by the app from the same overview situation_brief gives.',
inputSchema: {
type: 'object',
properties: { area: AREA_SCHEMA },
required: ['area'],
additionalProperties: false,
},
requires: ['weather', 'summary'],
async run(args, context) {
const { area, center, brief, lines } = await buildBrief(args, context);
// The app's caption context: place labels and each section as a layer
// with the feed state its result reported, failures included.
const layers = SECTIONS.filter(({ key }) => brief[key]).map(
({ key, label }) => ({
id: key,
name: label,
enabled: true,
feedState: sectionFeedState(brief[key]),
}),
);
const response = await context.services.summary.summarize(
{
placeLabels: [area.label],
location: area.label,
center,
observations: lines,
enabledLayerLabels: layers.map((layer) => layer.name),
enabledLayers: layers.map(({ id, name, feedState }) => ({
id,
name,
feedState,
})),
feedProvenance: feedProvenanceEnvelope(layers),
},
{ signal: context.signal },
);
const caption = response?.data?.summary;
if (!response?.ok || typeof caption !== 'string' || !caption)
throw new ToolError('unavailable', 'The caption service did not answer');
return {
summary: caption,
data: { area: area.label, caption },
};
},
});
export const militaryAwareness = defineTool({
name: 'military_awareness',
title: 'Military awareness',
description:
'Military contacts around a location, as the military awareness panel ' +
'shows them: military aircraft, other aircraft, ships and mapped ' +
`military installations within ${AWARENESS_RADIUS_KM} km by default, ` +
'each nearest first. Sections that are unavailable are marked as such.',
inputSchema: {
type: 'object',
properties: {
location: POINT_SCHEMA,
radius_km: { type: 'number', minimum: 1, maximum: AWARENESS_RADIUS_KM },
},
required: ['location'],
additionalProperties: false,
},
requires: ['military'],
async run(args, { services, signal, tools }) {
const point = await resolvePoint(args.location, { services, signal });
const radiusKm = args.radius_km ?? AWARENESS_RADIUS_KM;
const area = sectionArea(
await resolveArea(
{ lat: point.lat, lon: point.lon, radius_km: radiusKm },
{ services, signal },
),
);
const { answers, lines } = await runSections(AWARENESS_SECTIONS, area, {
tools,
signal,
});
return {
summary: `Military awareness within ${radiusKm} km of ${point.label}: ${lines.join(' ')}`,
data: {
view: suggestView(services, {
area,
layers: ['military', 'ais-live-vessels', 'military-installations'],
}),
location: point,
radius_km: radiusKm,
sections: answers,
},
};
},
});
+100
View File
@@ -0,0 +1,100 @@
/** Submarine cables and their landing points. */
import { suggestView } from '../views.js';
import { defineTool, ToolError } from '../catalog.js';
import {
AREA_SCHEMA,
areaContains,
lineTouchesArea,
resolveArea,
} from '../area.js';
import { LIMIT_SCHEMA, capRows, countNoun } from '../results.js';
const ATTRIBUTION = '© TeleGeography — submarinecablemap.com (CC BY-NC-SA 3.0)';
function cableLines(feature) {
const geometry = feature?.geometry;
if (geometry?.type === 'LineString') return [geometry.coordinates];
if (geometry?.type === 'MultiLineString') return geometry.coordinates;
return [];
}
export const findSubmarineCables = defineTool({
name: 'find_submarine_cables',
title: 'Submarine cables',
description:
'Undersea telecommunication cables and landing points from the ' +
'TeleGeography map: those passing through or landing in an area, ' +
'and/or those whose name matches.',
inputSchema: {
type: 'object',
properties: {
area: AREA_SCHEMA,
name: { type: 'string', minLength: 2, maxLength: 80 },
limit: LIMIT_SCHEMA,
},
additionalProperties: false,
},
requires: ['cables'],
async run(args, { services, signal }) {
if (!args.area && !args.name)
throw new ToolError('invalid_arguments', 'Give an area, a name or both');
const area = args.area
? await resolveArea(args.area, { services, signal })
: null;
const wanted = args.name?.toLowerCase();
const { cables, landingPoints } = await services.cables.fetch(signal);
const landingRows = (landingPoints?.features || [])
.map((feature) => ({
id: feature.properties?.id ?? null,
name: feature.properties?.name ?? null,
lon: feature.geometry?.coordinates?.[0],
lat: feature.geometry?.coordinates?.[1],
}))
.filter(
(point) =>
point.name &&
Number.isFinite(point.lat) &&
(!area || areaContains(area, point)),
);
const rows = (cables?.features || [])
.filter((feature) => {
const name = String(feature.properties?.name || '');
if (!name || (wanted && !name.toLowerCase().includes(wanted)))
return false;
return (
!area ||
cableLines(feature).some((line) => lineTouchesArea(line, area))
);
})
.map((feature) => ({
id: feature.properties.id ?? null,
name: feature.properties.name,
}))
.sort((a, b) => a.name.localeCompare(b.name));
const where = area ? ` in ${area.label}` : '';
const what = wanted ? ` matching "${args.name}"` : '';
return {
summary:
`${countNoun(rows.length, 'submarine cable')}${what}${where}` +
(area
? `, with ${countNoun(landingRows.length, 'landing point')}.`
: '.'),
data: {
view: suggestView(
services,
area
? { area, layers: ['telegeography-submarine-cables'] }
: {
point: { lat: 20, lon: 0 },
altitudeM: 15_000_000,
layers: ['telegeography-submarine-cables'],
},
),
...capRows(rows, args.limit),
landing_points: area ? capRows(landingRows, args.limit) : null,
attribution: ATTRIBUTION,
},
};
},
});
+110
View File
@@ -0,0 +1,110 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { composeCatalog, coreTools } from '../index.js';
import { createApiFetch } from '../../../server/mcp/services.js';
import { createBundledCableSource } from '../../layers/submarineCables/bundledSource.js';
const cables = {
async fetch() {
return {
cables: {
features: [
{
properties: { id: 'marea', name: 'MAREA' },
geometry: {
type: 'MultiLineString',
coordinates: [
[
[-76, 36.8],
[-40, 40],
[-2.9, 43.4],
],
],
},
},
{
properties: { id: 'unity', name: 'Unity' },
geometry: {
type: 'LineString',
coordinates: [
[-118, 34],
[139, 35],
],
},
},
],
},
landingPoints: {
features: [
{
properties: { id: 'vb', name: 'Virginia Beach, VA, United States' },
geometry: { type: 'Point', coordinates: [-75.98, 36.85] },
},
{
properties: { id: 'bilbao', name: 'Bilbao, Spain' },
geometry: { type: 'Point', coordinates: [-2.93, 43.26] },
},
],
},
};
},
};
test('cables are found by area and name, with landing points and attribution', async () => {
const catalog = composeCatalog({ tools: coreTools, services: { cables } });
const virginia = await catalog.call('find_submarine_cables', {
area: { lat: 36.85, lon: -75.98, radius_km: 50 },
});
assert.equal(
virginia.summary,
'1 submarine cable in 50 km around 36.850, -75.980, with 1 landing point.',
);
assert.deepEqual(virginia.data.rows, [{ id: 'marea', name: 'MAREA' }]);
assert.equal(
virginia.data.landing_points.rows[0].name,
'Virginia Beach, VA, United States',
);
assert.match(virginia.data.attribution, /TeleGeography.*CC BY-NC-SA 3\.0/);
const named = await catalog.call('find_submarine_cables', { name: 'uni' });
assert.equal(named.summary, '1 submarine cable matching "uni".');
assert.equal(named.data.landing_points, null);
await assert.rejects(
catalog.call('find_submarine_cables', {}),
/area, a name or both/,
);
// Areas a cable crosses between vertices, including at the antimeridian.
const atlantic = await catalog.call('find_submarine_cables', {
area: { bbox: [-30, 40, -20, 43] },
});
assert.deepEqual(
atlantic.data.rows.map((row) => row.id),
['marea'],
);
const pacific = await catalog.call('find_submarine_cables', {
area: { bbox: [179, 34, -179, 35.5] },
});
assert.deepEqual(
pacific.data.rows.map((row) => row.id),
['unity'],
);
});
test('the local fetch serves bundled data files and nothing outside them', async () => {
const fetchImpl = createApiFetch({
apiBase: 'http://127.0.0.1:1',
fetchImpl: async () => assert.fail('no network'),
});
const { cables: collection, landingPoints } = await createBundledCableSource({
fetchImpl,
}).fetch();
assert.ok(collection.features.length > 500);
assert.ok(landingPoints.features.length > 1000);
const outside = await fetchImpl(
new URL('../../../package.json', import.meta.url).href,
);
assert.equal(outside.status, 404);
const missing = await fetchImpl(
new URL('../../data/local_data/missing.json', import.meta.url).href,
);
assert.equal(missing.status, 404);
});
+521
View File
@@ -0,0 +1,521 @@
/**
* Environment queries: weather, regional briefs, cyclones, fire perimeters,
* terrain height, mapped military sites and map features.
*/
import { suggestView } from '../views.js';
import { defineTool, ToolError } from '../catalog.js';
import {
AREA_SCHEMA,
POINT_SCHEMA,
areaCenter,
areaContains,
distanceKm,
polygonsTouchArea,
resolveArea,
resolvePoint,
} from '../area.js';
import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.js';
const MAX_TERRAIN_POINTS = 20;
const MAX_SITE_BOX_DEGREES = 10;
const FEATURE_KINDS = {
administrative_areas: 'getAdministrativeAreas',
named_places: 'getEnclosingAreas',
monuments: 'getMonuments',
};
const round = (value, digits) =>
Number.isFinite(value) ? Number(value.toFixed(digits)) : null;
// WMO weather interpretation codes used by the weather provider.
const WEATHER_CODES = {
0: 'clear sky',
1: 'mainly clear',
2: 'partly cloudy',
3: 'overcast',
45: 'fog',
48: 'depositing rime fog',
51: 'light drizzle',
53: 'drizzle',
55: 'dense drizzle',
56: 'light freezing drizzle',
57: 'freezing drizzle',
61: 'light rain',
63: 'rain',
65: 'heavy rain',
66: 'light freezing rain',
67: 'freezing rain',
71: 'light snow',
73: 'snow',
75: 'heavy snow',
77: 'snow grains',
80: 'light rain showers',
81: 'rain showers',
82: 'violent rain showers',
85: 'light snow showers',
86: 'snow showers',
95: 'thunderstorm',
96: 'thunderstorm with light hail',
99: 'thunderstorm with heavy hail',
};
function weatherRow(weather) {
if (!weather) return null;
return {
observed_at: weather.observedAt ?? null,
conditions: WEATHER_CODES[weather.weatherCode] ?? null,
weather_code: weather.weatherCode ?? null,
temperature_c: weather.temperatureC ?? null,
feels_like_c: weather.apparentTemperatureC ?? null,
precipitation_mm: weather.precipitationMm ?? null,
cloud_cover_pct: weather.cloudCoverPct ?? null,
wind_kph: weather.windKph ?? null,
wind_direction_deg: weather.windDirectionDeg ?? null,
visibility_m: weather.visibilityM ?? null,
};
}
function describeWeather(row) {
if (!row) return 'no current weather';
const parts = [
row.conditions,
Number.isFinite(row.temperature_c) && `${Math.round(row.temperature_c)}°C`,
Number.isFinite(row.wind_kph) && `wind ${Math.round(row.wind_kph)} km/h`,
].filter(Boolean);
return parts.join(', ') || 'conditions unknown';
}
export const getWeather = defineTool({
name: 'get_weather',
title: 'Current weather',
description:
'Current weather at a location: conditions, temperature, feels-like, ' +
'precipitation, cloud cover, wind and visibility.',
inputSchema: {
type: 'object',
properties: { location: POINT_SCHEMA },
required: ['location'],
additionalProperties: false,
},
requires: ['weather'],
async run(args, { services, signal }) {
const point = await resolvePoint(args.location, { services, signal });
const payload = await services.weather.getConditions(point.lat, point.lon, {
signal,
});
const row = weatherRow(payload?.weather);
// The proxy answers from its cache, marked stale, when a refresh fails.
const stale = payload?.status === 'stale';
return {
summary:
`Weather at ${point.label}: ${describeWeather(row)}` +
(stale ? ' (data may be stale).' : '.'),
data: {
location: point,
weather: row,
stale,
retrieved_at: payload?.retrievedAt ?? null,
},
};
},
});
export const getRegionalBrief = defineTool({
name: 'get_regional_brief',
title: 'Regional brief',
description:
'What and where a location is, its current weather, and recent news ' +
'headlines for the region.',
inputSchema: {
type: 'object',
properties: { location: POINT_SCHEMA },
required: ['location'],
additionalProperties: false,
},
requires: ['regional'],
async run(args, { services, signal }) {
const point = await resolvePoint(args.location, { services, signal });
const brief = await services.regional.getBrief(point.lat, point.lon, {
signal,
});
const place = brief?.place ?? null;
const articles = (brief?.articles || []).slice(0, 10).map((article) => ({
title: article.title ?? null,
url: article.url ?? null,
source: article.domain ?? null,
published_at: article.publishedAt ?? null,
}));
const weather = weatherRow(brief?.weather);
const label = place?.label || point.label;
return {
summary: `${label}: ${describeWeather(weather)}; ${countNoun(articles.length, 'recent headline')}.`,
data: {
location: point,
place: place && {
label: place.label ?? null,
locality: place.locality ?? null,
region: place.region ?? null,
country: place.country ?? null,
kind: place.kind ?? null,
},
weather,
news_source: brief?.newsSource ?? null,
articles,
},
};
},
});
export const getCyclones = defineTool({
name: 'get_cyclones',
title: 'Tropical cyclones',
description:
'Active tropical cyclones from the NOAA National Hurricane Center and ' +
'Central Pacific Hurricane Center, optionally limited to an area.',
inputSchema: {
type: 'object',
properties: { area: AREA_SCHEMA },
additionalProperties: false,
},
requires: ['cyclones'],
async run(args, { services, signal }) {
const area = args.area
? await resolveArea(args.area, { services, signal })
: null;
const snapshot = await services.cyclones.getSnapshot({ signal });
if (snapshot.unavailable)
throw new ToolError(
'unavailable',
'Cyclone advisories are unavailable right now',
);
const storms = snapshot.storms
.filter(
(storm) =>
!area ||
areaContains(area, {
lat: storm.position.latitude,
lon: storm.position.longitude,
}),
)
.map((storm) => ({
id: storm.id,
name: storm.name,
classification: storm.classification,
basin: storm.basin,
lat: storm.position.latitude,
lon: storm.position.longitude,
position_at: storm.positionAt,
wind_kt: storm.windKt,
pressure_hpa: storm.pressureHpa,
movement: storm.movement ?? null,
advisory: storm.advisoryNumber,
issued_at: storm.issuedAt,
advisory_url: storm.advisoryUrl,
}))
.sort((a, b) => (b.wind_kt ?? 0) - (a.wind_kt ?? 0));
const where = area ? ` in ${area.label}` : '';
return {
summary:
`${countNoun(storms.length, 'active tropical cyclone')}${where}` +
(storms[0]
? `; strongest ${storms[0].name} at ${storms[0].wind_kt} kt.`
: '.'),
data: {
view: suggestView(
services,
area
? { area, layers: ['weather-cyclones'] }
: {
point: { lat: 20, lon: 0 },
altitudeM: 15_000_000,
layers: ['weather-cyclones'],
},
),
storms,
coverage: snapshot.coverage ?? null,
source: snapshot.source ?? null,
attribution: snapshot.attribution ?? null,
stale: snapshot.stale === true,
},
};
},
});
export const getFirePerimeters = defineTool({
name: 'get_fire_perimeters',
title: 'Wildfire perimeters',
description:
'Mapped wildfire perimeters from the US WFIGS service in an area, ' +
'largest first, with size, containment, cause and personnel.',
inputSchema: {
type: 'object',
properties: { area: AREA_SCHEMA, limit: LIMIT_SCHEMA },
required: ['area'],
additionalProperties: false,
},
requires: ['perimeters'],
async run(args, { services, signal }) {
const area = await resolveArea(args.area, { services, signal });
const rows = (await services.perimeters.getSnapshot({ signal }))
.filter((fire) => polygonsTouchArea(fire.polygons, area))
.map((fire) => ({ fire, point: polygonCenter(fire.polygons) }))
.filter(({ point }) => point)
.map(({ fire, point }) => ({
id: fire.stableId,
name: fire.name,
acres: round(fire.acres, 0),
contained_pct: fire.containedPct,
state: fire.state,
county: fire.county,
category: fire.category,
cause: fire.cause,
behavior: fire.behavior,
personnel: fire.personnel,
discovered: isoTime(fire.discoveredTime),
updated: isoTime(fire.updatedTime),
lat: round(point.lat, 5),
lon: round(point.lon, 5),
}))
.sort((a, b) => (b.acres ?? 0) - (a.acres ?? 0));
return {
summary: `${countNoun(rows.length, 'mapped wildfire perimeter')} in ${area.label}.`,
data: {
view: suggestView(services, { area, layers: ['fire-perimeters'] }),
...capRows(rows, args.limit),
},
};
},
});
/** The average vertex of a perimeter's outer rings, enough to place it in an area. */
function polygonCenter(polygons) {
let lat = 0;
let lon = 0;
let count = 0;
for (const polygon of polygons || []) {
for (const [x, y] of polygon?.[0] || []) {
if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
lon += x;
lat += y;
count += 1;
}
}
return count ? { lat: lat / count, lon: lon / count } : null;
}
export const getTerrainHeight = defineTool({
name: 'get_terrain_height',
title: 'Terrain height',
description:
'Ground elevation above sea level at up to 20 points, with the geoid ' +
'and ellipsoid heights.',
inputSchema: {
type: 'object',
properties: {
points: {
type: 'array',
minItems: 1,
maxItems: MAX_TERRAIN_POINTS,
items: {
type: 'object',
properties: {
lat: { type: 'number', minimum: -90, maximum: 90 },
lon: { type: 'number', minimum: -180, maximum: 180 },
},
required: ['lat', 'lon'],
additionalProperties: false,
},
},
},
required: ['points'],
additionalProperties: false,
},
requires: ['terrain'],
async run(args, { services, signal }) {
const results = await services.terrain.getHeights(args.points, { signal });
if (!Array.isArray(results) || results.length !== args.points.length)
throw new ToolError('malformed', 'Terrain heights were incomplete');
const rows = args.points.map((point, index) => ({
lat: point.lat,
lon: point.lon,
elevation_m: round(results[index]?.elevation, 1),
geoid_m: round(results[index]?.geoid, 1),
ellipsoid_m: round(results[index]?.ellipsoid, 1),
}));
const first = rows[0];
return {
summary:
rows.length === 1
? `Ground elevation is ${first.elevation_m ?? 'unknown'} m at ${first.lat}, ${first.lon}.`
: `Ground elevation at ${countNoun(rows.length, 'point')}.`,
data: { points: rows },
};
},
});
const NAMES_WAIT_MS = 5000;
/**
* The value `promise` resolves to within `ms`, or null when it takes longer
* or fails. Rejects only when `signal` aborts.
*/
function settleWithin(promise, ms, signal) {
signal?.throwIfAborted();
return new Promise((resolve, reject) => {
const done = (value) => {
clearTimeout(timer);
signal?.removeEventListener('abort', abort);
resolve(value);
};
const abort = () => {
clearTimeout(timer);
reject(signal.reason);
};
const timer = setTimeout(() => done(null), ms);
signal?.addEventListener('abort', abort, { once: true });
Promise.resolve(promise).then(
(value) => done(value ?? null),
() => done(null),
);
});
}
export const findMilitaryInstallations = defineTool({
name: 'find_military_installations',
title: 'Military installations',
description:
'Military installations mapped in OpenStreetMap within an area of at ' +
`most ${MAX_SITE_BOX_DEGREES}° on each side, nearest the center first.`,
inputSchema: {
type: 'object',
properties: { area: AREA_SCHEMA, limit: LIMIT_SCHEMA },
required: ['area'],
additionalProperties: false,
},
requires: ['installations'],
async run(args, { services, signal }) {
const area = await resolveArea(args.area, { services, signal });
if (
area.west > area.east ||
area.north - area.south > MAX_SITE_BOX_DEGREES ||
area.east - area.west > MAX_SITE_BOX_DEGREES
)
throw new ToolError(
'invalid_arguments',
`The area must be at most ${MAX_SITE_BOX_DEGREES}° on each side and not cross the antimeridian`,
);
const center = areaCenter(area);
let result = await services.installations.getMappedSites(area, {
exact: true,
thinned: false,
signal,
});
// Tile results can arrive before the name pack; wait briefly for the
// named version rather than answer with generic names.
let namesPending = false;
if (result.enrichment) {
const named = await settleWithin(
result.enrichment,
NAMES_WAIT_MS,
signal,
);
if (named) result = named;
else namesPending = true;
}
// A saturated source returned only part of the mapped sites.
const complete = !result.saturated;
const rows = (result.records || [])
.map((site) => ({
site,
point: { lat: site.latitude, lon: site.longitude },
}))
.filter(({ point }) => areaContains(area, point))
.map(({ site, point }) => ({
id: site.id,
name: site.name ?? null,
kind: site.kind ?? null,
class: site.class ?? null,
lat: round(point.lat, 5),
lon: round(point.lon, 5),
distance_km: round(distanceKm(center, point), 1),
}))
.sort((a, b) => a.distance_km - b.distance_km);
const notes = [
...(complete ? [] : ['partial: the source returned only some sites']),
...(namesPending ? ['site names are still loading'] : []),
];
return {
summary:
`${countNoun(rows.length, 'mapped military installation')} in ${area.label}` +
(notes.length ? ` (${notes.join('; ')}).` : '.'),
data: {
view: suggestView(services, {
area,
layers: ['military-installations'],
}),
...capRows(rows, args.limit),
complete,
names_pending: namesPending,
source: result.source ?? null,
},
};
},
});
export const getMapFeatures = defineTool({
name: 'get_map_features',
title: 'Map features',
description:
'Named OpenStreetMap features at a location: the administrative areas ' +
'containing it, named parks, land uses and water nearby, or monuments ' +
'and memorials within 2.5 km.',
inputSchema: {
type: 'object',
properties: {
location: POINT_SCHEMA,
kind: { type: 'string', enum: Object.keys(FEATURE_KINDS) },
limit: LIMIT_SCHEMA,
},
required: ['location', 'kind'],
additionalProperties: false,
},
requires: ['features'],
async run(args, { services, signal }) {
const point = await resolvePoint(args.location, { services, signal });
const features = await services.features[FEATURE_KINDS[args.kind]](
{ lat: point.lat, lon: point.lon },
{ signal },
);
if (features?.unavailable)
throw new ToolError(
'unavailable',
'Map feature lookups need an Overpass server configured for this app',
);
if (!Array.isArray(features))
throw new ToolError(
'retry_later',
'The map feature service did not answer',
);
const rows = features
.map((feature) => ({
id: feature.id,
name:
feature.names?.english ||
feature.names?.primary ||
feature.names?.official ||
null,
category: feature.category,
admin_level: feature.level === 99 ? null : feature.level,
lat: round(feature.point?.lat, 5),
lon: round(feature.point?.lon, 5),
}))
.filter((row) => row.name)
.sort((a, b) => (a.admin_level ?? 99) - (b.admin_level ?? 99));
const kind = args.kind.replace('_', ' ');
return {
summary: `${countNoun(rows.length, 'named feature')} (${kind}) at ${point.label}.`,
data: { location: point, ...capRows(rows, args.limit) },
};
},
});
+782
View File
@@ -0,0 +1,782 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
catalogForSurface,
composeCatalog,
coreTools,
defineTool,
ToolError,
} from '../index.js';
const conditions = {
observedAt: '2026-10-01T19:15:00.000Z',
temperatureC: 26.3,
apparentTemperatureC: 32,
precipitationMm: 0,
cloudCoverPct: 100,
windKph: 5.4,
windDirectionDeg: 318,
visibilityM: 15400,
weatherCode: 3,
};
const weather = {
calls: [],
async getConditions(latitude, longitude) {
this.calls.push([latitude, longitude]);
return { status: 'ready', weather: conditions };
},
};
const places = {
resolve: async (name) =>
name === 'Austin'
? {
name: 'Austin, Texas',
bounds: { west: -97.94, south: 30.1, east: -97.56, north: 30.52 },
}
: null,
};
const catalogWith = (services) =>
composeCatalog({ tools: coreTools, services });
test('weather describes current conditions at a point or place', async () => {
const catalog = catalogWith({ weather, places });
const result = await catalog.call('get_weather', {
location: { place: 'Austin' },
});
assert.equal(
result.summary,
'Weather at Austin, Texas: overcast, 26°C, wind 5 km/h.',
);
assert.deepEqual(result.data.weather, {
observed_at: '2026-10-01T19:15:00.000Z',
conditions: 'overcast',
weather_code: 3,
temperature_c: 26.3,
feels_like_c: 32,
precipitation_mm: 0,
cloud_cover_pct: 100,
wind_kph: 5.4,
wind_direction_deg: 318,
visibility_m: 15400,
});
const [latitude, longitude] = weather.calls.at(-1);
assert.ok(Math.abs(latitude - 30.31) < 1e-9 && longitude === -97.75);
});
test('the regional brief returns place, weather and at most ten headlines', async () => {
const regional = {
async getBrief() {
return {
place: {
label: 'Edwards Plateau',
locality: null,
region: 'Edwards Plateau',
country: null,
kind: 'natural',
},
weather: conditions,
newsSource: 'Google News RSS',
articles: Array.from({ length: 12 }, (_, index) => ({
title: `Story ${index}`,
url: `https://news.example/${index}`,
domain: 'news.example',
publishedAt: '2026-10-01T00:00:00Z',
})),
};
},
};
const result = await catalogWith({ regional }).call('get_regional_brief', {
location: { lat: 30.27, lon: -97.74 },
});
assert.equal(
result.summary,
'Edwards Plateau: overcast, 26°C, wind 5 km/h; 10 recent headlines.',
);
assert.equal(result.data.articles.length, 10);
assert.deepEqual(result.data.articles[0], {
title: 'Story 0',
url: 'https://news.example/0',
source: 'news.example',
published_at: '2026-10-01T00:00:00Z',
});
});
const storm = (id, name, latitude, longitude, windKt) => ({
id,
name,
classification: 'Hurricane',
basin: 'Atlantic',
position: { latitude, longitude },
positionAt: '2026-10-01T18:00:00.000Z',
advisoryNumber: '12',
issuedAt: '2026-10-01T15:00:00.000Z',
windKt,
pressureHpa: 980,
movement: { towardDeg: 300, speedKt: 10 },
advisoryUrl: 'https://www.nhc.noaa.gov/text/X.shtml',
});
test('cyclones are listed strongest first and can be limited to an area', async () => {
const cyclones = {
async getSnapshot() {
return {
source: 'NOAA NHC / CPHC',
coverage: 'Atlantic and eastern/central North Pacific',
stale: false,
unavailable: false,
storms: [
storm('a', 'Alpha', 25, -70, 65),
storm('b', 'Beta', 15, -110, 120),
],
};
},
};
const all = await catalogWith({ cyclones }).call('get_cyclones', {});
assert.equal(
all.summary,
'2 active tropical cyclones; strongest Beta at 120 kt.',
);
assert.deepEqual(
all.data.storms.map((row) => row.id),
['b', 'a'],
);
const atlantic = await catalogWith({ cyclones }).call('get_cyclones', {
area: { bbox: [-100, 0, -10, 50] },
});
assert.equal(
atlantic.summary,
'1 active tropical cyclone in the requested box; strongest Alpha at 65 kt.',
);
const down = { getSnapshot: async () => ({ unavailable: true, storms: [] }) };
await assert.rejects(
catalogWith({ cyclones: down }).call('get_cyclones', {}),
(error) => error.code === 'unavailable',
);
});
test('fire perimeters are placed by their outline and sorted by size', async () => {
const ring = (lon, lat) => [
[
[lon, lat],
[lon + 0.1, lat],
[lon + 0.1, lat + 0.1],
[lon, lat + 0.1],
],
];
const perimeters = {
async getSnapshot() {
return [
{
stableId: 'small',
name: 'Small Fire',
acres: 120.4,
containedPct: 80,
state: 'US-CA',
county: 'Kern',
category: 'WF',
cause: 'Human',
behavior: null,
personnel: 20,
discoveredTime: Date.UTC(2026, 8, 1),
updatedTime: null,
polygons: [ring(-118.5, 35.2)],
},
{
stableId: 'big',
name: 'Big Fire',
acres: 5000,
containedPct: 10,
polygons: [ring(-118.8, 35.4)],
},
{
stableId: 'far',
name: 'Far Fire',
acres: 9000,
polygons: [ring(-80, 30)],
},
{ stableId: 'empty', name: 'No Outline', acres: 1, polygons: [] },
];
},
};
const result = await catalogWith({ perimeters }).call('get_fire_perimeters', {
area: { bbox: [-120, 34, -117, 36] },
});
assert.equal(
result.summary,
'2 mapped wildfire perimeters in the requested box.',
);
assert.deepEqual(
result.data.rows.map((row) => row.id),
['big', 'small'],
);
assert.deepEqual(result.data.rows[1], {
id: 'small',
name: 'Small Fire',
acres: 120,
contained_pct: 80,
state: 'US-CA',
county: 'Kern',
category: 'WF',
cause: 'Human',
behavior: null,
personnel: 20,
discovered: '2026-09-01T00:00:00.000Z',
updated: null,
lat: 35.25,
lon: -118.45,
});
});
test('terrain heights are returned in request order', async () => {
const terrain = {
async getHeights(points) {
return points.map((point) => ({
...point,
elevation: point.lat * 100,
geoid: -30.04,
ellipsoid: point.lat * 100 - 30.04,
}));
},
};
const one = await catalogWith({ terrain }).call('get_terrain_height', {
points: [{ lat: 27.9881, lon: 86.925 }],
});
assert.equal(one.summary, 'Ground elevation is 2798.8 m at 27.9881, 86.925.');
const many = await catalogWith({ terrain }).call('get_terrain_height', {
points: [
{ lat: 1, lon: 1 },
{ lat: 2, lon: 2 },
],
});
assert.deepEqual(
many.data.points.map((point) => point.elevation_m),
[100, 200],
);
const short = { getHeights: async () => [] };
await assert.rejects(
catalogWith({ terrain: short }).call('get_terrain_height', {
points: [{ lat: 1, lon: 1 }],
}),
(error) => error.code === 'malformed',
);
await assert.rejects(
catalogWith({ terrain }).call('get_terrain_height', {
points: Array(21).fill({ lat: 0, lon: 0 }),
}),
/at most 20 items/,
);
});
test('military installations need a bounded area and are nearest first', async () => {
const requested = [];
const options = [];
let saturated = false;
const installations = {
async getMappedSites(box, { signal, ...rest } = {}) {
requested.push(box);
options.push(rest);
return {
saturated,
source: 'OpenStreetMap',
records: [
{
id: 'osm:1',
kind: 'installation',
class: 'base',
name: 'Naval Base Point Loma',
latitude: 32.6941,
longitude: -117.2494,
},
{
id: 'osm:2',
kind: 'installation',
class: 'airfield',
name: 'North Island',
latitude: 32.7,
longitude: -117.2,
},
],
};
},
};
const catalog = catalogWith({ installations });
const result = await catalog.call('find_military_installations', {
area: { lat: 32.7, lon: -117.2, radius_km: 20 },
});
assert.equal(
result.summary,
'2 mapped military installations in 20 km around 32.700, -117.200.',
);
assert.deepEqual(
result.data.rows.map((row) => row.id),
['osm:2', 'osm:1'],
);
assert.equal(result.data.source, 'OpenStreetMap');
assert.equal(result.data.complete, true);
assert.ok(requested[0].north - requested[0].south < 1);
assert.deepEqual(options[0], { exact: true, thinned: false });
saturated = true;
const partial = await catalog.call('find_military_installations', {
area: { lat: 32.7, lon: -117.2, radius_km: 20 },
});
assert.equal(partial.data.complete, false);
assert.match(
partial.summary,
/\(partial: the source returned only some sites\)\.$/,
);
await assert.rejects(
catalog.call('find_military_installations', {
area: { bbox: [-130, 20, -100, 50] },
}),
/at most 10° on each side/,
);
});
test('map features name what is at a location and report an unconfigured server', async () => {
const features = {
async getAdministrativeAreas() {
return [
{
id: 1,
category: 'administrative',
names: { primary: 'Texas' },
level: 4,
point: null,
},
{
id: 2,
category: 'administrative',
names: { primary: 'Travis County', english: 'Travis County' },
level: 6,
point: null,
},
{ id: 3, category: 'administrative', names: {}, level: 8, point: null },
];
},
getEnclosingAreas: async () => null,
getMonuments: async () => ({
unavailable: true,
code: 'OVERPASS_NOT_CONFIGURED',
}),
};
const catalog = catalogWith({ features });
const result = await catalog.call('get_map_features', {
location: { lat: 30.27, lon: -97.74 },
kind: 'administrative_areas',
});
assert.equal(
result.summary,
'2 named features (administrative areas) at 30.2700, -97.7400.',
);
assert.deepEqual(
result.data.rows.map((row) => [row.name, row.admin_level]),
[
['Texas', 4],
['Travis County', 6],
],
);
await assert.rejects(
catalog.call('get_map_features', {
location: { lat: 30.27, lon: -97.74 },
kind: 'monuments',
}),
(error) => error.code === 'unavailable' && /Overpass/.test(error.message),
);
await assert.rejects(
catalog.call('get_map_features', {
location: { lat: 30.27, lon: -97.74 },
kind: 'named_places',
}),
(error) => error.code === 'retry_later',
);
});
test('the situation brief combines available sections and marks failures', async () => {
const services = {
weather,
earthquakes: {
getSnapshot: async () => [
{
stableId: 'q',
usgsId: 'us1',
lat: 30.3,
lon: -97.7,
mag: 3.1,
place: 'near Austin',
time: 0,
depthKm: 5,
},
],
},
fires: { getSnapshot: async () => ({ keyRequired: true }) },
cyclones: {
getSnapshot: async () => {
throw new Error('upstream down');
},
},
places,
};
const result = await catalogWith(services).call('situation_brief', {
area: { place: 'Austin' },
});
assert.equal(
result.summary,
'Situation in Austin, Texas: Weather at Austin, Texas: overcast, 26°C, wind 5 km/h. ' +
'1 earthquake of M2.5+ in the last 24 hours in Austin, Texas; strongest M3.1 near Austin.',
);
assert.deepEqual(Object.keys(result.data.sections), [
'weather',
'earthquakes',
'fires',
'cyclones',
]);
assert.deepEqual(result.data.sections.fires, {
unavailable: true,
reason: 'Fire detections need a NASA FIRMS key configured for this server',
});
assert.deepEqual(result.data.sections.cyclones, {
unavailable: true,
reason: 'unavailable right now',
});
assert.equal(result.data.sections.aircraft, undefined);
});
test('the HUD caption summarizes the brief through the summary service', async () => {
const sent = [];
const summary = {
async summarize(context) {
sent.push(context);
return {
ok: true,
status: 200,
data: { summary: 'Overcast skies over Austin', error: null },
};
},
};
const result = await catalogWith({ weather, summary, places }).call(
'get_hud_caption',
{ area: { place: 'Austin' } },
);
assert.equal(result.summary, 'Overcast skies over Austin');
assert.equal(sent[0].location, 'Austin, Texas');
assert.deepEqual(sent[0].placeLabels, ['Austin, Texas']);
assert.deepEqual(sent[0].enabledLayerLabels, ['Weather']);
assert.equal(sent[0].feedProvenance.overall, 'nominal');
assert.match(sent[0].observations[0], /^Weather at /);
const failing = {
summarize: async () => ({
ok: false,
status: 502,
data: { summary: null },
}),
};
await assert.rejects(
catalogWith({ weather, summary: failing, places }).call('get_hud_caption', {
area: { place: 'Austin' },
}),
(error) => error instanceof ToolError && error.code === 'unavailable',
);
});
test('military awareness gathers contacts around a point and marks failures', async () => {
const contact = (id, latitude, longitude) => ({
id,
reference: id,
latitude,
longitude,
callsign: 'RCH123',
baroAltitudeM: 9000,
onGround: false,
positionTimeMs: Date.UTC(2026, 0, 1),
});
const military = {
getSnapshot: async () => ({
records: [contact('ae1234', 32.8, -117.1), contact('ae9999', 40, -100)],
complete: true,
source: 'Military feed',
freshness: 'current',
}),
};
const aircraft = {
getSnapshot: async () => {
throw new Error('upstream down');
},
};
const installations = {
getMappedSites: async () => ({
source: 'OpenStreetMap',
records: [
{
id: 'osm:1',
kind: 'installation',
class: 'base',
name: 'Naval Base Point Loma',
latitude: 32.6941,
longitude: -117.2494,
},
],
}),
};
const catalog = catalogWith({ military, aircraft, installations });
const result = await catalog.call('military_awareness', {
location: { lat: 32.7, lon: -117.2 },
radius_km: 100,
});
assert.equal(
result.summary,
'Military awareness within 100 km of 32.7000, -117.2000: ' +
'1 military aircraft in 100 km around 32.700, -117.200. ' +
'1 mapped military installation in 100 km around 32.700, -117.200.',
);
assert.deepEqual(Object.keys(result.data.sections), [
'military_aircraft',
'aircraft',
'installations',
]);
assert.deepEqual(
result.data.sections.military_aircraft.data.rows.map((row) => row.id),
['ae1234'],
);
assert.deepEqual(result.data.sections.aircraft, {
unavailable: true,
reason: 'unavailable right now',
});
assert.equal(result.data.radius_km, 100);
const voice = catalogForSurface(catalog, 'voice');
assert.equal(voice.get('aircraft_in_area'), undefined);
assert.deepEqual(
Object.keys(
(
await voice.call('military_awareness', {
location: { lat: 32.7, lon: -117.2 },
radius_km: 100,
})
).data.sections,
),
['military_aircraft', 'aircraft', 'installations'],
);
assert.equal(
(await catalog.call('military_awareness', { location: { lat: 0, lon: 0 } }))
.data.radius_km,
250,
);
await assert.rejects(
catalog.call('military_awareness', {
location: { lat: 0, lon: 0 },
radius_km: 500,
}),
/radius_km/,
);
});
test('composites reach their sections through the catalog', async () => {
const replacement = defineTool({
name: 'get_weather',
title: 'Weather',
description: 'Replaced weather.',
inputSchema: {
type: 'object',
properties: { location: { type: 'object' } },
},
run: async () => ({ summary: 'Replaced weather.', data: {} }),
});
const seen = [];
const catalog = composeCatalog({
tools: [...coreTools, replacement],
replace: ['get_weather'],
services: { weather, places },
interceptors: [
(call, next) => {
seen.push([call.tool.name, call.parent ?? null]);
return next(call);
},
],
});
const result = await catalog.call('situation_brief', {
area: { place: 'Austin' },
});
assert.equal(result.data.sections.weather.summary, 'Replaced weather.');
assert.deepEqual(seen, [
['situation_brief', null],
['get_weather', 'situation_brief'],
]);
});
test('the HUD caption context carries each section feed state', async () => {
const sent = [];
const summary = {
async summarize(context) {
sent.push(context);
return {
ok: true,
status: 200,
data: { summary: 'Austin aircraft STALE' },
};
},
};
const aircraft = {
getSnapshot: async () => ({
records: [],
complete: true,
source: 'Test feed',
freshness: 'stale',
}),
};
const cyclones = {
getSnapshot: async () => {
throw new Error('upstream down');
},
};
await catalogWith({ weather, summary, places, aircraft, cyclones }).call(
'get_hud_caption',
{ area: { place: 'Austin' } },
);
assert.deepEqual(
sent[0].enabledLayers.map((layer) => [layer.id, layer.feedState]),
[
['weather', 'nominal'],
['aircraft', 'stale'],
['cyclones', 'unavailable'],
],
);
assert.equal(sent[0].feedProvenance.overall, 'unavailable');
assert.match(sent[0].feedProvenance.note, /Aircraft is STALE/);
});
test('brief sections use a named place at its own point', async () => {
const tokyo = {
resolve: async () => ({
name: 'Tokyo, Japan',
point: { lat: 35.6769, lon: 139.7639 },
bounds: { west: 135, south: 20, east: 155, north: 36 },
}),
};
const queries = [];
const aircraft = {
getSnapshot: async (query) => {
queries.push(query);
return { records: [], complete: true, source: 'Test feed' };
},
};
const result = await catalogWith({ weather, places: tokyo, aircraft }).call(
'situation_brief',
{ area: { place: 'Tokyo' } },
);
assert.deepEqual(queries[0], { latitude: 35.6769, longitude: 139.7639 });
assert.match(result.data.sections.aircraft.summary, /in Tokyo, Japan\.$/);
});
test('a fire perimeter enclosing the whole area is found', async () => {
const perimeters = {
getSnapshot: async () => [
{
stableId: 'big',
name: 'Big Fire',
acres: 250000,
polygons: [
[
[
[-122, 38],
[-120, 38],
[-120, 40],
[-122, 40],
],
],
],
},
],
};
const result = await catalogWith({ perimeters }).call('get_fire_perimeters', {
area: { bbox: [-121.2, 38.9, -120.8, 39.1] },
});
assert.deepEqual(
result.data.rows.map((row) => row.id),
['big'],
);
});
test('stale weather and partial fire data reach answers and the HUD caption', async () => {
const sent = [];
const summary = {
async summarize(context) {
sent.push(context);
return { ok: true, status: 200, data: { summary: 'Austin STALE' } };
},
};
const staleWeather = {
getConditions: async () => ({ status: 'stale', weather: conditions }),
};
const fires = {
getSnapshot: async () => ({
stale: false,
fetchedAt: Date.UTC(2026, 0, 1),
sources: [
{ source: 'VIIRS_NOAA20_NRT', ok: true },
{ source: 'VIIRS_SNPP_NRT', ok: false },
],
fires: [],
}),
};
const catalog = catalogWith({
weather: staleWeather,
summary,
places,
fires,
});
const weatherResult = await catalog.call('get_weather', {
location: { place: 'Austin' },
});
assert.equal(weatherResult.data.stale, true);
assert.match(weatherResult.summary, /\(data may be stale\)\.$/);
const fireResult = await catalog.call('get_active_fires', {
area: { place: 'Austin' },
});
assert.deepEqual(fireResult.data.missing_sources, ['VIIRS_SNPP_NRT']);
assert.match(fireResult.summary, /\(no data from VIIRS_SNPP_NRT\)\.$/);
await catalog.call('get_hud_caption', { area: { place: 'Austin' } });
assert.deepEqual(
sent[0].enabledLayers.map((layer) => [layer.id, layer.feedState]),
[
['weather', 'stale'],
['fires', 'degraded'],
],
);
});
test('installation names wait for the name pack, briefly', async (t) => {
const site = (name) => ({
id: 'osm:9',
kind: 'installation',
name,
latitude: 30.31,
longitude: -97.76,
});
const source = (enrichment) => ({
getMappedSites: async () => ({
source: 'OpenStreetMap tiles',
records: [site('Military area')],
enrichment,
}),
});
const area = { lat: 30.31, lon: -97.76, radius_km: 5 };
const named = await catalogWith({
installations: source(
Promise.resolve({
source: 'OpenStreetMap tiles',
records: [site('Camp Mabry')],
}),
),
}).call('find_military_installations', { area });
assert.equal(named.data.rows[0].name, 'Camp Mabry');
assert.equal(named.data.names_pending, false);
t.mock.timers.enable({ apis: ['setTimeout'] });
const pending = catalogWith({
installations: source(new Promise(() => {})),
}).call('find_military_installations', { area });
await new Promise((resolve) => setImmediate(resolve));
t.mock.timers.tick(5000);
const slow = await pending;
assert.equal(slow.data.rows[0].name, 'Military area');
assert.equal(slow.data.names_pending, true);
assert.match(slow.summary, /site names are still loading\)\.$/);
});
+147
View File
@@ -0,0 +1,147 @@
/** The bundled Bhote Koshi 2026 outburst flood event pack. */
import { suggestView } from '../views.js';
import { BHOTE_KOSHI_FLOOD_PATH } from '../../data/bhoteKoshiFloodPath.js';
import { defineTool, ToolError } from '../catalog.js';
import { distanceKm } from '../area.js';
const EVENT_ID = 'bhote-koshi-2026';
const ATTRIBUTION =
'Imagery: Vantor Open Data; reconstruction: GeoPera (CC BY-NC 4.0). ' +
'Linked witness posts and the GeoGeorgeShadrach geolocation map keep ' +
"their owners' terms.";
const round = (value, digits) =>
Number.isFinite(value) ? Number(value.toFixed(digits)) : null;
/** Length of a `[lon, lat]` line in kilometers. */
function pathLengthKm(path) {
let total = 0;
for (let index = 1; index < path.length; index += 1)
total += distanceKm(
{ lat: path[index - 1][1], lon: path[index - 1][0] },
{ lat: path[index][1], lon: path[index][0] },
);
return total;
}
const pathPoint = ([lon, lat]) => ({ lat, lon });
/** The box around a `[lon, lat]` path, as an area to frame. */
function pathArea(path) {
const lons = path.map(([lon]) => lon);
const lats = path.map(([, lat]) => lat);
return {
west: Math.min(...lons),
south: Math.min(...lats),
east: Math.max(...lons),
north: Math.max(...lats),
};
}
function evidenceRow(record) {
return {
sequence: record.sequence ?? null,
id: record.id,
title: record.title ?? null,
summary: record.summary ?? null,
phase: record.phase ?? null,
lat: round(record.lat, 5),
lon: round(record.lon, 5),
location_confidence: record.confidence?.location ?? null,
time_confidence: record.confidence?.time ?? null,
captured_at: record.timing?.capturedAt ?? null,
in_imagery: record.imageryCoverage === 'within',
source_url: record.media?.sourceUrl ?? null,
platform: record.media?.platform ?? null,
via: record.provenance?.via ?? null,
};
}
export const getBhoteKoshiFlood = defineTool({
name: 'get_bhote_koshi_flood',
title: 'Bhote Koshi flood',
description:
"The 26 August 2026 Bhote Koshi outburst flood in Nepal as God's Eye " +
'View maps it: the evidence trail of geolocated public witness posts ' +
'from the collapse to Devighat in story order, the mapped flood path, ' +
'and the before and after satellite imagery. A visualization, not an ' +
'official hazard model; capture times are unverified.',
inputSchema: {
type: 'object',
properties: {
phase: {
type: 'string',
minLength: 1,
maxLength: 40,
description: 'Only evidence in a phase, such as "downstream passage".',
},
},
additionalProperties: false,
},
requires: ['events'],
async run(args, { services, signal }) {
const event = await services.events.getEvent(EVENT_ID, { signal });
if (!Array.isArray(event?.evidenceSpine))
throw new ToolError('malformed', 'The event pack has no evidence');
const wanted = args.phase?.toLowerCase();
const evidence = event.evidenceSpine
.filter((record) => !wanted || record.phase?.toLowerCase() === wanted)
.sort((a, b) => a.sequence - b.sequence)
.map(evidenceRow);
const phases = [
...new Set(event.evidenceSpine.map((record) => record.phase)),
];
const lengthKm = round(pathLengthKm(BHOTE_KOSHI_FLOOD_PATH), 1);
const { imagery = {} } = event;
return {
summary:
`${event.title}, observed ${event.observedDate}: ${evidence.length} ` +
`evidence record${evidence.length === 1 ? '' : 's'}` +
(args.phase ? ` in the ${args.phase} phase` : '') +
` along a ${lengthKm} km mapped flood path.`,
data: {
view: suggestView(services, {
area: pathArea(BHOTE_KOSHI_FLOOD_PATH),
layers: ['bhote-koshi-2026'],
}),
id: event.id,
title: event.title,
observed_date: event.observedDate,
caveat: event.reconstruction?.caveat ?? null,
phases,
evidence,
witnesses: (event.witnessAnchors || []).map((anchor) => ({
title: anchor.title ?? null,
lat: round(anchor.lat, 6),
lon: round(anchor.lon, 6),
source_url: anchor.sourceUrl ?? null,
provenance: anchor.provenance ?? null,
})),
flood_path: {
start: pathPoint(BHOTE_KOSHI_FLOOD_PATH[0]),
end: pathPoint(BHOTE_KOSHI_FLOOD_PATH.at(-1)),
length_km: lengthKm,
description:
'Simplified river centerline from the debris-dammed lake to Trishuli Bazaar.',
},
imagery: {
box: imagery.rectangle ?? null,
before: imagery.before
? {
observed_at: imagery.before.observedAt,
source: imagery.before.source,
}
: null,
after: imagery.after
? {
observed_at: imagery.after.observedAt,
source: imagery.after.source,
}
: null,
},
attribution: ATTRIBUTION,
},
};
},
});
Loaded 100 of 137 files, more files were not shown because too many files have changed in this diff. Show more