diff --git a/docs/TOOLS.md b/docs/TOOLS.md index eec6adba9..2b38d9cf1 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -79,6 +79,22 @@ the GBFS source. `createGeocodePlaceService` resolves place names through `/api/ `createPlaceSearchService` searches `/api/google/*` and reports when no search key is configured; `createRouteService` plans routes through `/api/route`. +## Views + +`gods-eye-view/view` (`src/view/index.js`) describes what the app shows, +independent of how it is shown: a camera (lat, lon, altitude, heading, +pitch), data layers, visual style, map imagery, and an aircraft, military +aircraft or satellite to follow. `createView` builds and bounds one, +`viewToParams` and `viewFromParams` write and read it in the share-link +format the app restores, and `viewUrl` gives the address that opens it. The +style names are the ones share links use. Ships cannot be followed from a +link yet. + +Tools take a view as `VIEW_ARGUMENTS`: an `area` to frame from above, or a +`camera`, plus `layers`, `style`, `map` and `follow`; camera fields given +with an area override its framing. `resolveViewArguments` turns them into a +view. + ## The `area` argument Location-scoped tools take `area` as exactly one of a `place` name, a `bbox` @@ -187,4 +203,4 @@ public feeds the sources already use. | `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 | -| `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 to a view: an area or camera, layers, style, map, and an aircraft or satellite to follow | diff --git a/package.json b/package.json index 7169a4c24..3b5e18094 100644 --- a/package.json +++ b/package.json @@ -254,6 +254,7 @@ "./sources/map-features": "./src/sources/overpassFeatures.js", "./tools": "./src/tools/index.js", "./tools/mcp": "./src/tools/mcp/index.js", - "./tools/services": "./src/tools/services.js" + "./tools/services": "./src/tools/services.js", + "./view": "./src/view/index.js" } } diff --git a/scripts/check-import-directions.mjs b/scripts/check-import-directions.mjs index 5eeca6ae2..ac1a3f732 100644 --- a/scripts/check-import-directions.mjs +++ b/scripts/check-import-directions.mjs @@ -40,6 +40,7 @@ const portableExport = (key) => './tools', './tools/mcp', './tools/services', + './view', './voice/action-schemas', './voice/session', './data/lifecycle', diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 580988971..1a98d255b 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -888,5 +888,6 @@ "src/tools/queries/events.test.mjs", "src/tools/functions.test.mjs", "src/tools/surfaces.test.mjs", - "src/tools/places.test.mjs" + "src/tools/places.test.mjs", + "src/view/index.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 7da7e6c08..c7c3fb2f3 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2038,7 +2038,8 @@ "src/data/infrastructureOverlayEntry.js", "src/sources/infrastructureData.js", "src/tools/functions.js", - "src/voice/actionSchemas.js" + "src/voice/actionSchemas.js", + "src/view/index.js" ], "external": [ "hls.js", @@ -3131,7 +3132,8 @@ "src/data/layerSnapshot.js", "src/data/feedState.js", "src/loadingFeedback.js", - "src/data/installationFeedback.js" + "src/data/installationFeedback.js", + "src/view/index.js" ], "external": ["satellite.js"] }, @@ -3211,5 +3213,14 @@ "src/sources/eventPacks.js" ], "external": ["@mapbox/vector-tile", "pbf"] + }, + "view": { + "exports": ["./view"], + "modules": [ + "src/view/index.js", + "src/data/layerState.js", + "src/data/layerStateTokenReservations.json" + ], + "external": [] } } diff --git a/src/sharelink.js b/src/sharelink.js index 08360932b..9c7c3728e 100644 --- a/src/sharelink.js +++ b/src/sharelink.js @@ -9,6 +9,7 @@ import { decodeLayerStateParams, encodeLayerStateParams, } from './data/layerState.js'; +import { STYLE_URL_NAMES } from './view/index.js'; /** * Share Links — URL Hash State Management @@ -20,16 +21,8 @@ import { const DEBOUNCE_MS = 500; const LEGACY_BLOOM_FALLBACK = 50; -// Style name mapping: internal → URL-friendly -const STYLE_TO_URL = { - normal: 'normal', - retro: 'crt', - surveillance: 'nvg', - thermal: 'flir', - anime: 'anime', - noir: 'noir', - snow: 'snow', -}; +// Style name mapping: internal → URL-friendly, shared with views. +const STYLE_TO_URL = STYLE_URL_NAMES; const SHARE_UI_STATE_PARAM = 'ui'; const SHARE_STYLE_PARAMS_PARAM = 'sp'; diff --git a/src/tools/queries/share.js b/src/tools/queries/share.js index cbb3e8bab..f15ce34d2 100644 --- a/src/tools/queries/share.js +++ b/src/tools/queries/share.js @@ -1,10 +1,6 @@ -/** A link that opens God's Eye View on an area. */ +/** Links that open God's Eye View at a view. */ -import { - REGISTERED_LAYER_IDS, - createDefaultLayerState, - encodeLayerStateParams, -} from '../../data/layerState.js'; +import { VIEW_PROPERTIES, createView, viewUrl } from '../../view/index.js'; import { defineTool, ToolError } from '../catalog.js'; import { AREA_SCHEMA, areaCenter, areaRadiusKm, resolveArea } from '../area.js'; @@ -13,65 +9,86 @@ 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; -export const openInGodsEyeView = defineTool({ - name: 'open_in_gods_eye_view', - title: "Open in God's Eye View", - description: - "A link that opens God's Eye View looking straight down on an area, " + - 'optionally with chosen data layers turned on.', - inputSchema: { - type: 'object', - properties: { - area: AREA_SCHEMA, - layers: { - type: 'array', - maxItems: REGISTERED_LAYER_IDS.length, - items: { type: 'string', enum: [...REGISTERED_LAYER_IDS] }, - description: 'Layers to turn on, such as "flights" or "earthquakes".', - }, - }, - required: ['area'], - additionalProperties: false, - }, - requires: ['app'], - async run(args, { services, signal }) { - let base; - try { - base = new URL(services.app.baseUrl); - } catch { - throw new ToolError('unavailable', "The app's address is not configured"); - } +/** Tool arguments that describe a view: an area to frame, or a camera. */ +export const VIEW_ARGUMENTS = Object.freeze({ + area: AREA_SCHEMA, + ...VIEW_PROPERTIES, +}); + +/** + * The view described by tool arguments. An area frames the camera straight + * down over it; camera fields given alongside it override the framing. + */ +export async function resolveViewArguments(args, { services, signal }) { + let camera = { ...args.camera }; + let label = null; + if (args.area) { const area = await resolveArea(args.area, { services, signal }); const center = areaCenter(area); - const altitude = Math.round( - Math.min( + 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, + }; + } + if (!Number.isFinite(camera.lat) || !Number.isFinite(camera.lon)) + throw new ToolError( + 'invalid_arguments', + 'Give an area, or a camera with lat and lon', ); - // The share-link camera and layer fields; the app restores its defaults - // for everything else. - const params = new URLSearchParams({ - v: '2', - lat: center.lat.toFixed(4), - lon: center.lon.toFixed(4), - alt: String(altitude), - heading: '0', - pitch: '-90', + const view = createView({ + camera, + layers: args.layers, + style: args.style ?? null, + map: args.map ?? null, + follow: args.follow ?? null, + }); + return { + view, + label: + label ?? `${view.camera.lat.toFixed(3)}, ${view.camera.lon.toFixed(3)}`, + }; +} + +function appBase(services) { + try { + return new URL(services.app.baseUrl).href; + } catch { + throw new ToolError('unavailable', "The app's address is not configured"); + } +} + +export const openInGodsEyeView = defineTool({ + name: 'open_in_gods_eye_view', + title: "Open in God's Eye View", + description: + "A link that opens God's Eye View at a view: an area framed from above " + + 'or a camera position, with chosen data layers, visual style and map, ' + + 'optionally following an aircraft or satellite.', + inputSchema: { + type: 'object', + properties: VIEW_ARGUMENTS, + additionalProperties: false, + }, + requires: ['app'], + async run(args, { services, signal }) { + const base = appBase(services); + const { view, label } = await resolveViewArguments(args, { + services, + signal, }); - const layers = [...new Set(args.layers || [])]; - if (layers.length) - encodeLayerStateParams(params, { - ...createDefaultLayerState(), - enabledLayerIds: layers, - }); - base.hash = params.toString(); + const url = viewUrl(base, view); return { - summary: `Open ${area.label} in God's Eye View: ${base.href}`, - data: { url: base.href, center, altitude_m: altitude, layers }, + summary: `Open ${label} in God's Eye View: ${url}`, + data: { url, view }, }; }, }); diff --git a/src/tools/queries/share.test.mjs b/src/tools/queries/share.test.mjs index 038e1eb88..06e4c29fe 100644 --- a/src/tools/queries/share.test.mjs +++ b/src/tools/queries/share.test.mjs @@ -22,6 +22,7 @@ test('links open the app looking straight down on the area', async () => { alt: '9091', heading: '0', pitch: '-90', + roll: '0', }); assert.equal( result.summary, @@ -37,11 +38,11 @@ test('altitude is bounded for tiny and planet-sized areas', async () => { const tiny = await catalog.call('open_in_gods_eye_view', { area: { lat: 0, lon: 0, radius_km: 0.1 }, }); - assert.equal(tiny.data.altitude_m, 500); + assert.equal(tiny.data.view.camera.altitude_m, 500); const world = await catalog.call('open_in_gods_eye_view', { area: { bbox: [-180, -85, 180, 85] }, }); - assert.equal(world.data.altitude_m, 15000000); + assert.equal(world.data.view.camera.altitude_m, 15000000); const broken = composeCatalog({ tools: coreTools, services: { app: { baseUrl: 'not a url' } }, @@ -64,7 +65,7 @@ test('links turn on the requested layers with the app share-link codec', async ( area: { lat: 30.27, lon: -97.74, radius_km: 10 }, layers: ['earthquakes', 'flights', 'flights'], }); - assert.deepEqual(result.data.layers, ['earthquakes', 'flights']); + assert.deepEqual(result.data.view.layers, ['earthquakes', 'flights']); const params = new URLSearchParams(new URL(result.data.url).hash.slice(1)); assert.deepEqual(decodeLayerStateParams(params).enabledLayerIds.sort(), [ 'earthquakes', @@ -78,3 +79,37 @@ test('links turn on the requested layers with the app share-link codec', async ( /must be one of/, ); }); + +test('links carry a camera, style, map and an entity to follow', async () => { + const { viewFromParams } = await import('../../view/index.js'); + const catalog = composeCatalog({ + tools: coreTools, + services: { app: { baseUrl: 'http://localhost:4173/' } }, + }); + const result = await catalog.call('open_in_gods_eye_view', { + area: { lat: 25, lon: 121, radius_km: 200 }, + camera: { pitch_deg: -40, heading_deg: 350 }, + layers: ['ais-live-vessels'], + style: 'thermal', + map: 'esri-imagery', + follow: { kind: 'military_aircraft', id: 'AE1234' }, + }); + const params = new URLSearchParams(new URL(result.data.url).hash.slice(1)); + assert.equal(params.get('style'), 'flir'); + assert.deepEqual(viewFromParams(params), result.data.view); + assert.deepEqual(result.data.view.layers, ['ais-live-vessels', 'military']); + assert.deepEqual(result.data.view.follow, { + kind: 'military_aircraft', + id: 'ae1234', + }); + assert.equal(result.data.view.camera.pitch_deg, -40); + const camera = await catalog.call('open_in_gods_eye_view', { + camera: { lat: 48.8584, lon: 2.2945, altitude_m: 1200, pitch_deg: -30 }, + }); + assert.equal(camera.data.view.camera.altitude_m, 1200); + assert.match(camera.summary, /^Open 48\.858, 2\.295 in God's Eye View: /); + await assert.rejects( + catalog.call('open_in_gods_eye_view', { layers: ['flights'] }), + /Give an area, or a camera with lat and lon/, + ); +}); diff --git a/src/view/index.js b/src/view/index.js new file mode 100644 index 000000000..d988918ed --- /dev/null +++ b/src/view/index.js @@ -0,0 +1,237 @@ +/** + * A view: what God's Eye View shows, independent of how it is shown. Camera, + * data layers, visual style, map imagery and an entity to follow. Views are + * written in the share-link format, so the app restores them when a link + * opens and every surface that produces one agrees on its meaning. + */ + +import { + REGISTERED_LAYER_IDS, + createDefaultLayerState, + decodeLayerStateParams, + encodeLayerStateParams, +} from '../data/layerState.js'; + +/** Visual styles by internal name, with the name share links use. */ +export const STYLE_URL_NAMES = Object.freeze({ + normal: 'normal', + retro: 'crt', + surveillance: 'nvg', + thermal: 'flir', + anime: 'anime', + noir: 'noir', + snow: 'snow', +}); + +const STYLES_BY_URL_NAME = Object.freeze( + Object.fromEntries( + Object.entries(STYLE_URL_NAMES).map(([name, url]) => [url, name]), + ), +); + +export const VIEW_STYLES = Object.freeze(Object.keys(STYLE_URL_NAMES)); + +export const VIEW_MAPS = Object.freeze([ + 'photoreal', + 'bing-aerial', + 'bing-labels', + 'esri-imagery', + 'osm', +]); + +/** + * What can be followed, the layer that shows it, and where the layer-state + * codec keeps its identifier. + */ +const FOLLOWABLE = Object.freeze({ + aircraft: { + layer: 'flights', + owner: 'flights', + key: 'selectedFlightsTrackingId', + }, + military_aircraft: { + layer: 'military', + owner: 'flights', + key: 'selectedMilitaryTrackingId', + }, + satellite: { + layer: 'satellites', + owner: 'satellites', + key: 'selectedSatTrackingId', + }, +}); + +export const VIEW_FOLLOW_KINDS = Object.freeze(Object.keys(FOLLOWABLE)); + +const MIN_ALTITUDE_M = 50; +const MAX_ALTITUDE_M = 20_000_000; + +/** JSON Schema for the parts of a view a caller chooses directly. */ +export const VIEW_PROPERTIES = Object.freeze({ + camera: Object.freeze({ + type: 'object', + description: + 'Camera position: lat and lon it is above, altitude in meters, heading ' + + '(0 is north) and pitch (-90 looks straight down, 0 at the horizon).', + properties: { + lat: { type: 'number', minimum: -90, maximum: 90 }, + lon: { type: 'number', minimum: -180, maximum: 180 }, + altitude_m: { + type: 'number', + minimum: MIN_ALTITUDE_M, + maximum: MAX_ALTITUDE_M, + }, + heading_deg: { type: 'number', minimum: 0, maximum: 360 }, + pitch_deg: { type: 'number', minimum: -90, maximum: 0 }, + }, + additionalProperties: false, + }), + layers: Object.freeze({ + type: 'array', + maxItems: REGISTERED_LAYER_IDS.length, + items: { type: 'string', enum: [...REGISTERED_LAYER_IDS] }, + description: 'Data layers to turn on, such as "flights" or "earthquakes".', + }), + style: Object.freeze({ + type: 'string', + enum: [...VIEW_STYLES], + description: + 'Visual style: retro is CRT, surveillance is night vision, thermal is FLIR.', + }), + map: Object.freeze({ + type: 'string', + enum: [...VIEW_MAPS], + description: 'Map imagery; photoreal is Google 3D tiles.', + }), + follow: Object.freeze({ + type: 'object', + description: + 'An entity the camera follows: an aircraft or military aircraft by ' + + 'ICAO 24-bit address, or a satellite by NORAD catalog number. Its layer ' + + 'is turned on.', + properties: { + kind: { type: 'string', enum: [...VIEW_FOLLOW_KINDS] }, + id: { type: 'string', pattern: '^[0-9A-Za-z~_-]{1,16}$' }, + }, + required: ['kind', 'id'], + additionalProperties: false, + }), +}); + +const round = (value, digits) => Number(value.toFixed(digits)); +const clamp = (value, min, max) => Math.min(max, Math.max(min, value)); + +/** + * A complete view from its parts. `camera` needs lat and lon; the rest has + * defaults: 800 km up, looking straight down, no layers, the recipient's own + * style and map. + */ +export function createView({ + camera, + layers = [], + style = null, + map = null, + follow = null, +} = {}) { + if (!Number.isFinite(camera?.lat) || !Number.isFinite(camera?.lon)) + throw new TypeError('A view needs a camera lat and lon'); + if (style !== null && !VIEW_STYLES.includes(style)) + throw new TypeError(`Unknown view style: ${style}`); + if (map !== null && !VIEW_MAPS.includes(map)) + throw new TypeError(`Unknown view map: ${map}`); + const followed = follow ? FOLLOWABLE[follow.kind] : null; + if (follow && !followed) + throw new TypeError(`Cannot follow a ${follow.kind}`); + const enabled = new Set( + layers.filter((layer) => REGISTERED_LAYER_IDS.includes(layer)), + ); + if (followed) enabled.add(followed.layer); + return Object.freeze({ + camera: Object.freeze({ + lat: round(clamp(camera.lat, -90, 90), 4), + lon: round(clamp(camera.lon, -180, 180), 4), + altitude_m: Math.round( + clamp(camera.altitude_m ?? 800_000, MIN_ALTITUDE_M, MAX_ALTITUDE_M), + ), + heading_deg: Math.round(camera.heading_deg ?? 0) % 360, + pitch_deg: Math.round(clamp(camera.pitch_deg ?? -90, -90, 0)), + }), + layers: Object.freeze( + REGISTERED_LAYER_IDS.filter((layer) => enabled.has(layer)), + ), + style, + map, + follow: follow + ? Object.freeze({ + kind: follow.kind, + id: String(follow.id).toLowerCase(), + }) + : null, + }); +} + +/** Share-link hash parameters that restore a view. */ +export function viewToParams(view) { + const params = new URLSearchParams({ + v: '2', + lat: view.camera.lat.toFixed(4), + lon: view.camera.lon.toFixed(4), + alt: String(view.camera.altitude_m), + heading: String(view.camera.heading_deg), + pitch: String(view.camera.pitch_deg), + roll: '0', + }); + if (view.style) params.set('style', STYLE_URL_NAMES[view.style]); + if (view.map) params.set('map', view.map); + if (view.layers.length) { + const state = createDefaultLayerState(); + state.enabledLayerIds = [...view.layers]; + const followed = view.follow ? FOLLOWABLE[view.follow.kind] : null; + if (followed) + state.options[followed.owner] = { + ...state.options[followed.owner], + [followed.key]: view.follow.id, + }; + encodeLayerStateParams(params, state); + } + return params; +} + +/** The view a set of share-link hash parameters restores, or null. */ +export function viewFromParams(params) { + const lat = Number.parseFloat(params.get('lat')); + const lon = Number.parseFloat(params.get('lon')); + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null; + const number = (name) => { + const value = Number.parseFloat(params.get(name)); + return Number.isFinite(value) ? value : undefined; + }; + const state = decodeLayerStateParams(params); + let follow = null; + for (const [kind, followed] of Object.entries(FOLLOWABLE)) { + const id = state?.options?.[followed.owner]?.[followed.key]; + if (id !== null && id !== undefined && !follow) + follow = { kind, id: String(id) }; + } + const map = params.get('map'); + return createView({ + camera: { + lat, + lon, + altitude_m: number('alt'), + heading_deg: number('heading'), + pitch_deg: number('pitch'), + }, + layers: state?.enabledLayerIds ?? [], + style: STYLES_BY_URL_NAME[params.get('style')] ?? null, + map: VIEW_MAPS.includes(map) ? map : null, + follow, + }); +} + +/** The address that opens the app at a view. */ +export function viewUrl(baseUrl, view) { + const url = new URL(baseUrl); + url.hash = viewToParams(view).toString(); + return url.href; +} diff --git a/src/view/index.test.mjs b/src/view/index.test.mjs new file mode 100644 index 000000000..49497c6d7 --- /dev/null +++ b/src/view/index.test.mjs @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + STYLE_URL_NAMES, + createView, + viewFromParams, + viewToParams, + viewUrl, +} from './index.js'; + +test('a view round-trips through share-link parameters', () => { + const view = createView({ + camera: { + lat: 35.68, + lon: 139.76, + altitude_m: 300000, + heading_deg: 15, + pitch_deg: -45, + }, + layers: ['earthquakes', 'ais-live-vessels'], + style: 'retro', + map: 'osm', + follow: { kind: 'aircraft', id: 'AE1234' }, + }); + const params = viewToParams(view); + assert.equal(params.get('style'), 'crt'); + assert.equal(params.get('map'), 'osm'); + assert.deepEqual(viewFromParams(params), view); + assert.deepEqual(view.layers, ['ais-live-vessels', 'earthquakes', 'flights']); + const satellite = createView({ + camera: { lat: 0, lon: 0 }, + follow: { kind: 'satellite', id: '25544' }, + }); + assert.deepEqual(viewFromParams(viewToParams(satellite)).follow, { + kind: 'satellite', + id: '25544', + }); +}); + +test('views have defaults, bounds, and reject what links cannot carry', () => { + const view = createView({ camera: { lat: 91, lon: 10, altitude_m: 1 } }); + assert.deepEqual(view.camera, { + lat: 90, + lon: 10, + altitude_m: 50, + heading_deg: 0, + pitch_deg: -90, + }); + assert.equal(view.style, null); + const params = viewToParams(view); + assert.equal(params.has('style'), false); + assert.equal(params.has('l'), false); + assert.throws(() => createView({ camera: {} }), /lat and lon/); + assert.throws( + () => createView({ camera: { lat: 0, lon: 0 }, style: 'sepia' }), + /Unknown view style/, + ); + assert.throws( + () => + createView({ + camera: { lat: 0, lon: 0 }, + follow: { kind: 'ship', id: '1' }, + }), + /Cannot follow a ship/, + ); + assert.equal(viewFromParams(new URLSearchParams('style=crt')), null); + assert.equal( + new URL(viewUrl('https://maps.example/app', view)).hash.slice(1), + params.toString(), + ); +}); + +test('views use the same style names as the app share links', async () => { + const source = await import('node:fs/promises').then((fs) => + fs.readFile(new URL('../sharelink.js', import.meta.url), 'utf8'), + ); + assert.match(source, /const STYLE_TO_URL = STYLE_URL_NAMES;/); + assert.equal(STYLE_URL_NAMES.retro, 'crt'); +});