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.
This commit is contained in:
Sameh Khamis committed 2026-10-02 15:21:36 -07:00
1 parent 96a8a9addb
commit d6473b6049
20 files changed
+393 -34

No files matched your search

+10 -1
View File
@@ -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`
+2 -1
View File
@@ -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"
]
+2 -1
View File
@@ -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"]
},
+26
View File
@@ -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),
+42 -7
View File
@@ -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,
+20 -1
View File
@@ -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,
+11
View File
@@ -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,
+19 -1
View File
@@ -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,
+17
View File
@@ -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,
+12
View File
@@ -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),
+2
View File
@@ -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,
+2
View File
@@ -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],
},
+26 -2
View File
@@ -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,
+8
View File
@@ -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,
+4
View File
@@ -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,
+3 -19
View File
@@ -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(
+28 -1
View File
@@ -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,
+2
View File
@@ -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,
+55
View File
@@ -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 };
}
+102
View File
@@ -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']);
});