diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index a7a0cb788..29d956cfb 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -15,7 +15,7 @@ pass over a point, and those overhead now), public cameras (including a camera's current image), radio stations, place search, routing, bike-share stations, transit vehicles, road traffic flow, weather, weather map images (radar, satellite, lightning), wind, the most recent satellite image of an -area, regional briefs, tropical cyclones, fire +area, submarine cables, regional briefs, tropical cyclones, fire perimeters, terrain height, military installations and map features, plus a combined situation brief, 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 diff --git a/docs/TOOLS.md b/docs/TOOLS.md index e29195ca2..383f6417c 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -130,6 +130,7 @@ public feeds the sources already use. | `get_weather_map` | `weatherMaps` | The latest NOAA radar, satellite or lightning map image over an area | | `get_wind` | `wind` | GFS or IFS model wind 10 m above ground at a location | | `get_recent_imagery` | `imagery` | The most recent clear Landsat/Sentinel-2 image of an area (VIIRS fallback) | +| `find_submarine_cables` | `cables` | TeleGeography cables and landing points by area or name (CC BY-NC-SA 3.0) | | `get_regional_brief` | `regional` | What and where a location is, its weather and recent headlines | | `get_cyclones` | `cyclones` | Active NHC/CPHC tropical cyclones, optionally in an area | | `get_fire_perimeters` | `perimeters` | Mapped WFIGS wildfire perimeters in an area, largest first | diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 91e86a958..206e5f739 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -881,5 +881,6 @@ "src/tools/queries/share.test.mjs", "src/tools/localRoute.test.mjs", "src/tools/queries/atmosphere.test.mjs", - "src/tools/queries/imagery.test.mjs" + "src/tools/queries/imagery.test.mjs", + "src/tools/queries/cables.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index f37849011..f97195d31 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3111,7 +3111,8 @@ "src/layers/wind/inspection.js", "src/layers/wind/model.js", "src/layers/wind/fields.js", - "src/tools/queries/imagery.js" + "src/tools/queries/imagery.js", + "src/tools/queries/cables.js" ], "external": ["satellite.js"] }, @@ -3181,7 +3182,8 @@ "src/layers/wind/source.js", "src/sources/windBody.js", "src/layers/recentImagery/catalog.js", - "src/layers/recentImagery/model.js" + "src/layers/recentImagery/model.js", + "src/layers/submarineCables/bundledSource.js" ], "external": ["@mapbox/vector-tile", "pbf"] } diff --git a/server/mcp/services.js b/server/mcp/services.js index a4ac48965..513bc146f 100644 --- a/server/mcp/services.js +++ b/server/mcp/services.js @@ -3,11 +3,20 @@ * running God's Eye View server's `/api` routes. */ +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; import { createToolServices } from '../../src/tools/services.js'; +// Bundled data some sources read by module-relative URL. +const BUNDLED_DATA = new URL('../../src/data/local_data/', import.meta.url); + export const DEFAULT_API_BASE = 'http://localhost:4173'; -/** Resolve the sources' relative `/api/...` requests against `apiBase`. */ +/** + * Resolve the sources' relative `/api/...` requests against `apiBase`, and + * serve `file:` URLs inside the bundled data directory from disk, as a + * browser would load those module-relative assets. + */ export function createApiFetch({ apiBase = DEFAULT_API_BASE, fetchImpl = (...args) => globalThis.fetch(...args), @@ -15,13 +24,34 @@ export function createApiFetch({ const base = new URL(apiBase); if (!['http:', 'https:'].includes(base.protocol)) throw new TypeError(`apiBase must be an http(s) URL: ${apiBase}`); - return (input, init) => - fetchImpl( + return (input, init) => { + const url = String(input instanceof Request ? input.url : input); + if (url.startsWith('file:')) return readBundled(url); + return fetchImpl( typeof input === 'string' && input.startsWith('/') ? new URL(input, base) : input, init, ); + }; +} + +async function readBundled(url) { + const href = new URL(url).href; + if (!href.startsWith(BUNDLED_DATA.href) || href.includes('/../')) + return new Response(null, { status: 404 }); + try { + const body = await readFile(fileURLToPath(href)); + return new Response(body, { + headers: { + 'Content-Type': href.endsWith('.json') + ? 'application/json' + : 'application/octet-stream', + }, + }); + } catch { + return new Response(null, { status: 404 }); + } } /** Construct every service Core's tools read, backed by the local server. */ diff --git a/src/tools/index.js b/src/tools/index.js index cf4e5d2a2..d7ca50b4e 100644 --- a/src/tools/index.js +++ b/src/tools/index.js @@ -10,6 +10,7 @@ import { getAircraftTrack, } from './queries/aviation.js'; import { getWeatherMap, getWind } from './queries/atmosphere.js'; +import { findSubmarineCables } from './queries/cables.js'; import { getHudCaption, situationBrief } from './queries/brief.js'; import { findMilitaryInstallations, @@ -93,6 +94,7 @@ export const coreTools = Object.freeze([ getWeatherMap, getWind, getRecentImagery, + findSubmarineCables, getRegionalBrief, getCyclones, getFirePerimeters, diff --git a/src/tools/queries/cables.js b/src/tools/queries/cables.js new file mode 100644 index 000000000..e7f5ec6f7 --- /dev/null +++ b/src/tools/queries/cables.js @@ -0,0 +1,86 @@ +/** Submarine cables and their landing points. */ + +import { defineTool, ToolError } from '../catalog.js'; +import { AREA_SCHEMA, areaContains, resolveArea } from '../area.js'; +import { LIMIT_SCHEMA, capRows, countNoun } from '../results.js'; + +const ATTRIBUTION = '© TeleGeography — submarinecablemap.com (CC BY-NC-SA 3.0)'; + +function cableLines(feature) { + const geometry = feature?.geometry; + if (geometry?.type === 'LineString') return [geometry.coordinates]; + if (geometry?.type === 'MultiLineString') return geometry.coordinates; + return []; +} + +export const findSubmarineCables = defineTool({ + name: 'find_submarine_cables', + title: 'Submarine cables', + description: + 'Undersea telecommunication cables and landing points from the ' + + 'TeleGeography map: those passing through or landing in an area, ' + + 'and/or those whose name matches.', + inputSchema: { + type: 'object', + properties: { + area: AREA_SCHEMA, + name: { type: 'string', minLength: 2, maxLength: 80 }, + limit: LIMIT_SCHEMA, + }, + additionalProperties: false, + }, + requires: ['cables'], + async run(args, { services, signal }) { + if (!args.area && !args.name) + throw new ToolError('invalid_arguments', 'Give an area, a name or both'); + const area = args.area + ? await resolveArea(args.area, { services, signal }) + : null; + const wanted = args.name?.toLowerCase(); + const { cables, landingPoints } = await services.cables.fetch(signal); + const landingRows = (landingPoints?.features || []) + .map((feature) => ({ + id: feature.properties?.id ?? null, + name: feature.properties?.name ?? null, + lon: feature.geometry?.coordinates?.[0], + lat: feature.geometry?.coordinates?.[1], + })) + .filter( + (point) => + point.name && + Number.isFinite(point.lat) && + (!area || areaContains(area, point)), + ); + const rows = (cables?.features || []) + .filter((feature) => { + const name = String(feature.properties?.name || ''); + if (!name || (wanted && !name.toLowerCase().includes(wanted))) + return false; + return ( + !area || + cableLines(feature).some((line) => + line.some(([lon, lat]) => areaContains(area, { lat, lon })), + ) + ); + }) + .map((feature) => ({ + id: feature.properties.id ?? null, + name: feature.properties.name, + })) + .sort((a, b) => a.name.localeCompare(b.name)); + const where = area ? ` in ${area.label}` : ''; + const what = wanted ? ` matching "${args.name}"` : ''; + return { + summary: + `${countNoun(rows.length, 'submarine cable')}${what}${where}` + + (area + ? `, with ${countNoun(landingRows.length, 'landing point')}.` + : '.'), + data: { + ...capRows(rows, args.limit), + landing_points: area ? capRows(landingRows, args.limit) : null, + attribution: ATTRIBUTION, + }, + }; + }, +}); diff --git a/src/tools/queries/cables.test.mjs b/src/tools/queries/cables.test.mjs new file mode 100644 index 000000000..754032b6e --- /dev/null +++ b/src/tools/queries/cables.test.mjs @@ -0,0 +1,95 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { composeCatalog, coreTools } from '../index.js'; +import { createApiFetch } from '../../../server/mcp/services.js'; +import { createBundledCableSource } from '../../layers/submarineCables/bundledSource.js'; + +const cables = { + async fetch() { + return { + cables: { + features: [ + { + properties: { id: 'marea', name: 'MAREA' }, + geometry: { + type: 'MultiLineString', + coordinates: [ + [ + [-76, 36.8], + [-40, 40], + [-2.9, 43.4], + ], + ], + }, + }, + { + properties: { id: 'unity', name: 'Unity' }, + geometry: { + type: 'LineString', + coordinates: [ + [-118, 34], + [139, 35], + ], + }, + }, + ], + }, + landingPoints: { + features: [ + { + properties: { id: 'vb', name: 'Virginia Beach, VA, United States' }, + geometry: { type: 'Point', coordinates: [-75.98, 36.85] }, + }, + { + properties: { id: 'bilbao', name: 'Bilbao, Spain' }, + geometry: { type: 'Point', coordinates: [-2.93, 43.26] }, + }, + ], + }, + }; + }, +}; + +test('cables are found by area and name, with landing points and attribution', async () => { + const catalog = composeCatalog({ tools: coreTools, services: { cables } }); + const virginia = await catalog.call('find_submarine_cables', { + area: { lat: 36.85, lon: -75.98, radius_km: 50 }, + }); + assert.equal( + virginia.summary, + '1 submarine cable in 50 km around 36.850, -75.980, with 1 landing point.', + ); + assert.deepEqual(virginia.data.rows, [{ id: 'marea', name: 'MAREA' }]); + assert.equal( + virginia.data.landing_points.rows[0].name, + 'Virginia Beach, VA, United States', + ); + assert.match(virginia.data.attribution, /TeleGeography.*CC BY-NC-SA 3\.0/); + const named = await catalog.call('find_submarine_cables', { name: 'uni' }); + assert.equal(named.summary, '1 submarine cable matching "uni".'); + assert.equal(named.data.landing_points, null); + await assert.rejects( + catalog.call('find_submarine_cables', {}), + /area, a name or both/, + ); +}); + +test('the local fetch serves bundled data files and nothing outside them', async () => { + const fetchImpl = createApiFetch({ + apiBase: 'http://127.0.0.1:1', + fetchImpl: async () => assert.fail('no network'), + }); + const { cables: collection, landingPoints } = await createBundledCableSource({ + fetchImpl, + }).fetch(); + assert.ok(collection.features.length > 500); + assert.ok(landingPoints.features.length > 1000); + const outside = await fetchImpl( + new URL('../../../package.json', import.meta.url).href, + ); + assert.equal(outside.status, 404); + const missing = await fetchImpl( + new URL('../../data/local_data/missing.json', import.meta.url).href, + ); + assert.equal(missing.status, 404); +}); diff --git a/src/tools/services.js b/src/tools/services.js index 18f90c4b3..a09e84bb1 100644 --- a/src/tools/services.js +++ b/src/tools/services.js @@ -16,6 +16,7 @@ import { createInstallationSource } from '../layers/installations/source.js'; import { createLaunchSource } from '../layers/launches/source.js'; import { createWfigsPerimeterSource } from '../layers/perimeters/source.js'; import { createRadioSource } from '../layers/radio/source.js'; +import { createBundledCableSource } from '../layers/submarineCables/bundledSource.js'; import { searchHls } from '../layers/recentImagery/catalog.js'; import { rankLatest, wvsSnapshotUrl } from '../layers/recentImagery/model.js'; import { createSatelliteSource } from '../layers/satellites/source.js'; @@ -66,6 +67,7 @@ export function createToolServices({ fetchImpl, appUrl }) { weatherMaps: createWeatherSource({ fetchImpl }), wind: createWindSource({ fetchImpl }), imagery: createImageryService({ fetchImpl }), + cables: createBundledCableSource({ fetchImpl }), regional: requests.regional, terrain: requests.terrain, summary: requests.summary,