From d6473b6049285f75620bc041ecfc46829ab168fa Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 21:25:27 -0700 Subject: [PATCH] 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. --- docs/TOOLS.md | 11 ++- scripts/format-scope.json | 3 +- scripts/package-boundaries.json | 3 +- src/tools/queries/atmosphere.js | 26 +++++++ src/tools/queries/aviation.js | 49 +++++++++++-- src/tools/queries/brief.js | 21 +++++- src/tools/queries/cables.js | 11 +++ src/tools/queries/environment.js | 20 +++++- src/tools/queries/events.js | 17 +++++ src/tools/queries/hazards.js | 12 ++++ src/tools/queries/imagery.js | 2 + src/tools/queries/infrastructure.js | 2 + src/tools/queries/maritime.js | 28 +++++++- src/tools/queries/media.js | 8 +++ src/tools/queries/mobility.js | 4 ++ src/tools/queries/share.js | 22 +----- src/tools/queries/space.js | 29 +++++++- src/tools/queries/surveillance.js | 2 + src/tools/views.js | 55 +++++++++++++++ src/tools/views.test.mjs | 102 ++++++++++++++++++++++++++++ 20 files changed, 393 insertions(+), 34 deletions(-) create mode 100644 src/tools/views.js create mode 100644 src/tools/views.test.mjs diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 2b38d9cf1..c13dd9073 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -52,7 +52,10 @@ as `structuredContent`, for clients that read only one of them. `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. `catalogForSurface(catalog, surface, overrides)` is the +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 @@ -95,6 +98,12 @@ Tools take a view as `VIEW_ARGUMENTS`: an `area` to frame from above, or a with an area override its framing. `resolveViewArguments` turns them into a view. +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` diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 1a98d255b..b32160de0 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -889,5 +889,6 @@ "src/tools/functions.test.mjs", "src/tools/surfaces.test.mjs", "src/tools/places.test.mjs", - "src/view/index.test.mjs" + "src/view/index.test.mjs", + "src/tools/views.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index c7c3fb2f3..b42038d1a 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3133,7 +3133,8 @@ "src/data/feedState.js", "src/loadingFeedback.js", "src/data/installationFeedback.js", - "src/view/index.js" + "src/view/index.js", + "src/tools/views.js" ], "external": ["satellite.js"] }, diff --git a/src/tools/queries/atmosphere.js b/src/tools/queries/atmosphere.js index f8e32012c..d20a58027 100644 --- a/src/tools/queries/atmosphere.js +++ b/src/tools/queries/atmosphere.js @@ -1,5 +1,6 @@ /** 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'; @@ -20,6 +21,13 @@ const MAPS = { 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; @@ -136,6 +144,19 @@ export const getWeatherMap = defineTool({ 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, @@ -188,6 +209,11 @@ export const getWind = defineTool({ `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), diff --git a/src/tools/queries/aviation.js b/src/tools/queries/aviation.js index d4b95d3b2..0b8b54b79 100644 --- a/src/tools/queries/aviation.js +++ b/src/tools/queries/aviation.js @@ -1,5 +1,6 @@ /** Aviation queries over the live aircraft sources (src/sources/live). */ +import { suggestView } from '../views.js'; import { defineTool, ToolError } from '../catalog.js'; import { AREA_SCHEMA, @@ -129,7 +130,14 @@ export const aircraftInArea = defineTool({ summary: `${rows.length} ${kind} in ${area.label}` + (notes.length ? ` (${notes.join('; ')}).` : '.'), - data: { ...capRows(rows, args.limit), ...snapshotInfo(snapshot) }, + data: { + view: suggestView(services, { + area, + layers: [args.military ? 'military' : 'flights'], + }), + ...capRows(rows, args.limit), + ...snapshotInfo(snapshot), + }, }; }, }); @@ -190,17 +198,25 @@ export const findAircraft = defineTool({ .map(({ name }) => name); if (unavailable.length === feeds.length) throw settled[0].reason; const seen = new Set(); - const rows = settled - .filter((result) => result.status === 'fulfilled') - .flatMap((result) => result.value.records) + const matches = settled + .flatMap((result, index) => + result.status === 'fulfilled' + ? result.value.records.map((record) => ({ + record, + feed: feeds[index].name, + })) + : [], + ) .filter( - (record) => + ({ record }) => String(record[field] || '') .trim() .toUpperCase() === wanted, ) - .filter((record) => !seen.has(record.id) && seen.add(record.id)) - .map((record) => aircraftRow(record)); + .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' @@ -222,6 +238,16 @@ export const findAircraft = defineTool({ ? ` 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, @@ -264,6 +290,15 @@ export const getAircraftTrack = defineTool({ ? `${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, diff --git a/src/tools/queries/brief.js b/src/tools/queries/brief.js index e1900873d..ba8e858dd 100644 --- a/src/tools/queries/brief.js +++ b/src/tools/queries/brief.js @@ -1,5 +1,6 @@ /** 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 { @@ -28,30 +29,35 @@ const SECTIONS = [ }, { 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 }), @@ -187,7 +193,16 @@ export const situationBrief = defineTool({ const { area, brief, lines } = await buildBrief(args, context); return { summary: `Situation in ${area.label}: ${lines.join(' ')}`, - data: { area: area.label, sections: brief }, + data: { + view: suggestView(context.services, { + area, + layers: SECTIONS.filter(({ key }) => brief[key]) + .map(({ layer }) => layer) + .filter(Boolean), + }), + area: area.label, + sections: brief, + }, }; }, }); @@ -277,6 +292,10 @@ export const militaryAwareness = defineTool({ 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, diff --git a/src/tools/queries/cables.js b/src/tools/queries/cables.js index ddf69f33d..8d8c0cecf 100644 --- a/src/tools/queries/cables.js +++ b/src/tools/queries/cables.js @@ -1,5 +1,6 @@ /** Submarine cables and their landing points. */ +import { suggestView } from '../views.js'; import { defineTool, ToolError } from '../catalog.js'; import { AREA_SCHEMA, @@ -80,6 +81,16 @@ export const findSubmarineCables = defineTool({ ? `, 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, diff --git a/src/tools/queries/environment.js b/src/tools/queries/environment.js index 4c79dce5c..4f653ff32 100644 --- a/src/tools/queries/environment.js +++ b/src/tools/queries/environment.js @@ -3,6 +3,7 @@ * terrain height, mapped military sites and map features. */ +import { suggestView } from '../views.js'; import { defineTool, ToolError } from '../catalog.js'; import { AREA_SCHEMA, @@ -221,6 +222,16 @@ export const getCyclones = defineTool({ ? `; 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, @@ -269,7 +280,10 @@ export const getFirePerimeters = defineTool({ .sort((a, b) => (b.acres ?? 0) - (a.acres ?? 0)); return { summary: `${countNoun(rows.length, 'mapped wildfire perimeter')} in ${area.label}.`, - data: capRows(rows, args.limit), + data: { + view: suggestView(services, { area, layers: ['fire-perimeters'] }), + ...capRows(rows, args.limit), + }, }; }, }); @@ -436,6 +450,10 @@ export const findMilitaryInstallations = defineTool({ `${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, diff --git a/src/tools/queries/events.js b/src/tools/queries/events.js index 9b530b40b..8b654014f 100644 --- a/src/tools/queries/events.js +++ b/src/tools/queries/events.js @@ -1,5 +1,6 @@ /** 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'; @@ -26,6 +27,18 @@ function pathLengthKm(path) { 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, @@ -88,6 +101,10 @@ export const getBhoteKoshiFlood = defineTool({ (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, diff --git a/src/tools/queries/hazards.js b/src/tools/queries/hazards.js index c841d197b..475df97ba 100644 --- a/src/tools/queries/hazards.js +++ b/src/tools/queries/hazards.js @@ -1,5 +1,6 @@ /** Natural hazard queries: earthquakes and active fires. */ +import { suggestView } from '../views.js'; import { defineTool, ToolError } from '../catalog.js'; import { AREA_SCHEMA, areaContains, resolveArea } from '../area.js'; import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.js'; @@ -38,6 +39,16 @@ export const getEarthquakes = defineTool({ return { summary: `${countNoun(events.length, 'earthquake')} of M${minimum}+ in the last 24 hours${where}${strongest}.`, data: { + view: suggestView( + services, + area + ? { area, layers: ['earthquakes'] } + : { + point: { lat: 20, lon: 0 }, + altitudeM: 15_000_000, + layers: ['earthquakes'], + }, + ), ...result, rows: result.rows.map((event) => ({ id: event.usgsId ?? event.stableId, @@ -93,6 +104,7 @@ export const getActiveFires = defineTool({ `${countNoun(fires.length, 'fire detection')} in ${area.label} in the last 24 hours` + (notes.length ? ` (${notes.join('; ')}).` : '.'), data: { + view: suggestView(services, { area, layers: ['local-firms'] }), stale, missing_sources: missing, fetched_at: isoTime(snapshot.fetchedAt), diff --git a/src/tools/queries/imagery.js b/src/tools/queries/imagery.js index 5f412c5e2..310f27056 100644 --- a/src/tools/queries/imagery.js +++ b/src/tools/queries/imagery.js @@ -1,5 +1,6 @@ /** The most recent satellite image of an area. */ +import { suggestView } from '../views.js'; import { defineTool, ToolError } from '../catalog.js'; import { AREA_SCHEMA, areaRadiusKm, resolveArea } from '../area.js'; import { toBase64 } from '../results.js'; @@ -105,6 +106,7 @@ export const getRecentImagery = defineTool({ return { summary: `Most recent image of ${area.label}: ${sensor}, ${candidate.day}, ${quality}${reason === 'overview' ? '' : `, ${cloud}`}.`, data: { + view: suggestView(services, { area, layers: ['recent-imagery'] }), area: area.label, image: { product: candidate.product, diff --git a/src/tools/queries/infrastructure.js b/src/tools/queries/infrastructure.js index fd64f5fa5..8f80fa441 100644 --- a/src/tools/queries/infrastructure.js +++ b/src/tools/queries/infrastructure.js @@ -1,5 +1,6 @@ /** Bundled OpenStreetMap infrastructure: datacenters and dams. */ +import { suggestView } from '../views.js'; import { defineTool } from '../catalog.js'; import { AREA_SCHEMA, @@ -62,6 +63,7 @@ export const findInfrastructure = defineTool({ return { summary: `${countNoun(rows.length, `mapped ${noun}`)} in ${area.label}.`, data: { + view: suggestView(services, { area, layers: [layerId] }), ...capRows(rows, args.limit), attribution: ATTRIBUTION[args.kind], }, diff --git a/src/tools/queries/maritime.js b/src/tools/queries/maritime.js index 5c2107a68..045f148dc 100644 --- a/src/tools/queries/maritime.js +++ b/src/tools/queries/maritime.js @@ -1,5 +1,6 @@ /** Maritime queries over the live vessel source (src/sources/live). */ +import { suggestView } from '../views.js'; import { defineTool, ToolError } from '../catalog.js'; import { AREA_SCHEMA, @@ -104,7 +105,11 @@ export const vesselsInArea = defineTool({ summary: `${countNoun(rows.length, kind)} in ${area.label}` + (snapshot.freshness === 'stale' ? ' (data may be stale).' : '.'), - data: { ...capRows(rows, args.limit), ...snapshotInfo(snapshot) }, + data: { + view: suggestView(services, { area, layers: ['ais-live-vessels'] }), + ...capRows(rows, args.limit), + ...snapshotInfo(snapshot), + }, }; }, }); @@ -148,7 +153,19 @@ export const findVessel = defineTool({ summary: rows.length ? `Found ${countNoun(rows.length, 'vessel')} with ${key} ${args[key]}.` : `No vessel with ${key} ${args[key]} is currently reported.`, - data: { ...capRows(rows, args.limit), ...snapshotInfo(snapshot) }, + data: { + // Ships cannot be followed from a link; one match is framed closely. + view: + rows.length === 1 + ? suggestView(services, { + point: { lat: rows[0].lat, lon: rows[0].lon }, + layers: ['ais-live-vessels'], + altitudeM: 5_000, + }) + : null, + ...capRows(rows, args.limit), + ...snapshotInfo(snapshot), + }, }; }, }); @@ -177,6 +194,13 @@ export const getVesselTrack = defineTool({ ? `${countNoun(points.length, 'position')} for ${args.mmsi} from ${isoTime(points[0].observedAtMs)} to ${isoTime(points.at(-1).observedAtMs)}.` : `No recent track is available for ${args.mmsi}.`, data: { + view: kept.length + ? suggestView(services, { + point: { lat: kept.at(-1).latitude, lon: kept.at(-1).longitude }, + layers: ['ais-live-vessels'], + altitudeM: 20_000, + }) + : null, mmsi: args.mmsi, total: points.length, returned: kept.length, diff --git a/src/tools/queries/media.js b/src/tools/queries/media.js index f866c9c86..4879d57c5 100644 --- a/src/tools/queries/media.js +++ b/src/tools/queries/media.js @@ -1,5 +1,6 @@ /** Public camera and radio queries over the CCTV and radio sources. */ +import { suggestView } from '../views.js'; import { defineTool, ToolError } from '../catalog.js'; import { AREA_SCHEMA, @@ -106,6 +107,7 @@ export const findCctvCameras = defineTool({ .join(', ')}).` : '.'), data: { + view: suggestView(services, { area, layers: ['cctv'] }), ...capRows(rows, args.limit), complete: trimmed.length === 0, catalog_trimmed: trimmed, @@ -150,6 +152,11 @@ export const getCctvSnapshot = defineTool({ return { summary: `Current view from ${name}${camera.city ? ` in ${camera.city}` : ''}${camera.credit ? `, courtesy of ${camera.credit}` : ''}.`, data: { + view: suggestView(services, { + point: { lat: camera.lat, lon: camera.lon }, + altitudeM: 1_500, + layers: ['cctv'], + }), id: camera.id, name, city: text(camera.city), @@ -247,6 +254,7 @@ export const findRadioStations = defineTool({ return { summary: `${countNoun(rows.length, 'radio station')}${what}${where} (${notes.join('; ')}).`, data: { + view: area ? suggestView(services, { area, layers: ['radio'] }) : null, ...capRows(rows, args.limit), directory_size: directory.stations.length, stale: directory.stale === true, diff --git a/src/tools/queries/mobility.js b/src/tools/queries/mobility.js index 2b5c735e2..743fa22a2 100644 --- a/src/tools/queries/mobility.js +++ b/src/tools/queries/mobility.js @@ -1,5 +1,6 @@ /** Bike-share and public transit queries. */ +import { suggestView } from '../views.js'; import { parseStationInformation, parseStationStatus, @@ -134,6 +135,7 @@ export const getBikeShare = defineTool({ `${countNoun(bikes, 'bike')} available` + (notes.length ? ` (${notes.join('; ')}).` : '.'), data: { + view: suggestView(services, { area, layers: ['bikeshare'] }), ...capRows(rows, args.limit), systems: systems.map((system) => system.city), systems_not_searched: skipped.map((system) => system.city), @@ -275,6 +277,7 @@ export const getTransitVehicles = defineTool({ `${countNoun(rows.length, 'transit vehicle')}${what} in ${area.label}` + (notes.length ? ` (${notes.join('; ')}).` : '.'), data: { + view: suggestView(services, { area, layers: ['transit'] }), ...capRows(rows, args.limit), stale: staleFeeds > 0, expired_positions_dropped: expired, @@ -472,6 +475,7 @@ export const getTrafficFlow = defineTool({ ? ' Some map tiles did not load, so these figures are partial.' : ''), data: { + view: suggestView(services, { area, layers: ['traffic'] }), area: area.label, partial: flow.partial, speed_pct_of_free_flow: speedPct, diff --git a/src/tools/queries/share.js b/src/tools/queries/share.js index f15ce34d2..6820aefbd 100644 --- a/src/tools/queries/share.js +++ b/src/tools/queries/share.js @@ -2,12 +2,8 @@ import { VIEW_PROPERTIES, createView, viewUrl } from '../../view/index.js'; import { defineTool, ToolError } from '../catalog.js'; -import { AREA_SCHEMA, areaCenter, areaRadiusKm, resolveArea } from '../area.js'; - -const MIN_ALTITUDE_M = 500; -const MAX_ALTITUDE_M = 15_000_000; -// A top-down view sees roughly this many meters of ground per meter of altitude. -const GROUND_PER_ALTITUDE = 0.55; +import { AREA_SCHEMA, resolveArea } from '../area.js'; +import { cameraForArea } from '../views.js'; /** Tool arguments that describe a view: an area to frame, or a camera. */ export const VIEW_ARGUMENTS = Object.freeze({ @@ -24,20 +20,8 @@ export async function resolveViewArguments(args, { services, signal }) { let label = null; if (args.area) { const area = await resolveArea(args.area, { services, signal }); - const center = areaCenter(area); label = area.label; - camera = { - lat: center.lat, - lon: center.lon, - altitude_m: Math.min( - MAX_ALTITUDE_M, - Math.max( - MIN_ALTITUDE_M, - (areaRadiusKm(area) * 1000) / GROUND_PER_ALTITUDE, - ), - ), - ...camera, - }; + camera = { ...cameraForArea(area), ...camera }; } if (!Number.isFinite(camera.lat) || !Number.isFinite(camera.lon)) throw new ToolError( diff --git a/src/tools/queries/space.js b/src/tools/queries/space.js index ad65f80e8..ef59c0be5 100644 --- a/src/tools/queries/space.js +++ b/src/tools/queries/space.js @@ -1,5 +1,6 @@ /** Space queries: launches, satellite passes and satellites overhead. */ +import { suggestView } from '../views.js'; import { twoline2satrec } from 'satellite.js'; import { findNextSatellitePass, @@ -57,7 +58,15 @@ export const getRecentLaunches = defineTool({ summary: `${countNoun(rows.length, 'launch', 'launches')} in the last 30 days${rows[0] ? `; latest ${rows[0].name}` : ''}` + (stale ? ' (data may be stale).' : '.'), - data: { ...result, stale }, + data: { + view: suggestView(services, { + point: { lat: 20, lon: 0 }, + altitudeM: 15_000_000, + layers: ['rocket-launches'], + }), + ...result, + stale, + }, }; }, }); @@ -103,6 +112,17 @@ const round = (value, digits) => Number.isFinite(value) ? Number(value.toFixed(digits)) : null; const nowMs = (services) => services.clock?.now() ?? Date.now(); +/** A view following a satellite from above an observer. */ +function followSatellite(services, point, satellite) { + return satellite.norad + ? suggestView(services, { + point, + altitudeM: 3_000_000, + follow: { kind: 'satellite', id: String(satellite.norad) }, + }) + : null; +} + async function readCatalog(services, group, signal) { const result = await services.satellites.readGroup(group, { signal }); if (!result.ok) @@ -197,6 +217,7 @@ export const nextSatellitePass = defineTool({ return { summary: `${name} has no ${args.visible_only ? 'visible ' : ''}pass over ${point.label} in the next ${hours} hours${staleNote}.`, data: { + view: followSatellite(services, point, satellite), stale, location: point, satellite: name, @@ -210,6 +231,7 @@ export const nextSatellitePass = defineTool({ `peaking at ${Math.round(pass.maxElevDeg)}°` + `${pass.visible ? '; visible to the naked eye' : ''}${staleNote}.`, data: { + view: followSatellite(services, point, satellite), stale, location: point, satellite: name, @@ -273,6 +295,11 @@ export const satellitesOverhead = defineTool({ `${countNoun(rows.length, 'satellite')} from the ${group} group ${rows.length === 1 ? 'is' : 'are'} at least ${minimum}° above ${point.label} at ${isoTime(at)}` + (stale ? ' (orbit data may be stale).' : '.'), data: { + view: suggestView(services, { + point, + altitudeM: 3_000_000, + layers: ['satellites'], + }), ...capRows(rows, args.limit), stale, location: point, diff --git a/src/tools/queries/surveillance.js b/src/tools/queries/surveillance.js index 6571036d5..06723007a 100644 --- a/src/tools/queries/surveillance.js +++ b/src/tools/queries/surveillance.js @@ -1,5 +1,6 @@ /** Automated license plate reader cameras mapped in OpenStreetMap. */ +import { suggestView } from '../views.js'; import { defineTool, ToolError } from '../catalog.js'; import { AREA_SCHEMA, @@ -88,6 +89,7 @@ export const findAlprCameras = defineTool({ `${countNoun(rows.length, 'license plate reader camera')} mapped in ${area.label}` + (notes.length ? ` (${notes.join('; ')}).` : '.'), data: { + view: suggestView(services, { area, layers: ['alpr-cameras'] }), ...capRows(rows, args.limit), // Some tiles failed, or a dense area was trimmed to the source limit. complete: !result.saturated, diff --git a/src/tools/views.js b/src/tools/views.js new file mode 100644 index 000000000..9ffd6d588 --- /dev/null +++ b/src/tools/views.js @@ -0,0 +1,55 @@ +/** + * Suggested views: the God's Eye View view that shows a tool's answer, and + * the link that opens it when the app's address is known. + */ + +import { createView, viewUrl } from '../view/index.js'; +import { areaCenter, areaRadiusKm } from './area.js'; + +const MIN_ALTITUDE_M = 500; +const MAX_ALTITUDE_M = 15_000_000; +// A top-down view sees roughly this many meters of ground per meter of altitude. +const GROUND_PER_ALTITUDE = 0.55; +const POINT_ALTITUDE_M = 50_000; + +/** A camera looking straight down that frames a resolved area. */ +export function cameraForArea(area) { + const center = areaCenter(area); + return { + lat: center.lat, + lon: center.lon, + altitude_m: Math.min( + MAX_ALTITUDE_M, + Math.max( + MIN_ALTITUDE_M, + (areaRadiusKm(area) * 1000) / GROUND_PER_ALTITUDE, + ), + ), + }; +} + +/** The app's address, or null when it is not configured. */ +function appUrl(services) { + try { + return services.app?.baseUrl ? new URL(services.app.baseUrl).href : null; + } catch { + return null; + } +} + +/** + * A view of an area or point with layers on and optionally something + * followed, plus `url` (null without the app's address). A point is framed + * from `altitudeM` above it. + */ +export function suggestView( + services, + { area, point, layers = [], follow = null, altitudeM = POINT_ALTITUDE_M }, +) { + const camera = area + ? cameraForArea(area) + : { lat: point.lat, lon: point.lon, altitude_m: altitudeM }; + const view = createView({ camera, layers, follow }); + const base = appUrl(services); + return { ...view, url: base ? viewUrl(base, view) : null }; +} diff --git a/src/tools/views.test.mjs b/src/tools/views.test.mjs new file mode 100644 index 000000000..6e8d9f321 --- /dev/null +++ b/src/tools/views.test.mjs @@ -0,0 +1,102 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { composeCatalog, coreTools } from './index.js'; +import { viewFromParams } from '../view/index.js'; +import { cameraForArea, suggestView } from './views.js'; +import { resolveArea } from './area.js'; + +const app = { baseUrl: 'https://maps.example/' }; +const snapshot = (records, extra = {}) => ({ + records, + complete: true, + source: 'Test feed', + observedAtMs: Date.UTC(2026, 0, 1), + freshness: 'current', + ...extra, +}); +const aircraft = (id, latitude, longitude, extra = {}) => ({ + id, + latitude, + longitude, + callsign: 'UAL1', + baroAltitudeM: 10000, + onGround: false, + positionTimeMs: Date.UTC(2026, 0, 1), + ...extra, +}); +const feed = (records) => ({ getSnapshot: async () => snapshot(records) }); + +test('suggested views frame an area or point and link to the app', async () => { + const area = await resolveArea({ lat: 30, lon: -97, radius_km: 10 }); + const view = suggestView({ app }, { area, layers: ['flights'] }); + assert.deepEqual(view.camera, { + ...cameraForArea(area), + altitude_m: Math.round(cameraForArea(area).altitude_m), + heading_deg: 0, + pitch_deg: -90, + }); + assert.deepEqual(view.layers, ['flights']); + const params = new URLSearchParams(new URL(view.url).hash.slice(1)); + const { url, ...rest } = view; + assert.deepEqual(viewFromParams(params), rest); + assert.equal(suggestView({}, { area, layers: [] }).url, null); + assert.equal( + suggestView({ app: { baseUrl: 'not a url' } }, { area }).url, + null, + ); + const point = suggestView({ app }, { point: { lat: 1, lon: 2 } }); + assert.equal(point.camera.altitude_m, 50000); +}); + +test('answers carry the view that shows them', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { + app, + aircraft: feed([aircraft('abc123', 37.62, -122.38)]), + military: feed([]), + }, + }); + const area = await catalog.call('aircraft_in_area', { + area: { lat: 37.62, lon: -122.38, radius_km: 20 }, + }); + assert.deepEqual(area.data.view.layers, ['flights']); + assert.match(area.data.view.url, /^https:\/\/maps\.example\/#v=2&/); + const found = await catalog.call('find_aircraft', { callsign: 'UAL1' }); + assert.deepEqual(found.data.view.follow, { kind: 'aircraft', id: 'abc123' }); + assert.deepEqual(found.data.view.layers, ['flights']); +}); + +test('the flood and the brief frame what they describe', async () => { + const { readFile } = await import('node:fs/promises'); + const bundled = JSON.parse( + await readFile( + new URL( + '../../public/events/bhote-koshi-2026/event.json', + import.meta.url, + ), + 'utf8', + ), + ); + const flood = await composeCatalog({ + tools: coreTools, + services: { app, events: { getEvent: async () => bundled } }, + }).call('get_bhote_koshi_flood', {}); + assert.deepEqual(flood.data.view.layers, ['bhote-koshi-2026']); + assert.ok(Math.abs(flood.data.view.camera.lat - 28.13) < 0.2); + assert.ok(Math.abs(flood.data.view.camera.lon - 85.32) < 0.2); + const brief = await composeCatalog({ + tools: coreTools, + services: { + app, + weather: { + getConditions: async () => ({ status: 'ready', weather: {} }), + }, + aircraft: feed([]), + earthquakes: { getSnapshot: async () => [] }, + }, + }).call('situation_brief', { + area: { lat: 30, lon: -97, radius_km: 50 }, + }); + assert.deepEqual(brief.data.view.layers, ['earthquakes', 'flights']); +});