mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
- Voice layer manifest (src/voice/layerManifest.js) generates the layer enums, aliases and analyst field hints; a test fails when a catalog layer has no entry. - analyst_query validates fields, operators, units, values and scopes, counts up to 250,000 loaded records per layer, reports complete/partial/unanswered, tells LAYER_OFF / NOT_READY / FEED_UNAVAILABLE apart from zero, refuses mismatched follow-ups and never lets a superseded query replace follow-up memory. Seven more layers expose analyst records. - Tool results carry a code-built say line and a display card; the voice card in the mic control shows captions, plan steps and the result (src/voice/resultDisplay.js is the one adapter). Slow tools narrate their current step out of band; holding Space while the assistant talks cuts it off. Turn spans go to the debug log; caption transcription is metered; the log omits what was said unless GEV_VOICE_LOG_CONTENT=1. - Point and ask: the Space-down pointer snapshot resolves "this", "that one" and "around here" through 'pointer' targets, and "the second one" through referent:n from the last numbered list. - Prompt states each policy once (instructions + tools 48.5k -> 46.7k chars). - Voice benchmark harness (scripts/qa-voice-bench.mjs, scripts/voice-bench/) shares its phrase table and grading with scripts/qa-voice-routing.mjs.
477 lines
17 KiB
JavaScript
477 lines
17 KiB
JavaScript
import * as Cesium from 'cesium';
|
|
import { hitTestWorldOverlay } from '../../overlays/worldOverlay.js';
|
|
import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js';
|
|
import { isPointerFree } from '../../data/inputOwnership.js';
|
|
import {
|
|
registerPickOwner,
|
|
unregisterPickOwner,
|
|
} from '../../data/pickRegistry.js';
|
|
import {
|
|
createCycloneRendering,
|
|
coherentCycloneGeometry,
|
|
} from './rendering.js';
|
|
import {
|
|
CYCLONE_OVERLAY_SOURCE_ID,
|
|
cycloneStormIdFromEntryId,
|
|
} from './labels.js';
|
|
|
|
const utc = (value) =>
|
|
value ? `${value.slice(5, 16).replace('T', ' ')} UTC` : 'Unavailable';
|
|
const COVERAGE =
|
|
'Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.';
|
|
const CLASSIFICATION_NAMES = Object.freeze({
|
|
PTC: 'Potential tropical cyclone',
|
|
HU: 'Hurricane',
|
|
TS: 'Tropical storm',
|
|
TD: 'Tropical depression',
|
|
SS: 'Subtropical storm',
|
|
SD: 'Subtropical depression',
|
|
});
|
|
const classificationName = (code) =>
|
|
Object.hasOwn(CLASSIFICATION_NAMES, code) ? CLASSIFICATION_NAMES[code] : code;
|
|
const number = (value, unit) =>
|
|
value === null ? 'Unavailable' : `${value} ${unit}`;
|
|
|
|
/** Advisory status and coherent forecast geometry; selected through the shared row list. */
|
|
export function createCyclonesLayer({
|
|
feed,
|
|
hitTestOverlay = hitTestWorldOverlay,
|
|
overlayHost,
|
|
cesium = Cesium,
|
|
createRendering = createCycloneRendering,
|
|
matchMedia = globalThis.matchMedia?.bind(globalThis),
|
|
openLink = (url) => globalThis.open?.(url, '_blank', 'noopener,noreferrer'),
|
|
} = {}) {
|
|
if (typeof feed?.getSnapshot !== 'function')
|
|
throw new TypeError('Cyclones require a snapshot source');
|
|
let viewer = null,
|
|
rendering = null,
|
|
snapshot = null,
|
|
request = null,
|
|
listener = null,
|
|
selectedId = null,
|
|
selectionIntent = 'auto',
|
|
navigationGeneration = 0,
|
|
clickHandler = null,
|
|
removeClickCapture = null;
|
|
let enabled = false,
|
|
loading = false,
|
|
error = null,
|
|
destroyed = false,
|
|
runNavigation = null;
|
|
const notify = () => listener?.();
|
|
const selected = () =>
|
|
snapshot?.storms.find((storm) => storm.id === selectedId) || null;
|
|
function select(id) {
|
|
if (selectedId !== id) ++navigationGeneration;
|
|
selectedId = id;
|
|
rendering?.setSelection(id);
|
|
}
|
|
// Photorealistic 3D Tiles pick as tileset content without an entity id;
|
|
// that is empty map, the same as no pick at all on the globe.
|
|
const isSurfacePick = (picked) =>
|
|
!picked ||
|
|
(picked.id === undefined &&
|
|
(picked.content !== undefined ||
|
|
(typeof cesium.Cesium3DTileset === 'function' &&
|
|
picked.primitive instanceof cesium.Cesium3DTileset)));
|
|
function installSelection() {
|
|
if (
|
|
clickHandler ||
|
|
!viewer?.scene?.canvas ||
|
|
typeof cesium.ScreenSpaceEventHandler !== 'function'
|
|
)
|
|
return;
|
|
const owner = new cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
|
|
clickHandler = owner;
|
|
// Snapshot before sibling bubble listeners can rebuild the overlay hit
|
|
// rectangles. A vessel selection does that synchronously in the same click.
|
|
const canvas = viewer.scene.canvas;
|
|
let capturedHit = null;
|
|
const overlayHit = (x, y) => {
|
|
const hit = hitTestOverlay(x, y);
|
|
return { sourceId: hit?.sourceId, entryId: hit?.entryId };
|
|
};
|
|
const capture = (event) => {
|
|
capturedHit = null;
|
|
const point = event.changedTouches?.[0] || event;
|
|
if (!Number.isFinite(point.clientX) || !Number.isFinite(point.clientY))
|
|
return;
|
|
const rect = canvas.getBoundingClientRect();
|
|
const x = point.clientX - rect.left,
|
|
y = point.clientY - rect.top;
|
|
capturedHit = { x, y, ...overlayHit(x, y) };
|
|
};
|
|
const resetCapture = () => {
|
|
capturedHit = null;
|
|
};
|
|
const resetEvents = [
|
|
'pointerdown',
|
|
'mousedown',
|
|
'touchstart',
|
|
'pointercancel',
|
|
'touchcancel',
|
|
];
|
|
for (const type of resetEvents)
|
|
canvas.addEventListener?.(type, resetCapture, { capture: true });
|
|
const events = ['pointerup', 'mouseup', 'touchend'];
|
|
for (const type of events)
|
|
canvas.addEventListener?.(type, capture, { capture: true });
|
|
removeClickCapture = () => {
|
|
for (const type of events)
|
|
canvas.removeEventListener?.(type, capture, { capture: true });
|
|
for (const type of resetEvents)
|
|
canvas.removeEventListener?.(type, resetCapture, { capture: true });
|
|
capturedHit = null;
|
|
};
|
|
owner.setInputAction((click) => {
|
|
const nativeHit = capturedHit;
|
|
capturedHit = null;
|
|
// Ambient selection yields to draw tools and Director; it never claims
|
|
// the pointer, camera, or tracking state.
|
|
if (
|
|
!enabled ||
|
|
destroyed ||
|
|
clickHandler !== owner ||
|
|
!isPointerFree() ||
|
|
!click?.position
|
|
)
|
|
return;
|
|
// AIS cards paint above the globe on a pointer-events:none canvas. The
|
|
// vessel handler resolves this same topmost hit before cyclone geometry.
|
|
const captureMatches =
|
|
nativeHit &&
|
|
Math.abs(nativeHit.x - click.position.x) < 1 &&
|
|
Math.abs(nativeHit.y - click.position.y) < 1;
|
|
const hit = captureMatches
|
|
? nativeHit
|
|
: overlayHit(click.position.x, click.position.y);
|
|
if (hit.sourceId === VESSEL_OVERLAY_SOURCE_ID) return;
|
|
// Storm cards and lead-hour labels paint on the same canvas; a click on
|
|
// one selects its storm. An id from a superseded advisory changes nothing.
|
|
if (hit.sourceId === CYCLONE_OVERLAY_SOURCE_ID) {
|
|
const id = cycloneStormIdFromEntryId(hit.entryId);
|
|
if (id) layer.setParams({ stormId: id });
|
|
return;
|
|
}
|
|
const picked = viewer.scene.pick(click.position);
|
|
const id = rendering?.pickStorm(picked);
|
|
if (id) layer.setParams({ stormId: id });
|
|
else if (isSurfacePick(picked)) layer.setParams({ clear: true });
|
|
}, cesium.ScreenSpaceEventType.LEFT_CLICK);
|
|
}
|
|
function removeSelection() {
|
|
const owner = clickHandler;
|
|
clickHandler = null;
|
|
removeClickCapture?.();
|
|
removeClickCapture = null;
|
|
if (owner && !owner.isDestroyed?.()) owner.destroy();
|
|
}
|
|
const layer = {
|
|
id: 'weather-cyclones',
|
|
name: 'Cyclone advisories',
|
|
icon: '◉',
|
|
source: 'NOAA NHC / CPHC',
|
|
updateInterval: 300_000,
|
|
init(nextViewer) {
|
|
viewer = nextViewer;
|
|
rendering = createRendering({ viewer, cesium, overlayHost });
|
|
},
|
|
attachShellServices(services) {
|
|
runNavigation =
|
|
typeof services?.runNavigation === 'function'
|
|
? services.runNavigation
|
|
: null;
|
|
notify();
|
|
},
|
|
enable() {
|
|
if (!destroyed && !enabled) {
|
|
enabled = true;
|
|
registerPickOwner(
|
|
'weather-cyclones',
|
|
(id) => enabled && rendering?.ownsPickId?.(id) === true,
|
|
);
|
|
installSelection();
|
|
}
|
|
},
|
|
disable() {
|
|
enabled = false;
|
|
unregisterPickOwner('weather-cyclones');
|
|
removeSelection();
|
|
request?.abort();
|
|
request = null;
|
|
loading = false;
|
|
error = null;
|
|
snapshot = null;
|
|
selectedId = null;
|
|
selectionIntent = 'auto';
|
|
++navigationGeneration;
|
|
rendering?.clear();
|
|
},
|
|
async update(_viewer, { signal } = {}) {
|
|
if (!enabled || destroyed) return false;
|
|
request?.abort();
|
|
const controller = new AbortController();
|
|
if (signal?.aborted) controller.abort(signal.reason);
|
|
request = controller;
|
|
const abort = () => controller.abort(signal.reason);
|
|
signal?.addEventListener('abort', abort, { once: true });
|
|
loading = true;
|
|
notify();
|
|
try {
|
|
signal?.throwIfAborted();
|
|
const next = await feed.getSnapshot({ signal: controller.signal });
|
|
if (!enabled || controller.signal.aborted || request !== controller)
|
|
return false;
|
|
if (next.unavailable) {
|
|
// An expired advisory must not remain presented as current hazard context.
|
|
rendering.clear();
|
|
snapshot = next;
|
|
selectedId = null;
|
|
++navigationGeneration;
|
|
error = next.reason || 'Cyclone advisories unavailable';
|
|
return true;
|
|
}
|
|
const applied = await rendering.setSnapshot(next, {
|
|
signal: controller.signal,
|
|
});
|
|
if (
|
|
!applied ||
|
|
!enabled ||
|
|
controller.signal.aborted ||
|
|
request !== controller
|
|
)
|
|
return false;
|
|
snapshot = next;
|
|
error = null;
|
|
if (!snapshot.storms.some((storm) => storm.id === selectedId))
|
|
select(
|
|
selectionIntent === 'cleared'
|
|
? null
|
|
: snapshot.storms[0]?.id || null,
|
|
);
|
|
else rendering.setSelection(selectedId);
|
|
return true;
|
|
} catch (cause) {
|
|
if (controller.signal.aborted || request !== controller) return false;
|
|
error = cause?.message || 'Cyclone advisories unavailable';
|
|
// Failed acquisition has no bounded last-good age guarantee at this layer.
|
|
rendering?.clear();
|
|
snapshot = null;
|
|
selectedId = null;
|
|
++navigationGeneration;
|
|
return true;
|
|
} finally {
|
|
signal?.removeEventListener('abort', abort);
|
|
if (request === controller) {
|
|
request = null;
|
|
loading = false;
|
|
notify();
|
|
}
|
|
}
|
|
},
|
|
setParams(params = {}) {
|
|
if (!enabled || destroyed) return;
|
|
if (params.clear === true || params.stormId === null) {
|
|
selectionIntent = 'cleared';
|
|
select(null);
|
|
notify();
|
|
} else if (
|
|
typeof params.stormId === 'string' &&
|
|
snapshot?.storms.some((storm) => storm.id === params.stormId)
|
|
) {
|
|
selectionIntent = 'user';
|
|
select(params.stormId);
|
|
notify();
|
|
}
|
|
const storm = selected();
|
|
if (params.focus === true && storm && runNavigation) {
|
|
const sphere = rendering.getFocusSphere(storm.id);
|
|
if (sphere) {
|
|
const generation = ++navigationGeneration;
|
|
runNavigation(() => {
|
|
if (
|
|
!enabled ||
|
|
destroyed ||
|
|
generation !== navigationGeneration ||
|
|
selectedId !== storm.id
|
|
)
|
|
return;
|
|
return viewer.camera.flyToBoundingSphere(sphere, {
|
|
duration: matchMedia?.('(prefers-reduced-motion: reduce)')
|
|
?.matches
|
|
? 0
|
|
: 1.4,
|
|
});
|
|
});
|
|
}
|
|
}
|
|
if (params.advisory === true && storm?.advisoryUrl)
|
|
openLink(storm.advisoryUrl);
|
|
},
|
|
getRowControls() {
|
|
const storm = selected();
|
|
const empty =
|
|
snapshot && !snapshot.unavailable && snapshot.storms.length === 0;
|
|
const geometry =
|
|
storm &&
|
|
(coherentCycloneGeometry(storm)
|
|
? 'Track and cone match this advisory'
|
|
: storm.geometryStatus === 'pending'
|
|
? `Track/cone awaiting advisory ${storm.advisoryNumber}`
|
|
: 'Track/cone unavailable');
|
|
const status =
|
|
error ||
|
|
(snapshot?.stale
|
|
? 'Cached advisory · stale source'
|
|
: loading
|
|
? 'Loading advisories…'
|
|
: storm && !coherentCycloneGeometry(storm)
|
|
? geometry
|
|
: null);
|
|
const detail = storm
|
|
? `${storm.name} · ${classificationName(storm.classification)} · Advisory ${storm.advisoryNumber} · issued ${utc(storm.issuedAt)}`
|
|
: empty
|
|
? 'No active NHC/CPHC systems'
|
|
: snapshot?.storms.length
|
|
? `${snapshot.storms.length} active storm${snapshot.storms.length === 1 ? '' : 's'}`
|
|
: loading
|
|
? 'Loading advisories…'
|
|
: 'Advisories unavailable';
|
|
const controls = {
|
|
readout: true,
|
|
summary: {
|
|
label: 'Cyclones · NHC / CPHC',
|
|
coverage: 'Atlantic · E/C Pacific',
|
|
compact: snapshot?.storms.length
|
|
? `${snapshot.storms.length} active storm${snapshot.storms.length === 1 ? '' : 's'}${storm ? ` · ${storm.name} selected` : ''}`
|
|
: detail,
|
|
actions: storm?.advisoryUrl
|
|
? [
|
|
{
|
|
id: 'advisory',
|
|
label: 'Official advisory ↗',
|
|
href: storm.advisoryUrl,
|
|
},
|
|
]
|
|
: [],
|
|
settings: [],
|
|
lines: storm
|
|
? [
|
|
{
|
|
id: 'position',
|
|
text: `Position as of ${utc(storm.positionAt)}`,
|
|
muted: true,
|
|
},
|
|
{
|
|
id: 'intensity',
|
|
text: `Maximum sustained wind: ${number(storm.windKt, 'kt')} · Pressure: ${number(storm.pressureHpa, 'hPa')}`,
|
|
},
|
|
{ id: 'geometry', text: geometry, muted: true },
|
|
]
|
|
: [],
|
|
detail,
|
|
status,
|
|
units: 'kt',
|
|
},
|
|
list: {
|
|
ariaLabel: 'Active NHC and CPHC cyclone advisories',
|
|
items: (snapshot?.storms || []).map((item, index) => ({
|
|
id: item.id,
|
|
ordinal: index + 1,
|
|
lead: item.basin,
|
|
text: `${item.name} · ${classificationName(item.classification)} · ${item.windKt === null ? 'Wind unavailable' : `${item.windKt} kt`}`,
|
|
active: item.id === selectedId,
|
|
params: { stormId: item.id, focus: true },
|
|
})),
|
|
},
|
|
chips: [],
|
|
legend: storm
|
|
? [
|
|
{ label: 'Advisory center / forecast track', color: '#7fe6ed' },
|
|
{ label: 'Center-track uncertainty cone', color: '#7fe6ed44' },
|
|
]
|
|
: [],
|
|
info: storm
|
|
? `${detail}\nPosition as of ${utc(storm.positionAt)}\nMaximum sustained wind: ${number(storm.windKt, 'kt')} · Pressure: ${number(storm.pressureHpa, 'hPa')}\n${geometry}${status && status !== geometry ? '\n' + status : ''}\n${snapshot.coverage}`
|
|
: `${detail}${status ? '\n' + status : ''}\n${snapshot?.coverage || COVERAGE}`,
|
|
infoTitle:
|
|
'Select a storm on the map, or choose a storm in the list to select it and move the camera. Click empty map space to clear the selection. NOAA NHC/CPHC advisory context. The cone describes forecast center-track uncertainty, not storm size or the full hazard area. Forecast point labels are source lead hours, not times computed from advisory issuance. Geometry follows the surface; height is not weather altitude. Consult the official advisory.',
|
|
};
|
|
return controls;
|
|
},
|
|
setRowControlsListener(value) {
|
|
listener = typeof value === 'function' ? value : null;
|
|
},
|
|
/**
|
|
* Snapshot loaded active storms as plain JSON-safe records for the analyst
|
|
* query engine. On demand only (once per spoken query); [] while the
|
|
* layer is off.
|
|
* @param {number} [maxCount=Infinity] Most records to return.
|
|
* @returns {Array<Object>} {id, name, classification, basin, lat, lon, windKt, pressureHpa}.
|
|
*/
|
|
getAnalystRecords(maxCount = Infinity) {
|
|
if (!enabled || destroyed) return [];
|
|
const num = (v) => (Number.isFinite(v) ? v : null);
|
|
const result = [];
|
|
for (const storm of snapshot?.storms || []) {
|
|
if (result.length >= maxCount) break;
|
|
const lat = storm.position?.latitude;
|
|
const lon = storm.position?.longitude;
|
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
|
|
result.push({
|
|
id: storm.name || storm.id,
|
|
name: storm.name || null,
|
|
classification: storm.classification || null,
|
|
basin: storm.basin || null,
|
|
lat,
|
|
lon,
|
|
windKt: num(storm.windKt),
|
|
pressureHpa: num(storm.pressureHpa),
|
|
});
|
|
}
|
|
return result;
|
|
},
|
|
getStats() {
|
|
const storm = selected();
|
|
return {
|
|
count: snapshot?.storms.length || 0,
|
|
lastUpdate: storm
|
|
? Date.parse(storm.issuedAt)
|
|
: snapshot?.fetchedAt || null,
|
|
loading,
|
|
error,
|
|
stale: Boolean(snapshot?.stale),
|
|
source: 'NOAA NHC / CPHC',
|
|
advisoryAt: storm?.issuedAt || null,
|
|
empty: Boolean(
|
|
snapshot && !snapshot.unavailable && !snapshot.storms.length,
|
|
),
|
|
};
|
|
},
|
|
getDiagnostics() {
|
|
return {
|
|
...rendering?.getDiagnostics(),
|
|
enabled,
|
|
loading,
|
|
requestPending: !!request,
|
|
selectionActive: clickHandler !== null,
|
|
selectedId,
|
|
selectionIntent,
|
|
timerActive: false,
|
|
};
|
|
},
|
|
destroy() {
|
|
if (destroyed) return;
|
|
layer.disable();
|
|
destroyed = true;
|
|
rendering?.destroy();
|
|
rendering = null;
|
|
viewer = null;
|
|
listener = null;
|
|
runNavigation = null;
|
|
},
|
|
};
|
|
return layer;
|
|
}
|