Files
gods-eye-view/src/voice/layerManifest.js
T
Sameh Khamis dd15223109 Class only PID's trolleybus lines as trolleybuses, and name the new modes in the plural for voice
PID's routes.txt types lines 51, 52, 53, 58, 59, 232 and 275 as
trolleybuses (route type 11); the rest of 50 to 59 stay buses, as before.
Voice now also knows trolleybuses, gondolas, cable cars, funiculars and
monorails.
2026-11-14 12:22:30 -08:00

648 lines
17 KiB
JavaScript

/**
* Voice manifest for every data layer the application catalog constructs.
*
* One plain-data entry per layer declares each voice capability separately:
* the spoken aliases the action runner accepts, `toggle` (set_layer_visibility),
* `context` (registers in-view entity context), `track` (track_entity), and
* `query` — the analyst_query fields with type and unit — or `noQuery`, the
* reason it has none. No layer has a voice timeline yet.
*
* Both the server tool builder (`actionSchemas.js` → the Realtime tool JSON)
* and the browser runner (`gevActions.js`) derive their layer enums and alias
* tables from here, so a new layer is reachable by voice as soon as it has an
* entry. `layerManifest.test.mjs` fails when a catalog layer is neither listed
* nor named in `VOICE_OFF_LAYERS`.
*
* Pure data: no Cesium, DOM or Node imports (the server imports this module).
* @module voice/layerManifest
*/
/**
* Field type shorthands: n = number, t = text, f = flag (boolean),
* ms = epoch-milliseconds time (filters also accept ISO strings).
* A second array element is the unit.
*/
const AIRCRAFT_FIELDS = {
altitudeM: ['n', 'm'],
speedMps: ['n', 'm/s'],
verticalRateMps: ['n', 'm/s'],
heading: ['n', 'deg'],
callsign: ['t'],
icao24: ['t'],
originCountry: ['t'],
operator: ['t'],
aircraftClass: ['t'],
military: ['f'],
onGround: ['f'],
squawk: ['t'],
emergency: ['f'],
emergencyKind: ['t'],
emergencySource: ['t'],
emergencyBroadcast: ['t'],
lastContactAtMs: ['ms'],
};
const layers = [
{
id: 'gnss-integrity',
aliases: [
'gnss integrity',
'navigation integrity',
'low navigation accuracy',
'gnss interference',
'gps interference',
'gps jamming',
'gnss',
],
context: false,
noQuery: 'navigation-integrity cells; no countable analyst records',
},
{
id: 's2-ghostmaps',
aliases: [
's2 ghostmaps',
'ghost maps',
'ghostmaps',
's2 underground',
's2 intel',
'intel picture',
'common intelligence picture',
],
context: false,
noQuery: 'published third-party intel placemarks; not analyst records',
},
{
id: 'sea-marks',
aliases: ['seamarks', 'nautical charts', 'buoys', 'openseamap'],
context: false,
noQuery: 'raster tile overlay; no countable records',
},
{
id: 'ski-pistes',
aliases: ['ski slopes', 'ski runs', 'snow map', 'opensnowmap'],
context: false,
noQuery: 'raster tile overlay; no countable records',
},
{
id: 'flights',
track: true,
aliases: ['planes', 'aircraft', 'air traffic'],
context: false,
query: {
fields: {
...AIRCRAFT_FIELDS,
routeOrigin: ['t'],
routeDestination: ['t'],
},
viewportLoaded: true,
},
},
{
id: 'military',
track: true,
aliases: ['military flights', 'military aircraft', 'military planes'],
context: false,
query: { fields: AIRCRAFT_FIELDS },
},
{
id: 'local-adsb',
aliases: [
'my receiver',
'local adsb',
'local ads-b',
'my antenna',
'my sdr',
],
context: true,
query: {
fields: {
altitudeM: ['n', 'm'],
speedKts: ['n', 'kn'],
heading: ['n', 'deg'],
callsign: ['t'],
icao24: ['t'],
onGround: ['f'],
},
},
},
{
id: 'ais-live-vessels',
track: true,
aliases: ['ships', 'vessels', 'boats', 'ais', 'live vessels'],
context: true,
query: {
fields: {
speedKts: ['n', 'kn'],
courseDeg: ['n', 'deg'],
name: ['t'],
mmsi: ['t'],
shipType: ['t'],
destination: ['t'],
},
},
},
{
id: 'satellites',
track: true,
aliases: ['satellite', 'sats'],
context: false,
query: {
fields: {
altitudeM: ['n', 'm'],
speedMps: ['n', 'm/s'],
name: ['t'],
noradId: ['t'],
satelliteClass: ['t'],
group: ['t'],
},
caveat: 'distance is ground distance, not overhead',
},
},
{
id: 'rocket-launches',
aliases: [
'rocket launches',
'launches',
'space missions',
'space mission',
'missions',
],
context: false,
query: {
fields: {
name: ['t'],
provider: ['t'],
status: ['t'],
launchSite: ['t'],
missionName: ['t'],
launchTimeMs: ['ms'],
hoursUntil: ['n', 'h'],
},
window: 'past 30 d and upcoming',
},
},
{
id: 'earthquakes',
aliases: ['quakes', 'seismic'],
context: false,
query: {
fields: {
magnitude: ['n'],
depthKm: ['n', 'km'],
place: ['t'],
timeMs: ['ms'],
},
timeField: 'timeMs',
window: 'last 24 h',
},
},
{
id: 'local-firms',
aliases: ['fires', 'wildfires', 'active fires', 'firms'],
context: true,
query: {
fields: {
frp: ['n', 'MW'],
confidence: ['n', '0-1'],
satellite: ['t'],
sensor: ['t'],
acqTime: ['ms'],
},
timeField: 'acqTime',
window: 'last 24 h',
},
},
{
id: 'fire-perimeters',
aliases: [
'fire perimeters',
'wildfire perimeters',
'burn areas',
'perimeters',
],
context: false,
query: {
fields: {
acres: ['n', 'acres'],
containedPct: ['n', '%'],
personnel: ['n'],
costToDate: ['n', 'USD'],
name: ['t'],
state: ['t'],
county: ['t'],
cause: ['t'],
behavior: ['t'],
complexity: ['t'],
discoveredTime: ['ms'],
},
timeField: 'discoveredTime',
},
},
{
id: 'weather-cyclones',
aliases: ['cyclones', 'hurricanes', 'typhoons', 'tropical storms'],
context: false,
query: {
fields: {
name: ['t'],
classification: ['t'],
basin: ['t'],
windKt: ['n', 'kn'],
pressureHpa: ['n', 'hPa'],
},
},
},
{
id: 'weather-radar',
aliases: ['weather radar', 'radar', 'rain', 'weather'],
context: false,
noQuery: 'map overlay; no countable records',
},
{
id: 'weather-satellite',
aliases: ['weather satellite', 'clouds', 'cloud imagery'],
context: false,
noQuery: 'map overlay; no countable records',
},
{
id: 'weather-lightning',
aliases: ['lightning', 'lightning strikes'],
context: false,
noQuery: 'map overlay; no countable records',
},
{
id: 'wind',
aliases: ['winds', 'wind map'],
context: false,
noQuery: 'map overlay; no countable records',
},
{
id: 'air-quality',
aliases: ['air quality', 'aqhi', 'smog', 'air pollution'],
context: false,
query: {
fields: {
value: ['n', 'AQHI'],
band: ['t'],
scale: ['t'],
provider: ['t'],
name: ['t'],
zone: ['t'],
risk: ['t'],
observedMs: ['ms'],
},
timeField: 'observedMs',
},
},
{
id: 'traffic',
aliases: ['street traffic', 'road traffic', 'congestion'],
context: false,
noQuery: 'road-speed overlay; no countable records',
},
{
id: 'transit',
aliases: [
'buses',
'trains',
'public transit',
'trams',
'subway',
'trolleybus',
'trolleybuses',
'gondola',
'gondolas',
'cable car',
'cable cars',
'funicular',
'funiculars',
'monorail',
'monorails',
],
context: false,
query: {
fields: {
mode: ['t'],
routeId: ['t'],
speedMps: ['n', 'm/s'],
courseDeg: ['n', 'deg'],
status: ['t'],
occupancy: ['t'],
},
},
},
{
id: 'bikeshare',
aliases: ['bike share', 'bikes', 'bike stations'],
context: false,
query: {
fields: {
bikesAvailable: ['n'],
docksAvailable: ['n'],
capacity: ['n'],
name: ['t'],
city: ['t'],
renting: ['f'],
},
},
},
{
id: 'cctv',
aliases: ['traffic cameras', 'cameras', 'webcams'],
context: false,
noQuery: 'use control_cctv',
},
{
id: 'alpr-cameras',
aliases: [
'flock cameras',
'license plate readers',
'alpr',
'alpr cameras',
'license plate cameras',
'plate readers',
],
context: true,
query: {
fields: {
operator: ['t'],
manufacturer: ['t'],
cameraType: ['t'],
directionDeg: ['n', 'deg'],
},
},
},
{
id: 'radio',
aliases: ['internet radio', 'radio stations'],
context: false,
noQuery: 'use control_radio',
},
{
id: 'street-level',
aliases: [
'street level',
'street-level imagery',
'mapillary',
'street view',
],
context: false,
noQuery: 'street-level imagery; no countable records',
},
{
id: 'recent-imagery',
aliases: [
'recent satellite imagery',
'recent imagery',
'latest imagery',
'new imagery',
],
context: false,
noQuery: 'imagery tiles; no countable records',
},
{
id: 'directions',
aliases: ['driving directions', 'directions panel'],
context: false,
noQuery: 'use annotate_map type=route',
},
{
id: 'military-installations',
aliases: ['military bases', 'bases', 'installations'],
context: true,
query: { fields: { name: ['t'], class: ['t'], kind: ['t'] } },
},
{
id: 'local-datacenters',
aliases: ['datacenters', 'data centers', 'data centres'],
context: true,
query: {
fields: { name: ['t'], operator: ['t'], capacity: ['t'] },
},
},
{
id: 'local-dams',
aliases: ['dams'],
context: true,
query: {
fields: {
name: ['t'],
operator: ['t'],
river: ['t'],
output: ['t'],
},
},
},
{
id: 'telegeography-submarine-cables',
aliases: ['submarine cables', 'undersea cables', 'cables', 'telegeography'],
context: false,
noQuery: 'cable records are not loaded for queries',
},
{
// Results of the last osm_query ("hospitals in Kathmandu").
id: 'osm-places',
aliases: ['osm places', 'map search results', 'search results'],
context: false,
query: {
fields: {
name: ['t'],
kind: ['t'],
operator: ['t'],
emergency: ['t'],
wheelchair: ['t'],
openingHours: ['t'],
city: ['t'],
},
},
},
{
// Global Context owns this layer; voice reaches it through
// set_context_mode, never a bare toggle.
id: 'military-awareness',
aliases: [],
toggle: false,
context: false,
noQuery: 'use set_context_mode contacts',
},
];
/**
* Catalog layers deliberately outside voice, with the reason. The Bhote Koshi
* pair belong to a scripted scene and are driven by `control_scene`.
*/
export const VOICE_OFF_LAYERS = Object.freeze({
'bhote-koshi-2026': 'scene-driven event layer (control_scene)',
'bhote-koshi-locator': 'scene-driven event layer (control_scene)',
// The Realtime prompt is at its 55,500-character budget; voice toggle and
// query for this layer wait until there is room.
'drone-airspace':
'informational airspace layer; no room in the prompt budget',
// Same budget: a historical wreck archive waits for room too.
shipwrecks: 'historical shipwreck archive; no room in the prompt budget',
news: 'browsed in the News panel; voice tools are a later step',
'wildlife-sightings':
'community observation layer; no room in the prompt budget',
rail: 'train layer; no room in the prompt budget, and "trains" still names transit',
});
const TYPE_NAMES = Object.freeze({
n: 'number',
t: 'text',
f: 'flag',
ms: 'time',
});
function normalizeEntry(entry) {
const fields = entry.query
? Object.fromEntries(
Object.entries(entry.query.fields).map(([name, [type, unit]]) => [
name,
Object.freeze({
type: TYPE_NAMES[type],
...(unit ? { unit } : {}),
}),
]),
)
: null;
return Object.freeze({
id: entry.id,
aliases: Object.freeze([...entry.aliases]),
toggle: entry.toggle !== false,
context: entry.context === true,
track: entry.track === true,
noQuery: entry.noQuery || null,
query: fields
? Object.freeze({
fields: Object.freeze(fields),
timeField: entry.query.timeField || null,
window: entry.query.window || null,
caveat: entry.query.caveat || null,
viewportLoaded: entry.query.viewportLoaded === true,
})
: null,
});
}
/** Every voice-reachable layer, in the order the tool enums list them. */
export const VOICE_LAYER_MANIFEST = Object.freeze(layers.map(normalizeEntry));
const byId = new Map(VOICE_LAYER_MANIFEST.map((entry) => [entry.id, entry]));
/**
* The manifest entry for one layer id.
* @param {string} id Layer id.
* @returns {object|null} Entry, or null for an unknown or voice-off layer.
*/
export function voiceLayer(id) {
return byId.get(id) || null;
}
/** Layer ids `set_layer_visibility` and `show_data_layers_menu` accept. */
export const VOICE_TOGGLE_LAYER_IDS = Object.freeze(
VOICE_LAYER_MANIFEST.filter((entry) => entry.toggle).map((entry) => entry.id),
);
/** Layers that register in-view entity context (`get_entity_context.layerId`). */
export const VOICE_CONTEXT_LAYER_IDS = Object.freeze(
VOICE_LAYER_MANIFEST.filter((entry) => entry.context).map(
(entry) => entry.id,
),
);
/** Layers `analyst_query` can count, filter and rank. */
export const VOICE_QUERY_LAYER_IDS = Object.freeze(
VOICE_LAYER_MANIFEST.filter((entry) => entry.query).map((entry) => entry.id),
);
/**
* Spoken name → layer id, for the browser runner. Ids map to themselves and
* each alias to its layer; the first layer to claim an alias keeps it.
* @returns {Map<string, string>} Lower-case phrase → layer id.
*/
export function voiceLayerAliasMap() {
const map = new Map();
for (const entry of VOICE_LAYER_MANIFEST) {
map.set(entry.id, entry.id);
map.set(entry.id.replace(/-/g, ' '), entry.id);
}
for (const entry of VOICE_LAYER_MANIFEST) {
for (const alias of entry.aliases) {
const key = alias.toLowerCase();
if (!map.has(key)) map.set(key, entry.id);
}
}
return map;
}
/** Plain-word ids the model already maps from speech without a hint. */
const SELF_EVIDENT_IDS = new Set([
'flights',
'military',
'satellites',
'earthquakes',
'traffic',
'radio',
'wind',
'directions',
]);
/** Lower case with spaces and hyphens removed: "Data centers" → "datacenters". */
const spelled = (text) => text.toLowerCase().replace(/[\s-]+/g, '');
/**
* Compact model-facing alias hint: "alias/alias→id; …" for toggleable layers
* whose id is not already the spoken word. At most two aliases per layer keep
* the tool JSON small; the runner accepts the full alias list. Of those two,
* an alias the id already spells ("dams"→local-dams, "weather radar"→
* weather-radar) is left out, and a layer with neither left gets no hint.
* @returns {string} One line for the layerId description.
*/
export function voiceLayerAliasHint() {
return VOICE_LAYER_MANIFEST.filter(
(entry) => entry.toggle && !SELF_EVIDENT_IDS.has(entry.id),
)
.map((entry) => ({
id: entry.id,
aliases: entry.aliases
.slice(0, 2)
.filter((alias) => !spelled(entry.id).includes(spelled(alias))),
}))
.filter((entry) => entry.aliases.length)
.map((entry) => `${entry.aliases.join('/')}→${entry.id}`)
.join(';');
}
/** Field names that already carry their unit (altitudeM, speedKts, …). */
const UNIT_SUFFIX = /(M|Mps|Kts|Kt|Km|Deg|Hpa)$/;
/**
* Compact field list for the analyst_query description:
* "flights: altitudeM,speedMps,…,confidence(0-1)". Units appear only where
* the field name does not already say them.
* @returns {string} One clause per queryable layer.
*/
export function voiceQueryFieldHint() {
const printed = [];
return VOICE_LAYER_MANIFEST.filter((entry) => entry.query)
.map((entry) => {
const names = Object.keys(entry.query.fields);
// A layer whose fields are a subset of an earlier one's points at it.
const parent = printed.find((prior) =>
names.every((name) => prior.names.includes(name)),
);
printed.push({ id: entry.id, names });
if (parent) {
const minus = parent.names.filter((name) => !names.includes(name));
return `${entry.id}: as ${parent.id}${minus.length ? ` minus ${minus.join(',')}` : ''}`;
}
const fields = Object.entries(entry.query.fields)
.map(([name, spec]) =>
spec.unit && !UNIT_SUFFIX.test(name) ? `${name}(${spec.unit})` : name,
)
.join(',');
return `${entry.id}: ${fields}`;
})
.join('; ');
}