mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
feat: describe what the app shows as a view
A view is a camera, data layers, visual style, map imagery and an aircraft, military aircraft or satellite to follow, written in the share-link format the app already restores. gods-eye-view/view builds, encodes and decodes one, and share links take their style names from it. open_in_gods_eye_view now builds its link from a full view: an area or camera, layers, style, map and something to follow.
This commit is contained in:
1 parent
de1e0836b3
commit
96a8a9addb
10 files changed
+463
-72
No files matched your search
+17
-1
@@ -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 |
|
||||
+2
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ const portableExport = (key) =>
|
||||
'./tools',
|
||||
'./tools/mcp',
|
||||
'./tools/services',
|
||||
'./view',
|
||||
'./voice/action-schemas',
|
||||
'./voice/session',
|
||||
'./data/lifecycle',
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
+3
-10
@@ -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';
|
||||
|
||||
+71
-54
@@ -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 },
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
Reference in new issue
Block a user