mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
feat: add a military awareness tool
This commit is contained in:
1 parent
28cfd39d1a
commit
a747b8a4e0
5 files changed
+197
-19
No files matched your search
@@ -17,7 +17,7 @@ stations, transit vehicles, road traffic flow, weather, weather map images
|
|||||||
(radar, satellite, lightning), wind, the most recent satellite image of an
|
(radar, satellite, lightning), wind, the most recent satellite image of an
|
||||||
area, submarine cables, datacenters and dams, regional briefs, tropical cyclones, fire
|
area, submarine cables, datacenters and dams, regional briefs, tropical cyclones, fire
|
||||||
perimeters, terrain height, military installations and map features, plus a
|
perimeters, terrain height, military installations and map features, plus a
|
||||||
combined situation brief, the app's heads-up display caption, and a link that
|
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
|
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
|
shared `area` argument (place name, bounding box, or point and radius) and cap
|
||||||
lists at 25 rows by default. The app does not import the tools, so its runtime
|
lists at 25 rows by default. The app does not import the tools, so its runtime
|
||||||
|
|||||||
+2
-1
@@ -53,7 +53,7 @@ relative `/api/...` paths through an injected `fetchImpl`, so the same tool
|
|||||||
code runs wherever an application routes those paths.
|
code runs wherever an application routes those paths.
|
||||||
The `weather`, `regional`, `terrain`, `summary` and `features` services are the
|
The `weather`, `regional`, `terrain`, `summary` and `features` services are the
|
||||||
application request services from `gods-eye-view/application/requests`.
|
application request services from `gods-eye-view/application/requests`.
|
||||||
`situation_brief` runs each section whose services are supplied and marks the
|
`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
|
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`;
|
the GBFS source. `createGeocodePlaceService` resolves place names through `/api/geocode`;
|
||||||
`createPlaceSearchService` searches `/api/google/*` and reports when no search key
|
`createPlaceSearchService` searches `/api/google/*` and reports when no search key
|
||||||
@@ -140,5 +140,6 @@ public feeds the sources already use.
|
|||||||
| `find_military_installations` | `installations` | OpenStreetMap military sites in an area of at most 10° per side |
|
| `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) |
|
| `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 |
|
| `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 |
|
| `get_hud_caption` | `weather`, `summary` | The app's heads-up display caption for an area |
|
||||||
| `open_in_gods_eye_view` | `app` | A share link looking straight down on an area, with chosen layers on |
|
| `open_in_gods_eye_view` | `app` | A share link looking straight down on an area, with chosen layers on |
|
||||||
+6
-1
@@ -11,7 +11,11 @@ import {
|
|||||||
} from './queries/aviation.js';
|
} from './queries/aviation.js';
|
||||||
import { getWeatherMap, getWind } from './queries/atmosphere.js';
|
import { getWeatherMap, getWind } from './queries/atmosphere.js';
|
||||||
import { findSubmarineCables } from './queries/cables.js';
|
import { findSubmarineCables } from './queries/cables.js';
|
||||||
import { getHudCaption, situationBrief } from './queries/brief.js';
|
import {
|
||||||
|
getHudCaption,
|
||||||
|
militaryAwareness,
|
||||||
|
situationBrief,
|
||||||
|
} from './queries/brief.js';
|
||||||
import {
|
import {
|
||||||
findMilitaryInstallations,
|
findMilitaryInstallations,
|
||||||
getCyclones,
|
getCyclones,
|
||||||
@@ -106,6 +110,7 @@ export const coreTools = Object.freeze([
|
|||||||
findMilitaryInstallations,
|
findMilitaryInstallations,
|
||||||
getMapFeatures,
|
getMapFeatures,
|
||||||
situationBrief,
|
situationBrief,
|
||||||
|
militaryAwareness,
|
||||||
getHudCaption,
|
getHudCaption,
|
||||||
openInGodsEyeView,
|
openInGodsEyeView,
|
||||||
]);
|
]);
|
||||||
+110
-16
@@ -1,13 +1,25 @@
|
|||||||
/** Composite queries that combine other tools' answers for one area. */
|
/** Composite queries that combine other tools' answers for one area. */
|
||||||
|
|
||||||
import { defineTool, ToolError } from '../catalog.js';
|
import { defineTool, ToolError } from '../catalog.js';
|
||||||
import { AREA_SCHEMA, areaCenter, resolveArea } from '../area.js';
|
import {
|
||||||
|
AREA_SCHEMA,
|
||||||
|
POINT_SCHEMA,
|
||||||
|
areaCenter,
|
||||||
|
resolveArea,
|
||||||
|
resolvePoint,
|
||||||
|
} from '../area.js';
|
||||||
import { aircraftInArea } from './aviation.js';
|
import { aircraftInArea } from './aviation.js';
|
||||||
import { getCyclones, getWeather } from './environment.js';
|
import {
|
||||||
|
findMilitaryInstallations,
|
||||||
|
getCyclones,
|
||||||
|
getWeather,
|
||||||
|
} from './environment.js';
|
||||||
import { getActiveFires, getEarthquakes } from './hazards.js';
|
import { getActiveFires, getEarthquakes } from './hazards.js';
|
||||||
import { vesselsInArea } from './maritime.js';
|
import { vesselsInArea } from './maritime.js';
|
||||||
|
|
||||||
const SECTION_LIMIT = 5;
|
const SECTION_LIMIT = 5;
|
||||||
|
const AWARENESS_LIMIT = 10;
|
||||||
|
const AWARENESS_RADIUS_KM = 250;
|
||||||
|
|
||||||
/** Brief sections: the tool each one reuses and the services it needs. */
|
/** Brief sections: the tool each one reuses and the services it needs. */
|
||||||
const SECTIONS = [
|
const SECTIONS = [
|
||||||
@@ -45,14 +57,37 @@ const SECTIONS = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/** Military contacts around a point, as the app's military awareness panel. */
|
||||||
* Run every section whose services are supplied. A failing section is
|
const AWARENESS_SECTIONS = [
|
||||||
* reported as unavailable instead of failing the brief.
|
{
|
||||||
*/
|
key: 'military_aircraft',
|
||||||
async function buildBrief(args, { services, signal }) {
|
tool: aircraftInArea,
|
||||||
const resolved = await resolveArea(args.area, { services, signal });
|
args: (area) => ({
|
||||||
// Sections receive the resolved box so a place name is looked up once.
|
area: area.argument,
|
||||||
const area = {
|
military: true,
|
||||||
|
limit: AWARENESS_LIMIT,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'aircraft',
|
||||||
|
tool: aircraftInArea,
|
||||||
|
args: (area) => ({ area: area.argument, limit: AWARENESS_LIMIT }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'vessels',
|
||||||
|
tool: vesselsInArea,
|
||||||
|
args: (area) => ({ area: area.argument, limit: AWARENESS_LIMIT }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'installations',
|
||||||
|
tool: findMilitaryInstallations,
|
||||||
|
args: (area) => ({ area: area.argument, limit: AWARENESS_LIMIT }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** The resolved area, plus the argument sections receive in its place. */
|
||||||
|
function sectionArea(resolved) {
|
||||||
|
return {
|
||||||
...resolved,
|
...resolved,
|
||||||
argument: resolved.center
|
argument: resolved.center
|
||||||
? {
|
? {
|
||||||
@@ -64,8 +99,15 @@ async function buildBrief(args, { services, signal }) {
|
|||||||
bbox: [resolved.west, resolved.south, resolved.east, resolved.north],
|
bbox: [resolved.west, resolved.south, resolved.east, resolved.north],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const center = areaCenter(resolved);
|
}
|
||||||
const sections = SECTIONS.filter(({ tool }) =>
|
|
||||||
|
/**
|
||||||
|
* Run every section whose services are supplied. A failing section is
|
||||||
|
* reported as unavailable instead of failing the whole answer.
|
||||||
|
*/
|
||||||
|
async function runSections(all, area, { services, signal }) {
|
||||||
|
const center = areaCenter(area);
|
||||||
|
const sections = all.filter(({ tool }) =>
|
||||||
tool.requires.every((key) => services[key] != null),
|
tool.requires.every((key) => services[key] != null),
|
||||||
);
|
);
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
@@ -74,22 +116,32 @@ async function buildBrief(args, { services, signal }) {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
const brief = {};
|
const answers = {};
|
||||||
const lines = [];
|
const lines = [];
|
||||||
sections.forEach(({ key }, index) => {
|
sections.forEach(({ key }, index) => {
|
||||||
const result = results[index];
|
const result = results[index];
|
||||||
if (result.status === 'fulfilled') {
|
if (result.status === 'fulfilled') {
|
||||||
brief[key] = { summary: result.value.summary, data: result.value.data };
|
answers[key] = { summary: result.value.summary, data: result.value.data };
|
||||||
lines.push(result.value.summary);
|
lines.push(result.value.summary);
|
||||||
} else {
|
} else {
|
||||||
const known = result.reason instanceof ToolError;
|
const known = result.reason instanceof ToolError;
|
||||||
brief[key] = {
|
answers[key] = {
|
||||||
unavailable: true,
|
unavailable: true,
|
||||||
reason: known ? result.reason.message : 'unavailable right now',
|
reason: known ? result.reason.message : 'unavailable right now',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return { area: resolved, center, brief, lines };
|
return { center, answers, lines };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildBrief(args, { services, signal }) {
|
||||||
|
// Sections receive the resolved box so a place name is looked up once.
|
||||||
|
const area = sectionArea(await resolveArea(args.area, { services, signal }));
|
||||||
|
const { center, answers, lines } = await runSections(SECTIONS, area, {
|
||||||
|
services,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
return { area, center, brief: answers, lines };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const situationBrief = defineTool({
|
export const situationBrief = defineTool({
|
||||||
@@ -143,3 +195,45 @@ export const getHudCaption = defineTool({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 }) {
|
||||||
|
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, {
|
||||||
|
services,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
summary: `Military awareness within ${radiusKm} km of ${point.label}: ${lines.join(' ')}`,
|
||||||
|
data: {
|
||||||
|
location: point,
|
||||||
|
radius_km: radiusKm,
|
||||||
|
sections: answers,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -453,3 +453,81 @@ test('the HUD caption summarizes the brief through the summary service', async (
|
|||||||
(error) => error instanceof ToolError && error.code === 'unavailable',
|
(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);
|
||||||
|
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/,
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user