mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
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:
1 parent
96a8a9addb
commit
d6473b6049
20 files changed
+393
-34
No files matched your search
+10
-1
@@ -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`
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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"]
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
Reference in new issue
Block a user