mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
Knip, configured with the application, package exports, scripts, QA harnesses, workers, tools and tests as entry points, reports exports that no module imports. Each was checked by hand against string-based and namespace imports. - Symbols that are still used inside their own module lose only the `export` keyword (176 constants and helpers). - Unused re-exports are removed from barrel modules (flight, military, vessel, CCTV, directions, transit history, local ADS-B and street-level policy modules). - Declarations that nothing uses at all are deleted, among them AIS_WATCHDOG_STATUSES, applyFilter, isRadioCountryCode, compactText, DENSITY_STOPS, the unused CCTV endpoint constants, and the SDR band constants. - Default exports that nothing imports are removed. The ones in src/data/earthquakes.js, src/data/telegeographySubmarineCables.js, src/data/wind.js and src/data/bhoteKoshiLocator.js each constructed a layer instance at import time that nothing used; those instances are no longer created. Every export of a package.json entry stays, as does TOMTOM_CREDIT, which the traffic layer reads from the credits namespace it is given.
431 lines
14 KiB
JavaScript
431 lines
14 KiB
JavaScript
/**
|
||
* Point-and-ask: what the cursor is on when the user starts to speak.
|
||
*
|
||
* The tracker listens to pointer movement over the globe canvas and keeps only
|
||
* the latest position plus a dwell anchor — no per-move picking. A pick runs
|
||
* lazily: once when the pointer settles (a dwell) and once at a snapshot, and
|
||
* the snapshot reuses the dwell's pick when the pointer has not moved since.
|
||
*
|
||
* Snapshots are taken when the user decides to ask — Space keydown for
|
||
* push-to-talk, speech start for open mic — not when they finish talking,
|
||
* because people point before or while they speak. A push-to-talk keydown
|
||
* snapshot is HELD and reused by every speech start inside that hold.
|
||
*
|
||
* A snapshot is only a referent when it is FRESH: the pointer is over the
|
||
* canvas (not a panel on top of it), away from the canvas edge, and moved
|
||
* within {@link POINTER_FRESH_MS}. A cursor parked in a corner or left behind
|
||
* on the mic button is not "this".
|
||
*
|
||
* Provider-neutral and Cesium-free: the pick is injected.
|
||
*/
|
||
|
||
/** Movement below this many CSS pixels is still the same dwell. */
|
||
const POINTER_DWELL_PX = 12;
|
||
/** Stillness that makes a dwell point. */
|
||
export const POINTER_DWELL_MS = 250;
|
||
/** A resting pointer stays a referent this long after it last moved. */
|
||
export const POINTER_FRESH_MS = 30000;
|
||
/** Pixels from the canvas edge that count as "parked", never a referent. */
|
||
const POINTER_EDGE_PX = 8;
|
||
/** A dwell pick is reused by a snapshot only while this recent (contacts move). */
|
||
const POINTER_DWELL_REUSE_MS = 1000;
|
||
/**
|
||
* Open mic has no gesture, so the pointer only counts when it moved or
|
||
* settled on the map this recently: a cursor resting there for minutes is
|
||
* not the user pointing.
|
||
*/
|
||
export const POINTER_OPEN_MIC_RECENT_MS = 5000;
|
||
/** A held push-to-talk snapshot serves speech starts this long. */
|
||
export const POINTER_HOLD_MS = 20000;
|
||
|
||
const LABEL_MAX = 48;
|
||
|
||
function round(value, digits) {
|
||
const scale = 10 ** digits;
|
||
return Math.round(value * scale) / scale;
|
||
}
|
||
|
||
/** A real number, or null. `Number(null)` is 0, so empty values are rejected first. */
|
||
export function finiteOrNull(value) {
|
||
if (value === null || value === undefined || value === '') return null;
|
||
const number = Number(value);
|
||
return Number.isFinite(number) ? number : null;
|
||
}
|
||
const finite = finiteOrNull;
|
||
|
||
/**
|
||
* Camera pose identity: position to 1 m, orientation to about 0.06 degrees.
|
||
* A pixel read under one key names nothing under another. Reads plain
|
||
* numbers only, so it needs no Cesium import.
|
||
* @param {object} viewer Cesium viewer.
|
||
* @returns {string|null}
|
||
*/
|
||
export function cameraPoseKey(viewer) {
|
||
const camera = viewer?.camera;
|
||
const p = camera?.positionWC;
|
||
if (!p || !Number.isFinite(p.x)) return null;
|
||
const angle = (value) => Math.round((Number(value) || 0) * 1000);
|
||
return [
|
||
Math.round(p.x),
|
||
Math.round(p.y),
|
||
Math.round(p.z),
|
||
angle(camera.heading),
|
||
angle(camera.pitch),
|
||
angle(camera.roll),
|
||
].join(',');
|
||
}
|
||
|
||
/** Bound and de-control a label before it goes to the model or the card. */
|
||
function pointerLabel(value, max = LABEL_MAX) {
|
||
const clean = String(value ?? '')
|
||
.replace(/[\p{Cc}\p{Cf}]+/gu, ' ')
|
||
.replace(/\s+/g, ' ')
|
||
.trim();
|
||
return clean.length <= max ? clean : `${clean.slice(0, max - 1).trimEnd()}…`;
|
||
}
|
||
|
||
/**
|
||
* Build a snapshot from one pick. A pick with neither an entity nor a ground
|
||
* point (open sky, a failed pick) is not a referent.
|
||
* @param {object} input
|
||
* @returns {object} Snapshot; `fresh` false when there is nothing to name.
|
||
*/
|
||
export function buildPointerSnapshot({
|
||
at,
|
||
x,
|
||
y,
|
||
width,
|
||
height,
|
||
pick,
|
||
capturedAt = Date.now(),
|
||
}) {
|
||
const cameraKey = pick?.cameraKey ?? null;
|
||
const entity = pick?.entity?.layerId
|
||
? {
|
||
layerId: String(pick.entity.layerId),
|
||
id: String(pick.entity.id ?? ''),
|
||
label: pointerLabel(pick.entity.label || pick.entity.id),
|
||
kind: String(pick.entity.kind || 'feature'),
|
||
}
|
||
: null;
|
||
const entityLat = finite(pick?.entity?.lat);
|
||
const entityLon = finite(pick?.entity?.lon);
|
||
const groundLat = finite(pick?.ground?.lat);
|
||
const groundLon = finite(pick?.ground?.lon);
|
||
const hasEntityPosition = entityLat !== null && entityLon !== null;
|
||
const hasGround = groundLat !== null && groundLon !== null;
|
||
const lat = hasEntityPosition ? entityLat : hasGround ? groundLat : null;
|
||
const lon = hasEntityPosition ? entityLon : hasGround ? groundLon : null;
|
||
const target = entity ? 'entity' : hasGround ? 'ground' : 'sky';
|
||
return {
|
||
fresh: target !== 'sky',
|
||
at,
|
||
capturedAt,
|
||
cameraKey,
|
||
target,
|
||
entity,
|
||
lat: lat === null ? null : round(lat, 5),
|
||
lon: lon === null ? null : round(lon, 5),
|
||
heightM: finite(pick?.ground?.heightM),
|
||
radiusKm: finite(pick?.radiusKm),
|
||
screen: {
|
||
x: width ? round(x / width, 3) : null,
|
||
y: height ? round(y / height, 3) : null,
|
||
},
|
||
screenPx: { x: Math.round(x), y: Math.round(y) },
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Compact, inert context item for the model (about 60–100 tokens). Labels are
|
||
* JSON data, never prose, so a callsign or place name cannot carry an
|
||
* instruction.
|
||
* @param {object|null} snapshot
|
||
* @returns {object|null} Item, or null for a stale snapshot.
|
||
*/
|
||
export function pointerContextItem(snapshot) {
|
||
if (!snapshot?.fresh) return null;
|
||
const item = { type: 'pointer_context', at: snapshot.at };
|
||
if (snapshot.entity) {
|
||
item.target = {
|
||
kind: snapshot.entity.kind,
|
||
layer: snapshot.entity.layerId,
|
||
label: snapshot.entity.label,
|
||
};
|
||
}
|
||
if (snapshot.lat !== null && snapshot.lon !== null) {
|
||
item.ground = { lat: round(snapshot.lat, 4), lon: round(snapshot.lon, 4) };
|
||
}
|
||
if (snapshot.screen?.x !== null) item.screen = snapshot.screen;
|
||
return item;
|
||
}
|
||
|
||
/** {@link pointerContextItem} serialized; null for a stale snapshot. */
|
||
export function formatPointerContext(snapshot) {
|
||
const item = pointerContextItem(snapshot);
|
||
return item ? JSON.stringify(item) : null;
|
||
}
|
||
|
||
/**
|
||
* What the voice card shows "this" resolved to.
|
||
* @param {object|null} snapshot
|
||
* @returns {{kind: 'this'|'here', label: string}|null}
|
||
*/
|
||
export function pointerChip(snapshot) {
|
||
if (!snapshot?.fresh) return null;
|
||
if (snapshot.entity)
|
||
return { kind: 'this', label: pointerLabel(snapshot.entity.label, 32) };
|
||
if (snapshot.lat === null || snapshot.lon === null) return null;
|
||
return {
|
||
kind: 'here',
|
||
label: `${snapshot.lat.toFixed(2)}, ${snapshot.lon.toFixed(2)}`,
|
||
};
|
||
}
|
||
|
||
/** Dedupe key of the clearing pointer_context item. */
|
||
export const POINTER_NONE_KEY = 'none';
|
||
|
||
/**
|
||
* The voice-card event for a turn's pointer: a gesture reveals its target at
|
||
* once; open-mic and typed turns reveal it only through announcePointer.
|
||
* @param {object|null} snapshot
|
||
* @param {boolean} gesture
|
||
* @returns {{announced: object|null, pointer: object|null}}
|
||
*/
|
||
export function pointerTurnEvent(snapshot, gesture) {
|
||
const chip = gesture ? pointerChip(snapshot) : null;
|
||
return chip
|
||
? { announced: snapshot, pointer: { ...chip, screenPx: snapshot.screenPx } }
|
||
: { announced: null, pointer: null };
|
||
}
|
||
|
||
/**
|
||
* The pointer_context item a turn should send, or null when there is nothing
|
||
* new. Without a gesture the model learns only what kind of thing is pointed
|
||
* at, never which. Losing a sent pointer produces one clearing item.
|
||
* @param {object|null} snapshot
|
||
* @param {{at: string, gesture: boolean, sentKey: string|null}} turn
|
||
* @returns {{item: object, key: string}|null}
|
||
*/
|
||
export function pointerTurnContext(snapshot, { at, gesture, sentKey }) {
|
||
const chip = pointerChip(snapshot);
|
||
const item = !chip
|
||
? sentKey && sentKey !== POINTER_NONE_KEY
|
||
? { type: 'pointer_context', at, target: 'none' }
|
||
: null
|
||
: gesture
|
||
? pointerContextItem(snapshot)
|
||
: {
|
||
type: 'pointer_context',
|
||
at,
|
||
pointing: snapshot.entity?.kind || 'ground',
|
||
};
|
||
if (!item) return null;
|
||
const { at: _at, ...content } = item;
|
||
const key = chip ? JSON.stringify(content) : POINTER_NONE_KEY;
|
||
return key === sentKey ? null : { item, key };
|
||
}
|
||
|
||
/**
|
||
* The voice-card event revealing a pointer a tool resolved, once per snapshot.
|
||
* @param {object|null} snapshot
|
||
* @param {object|null} announced Snapshot already shown.
|
||
* @returns {object|null}
|
||
*/
|
||
export function pointerAnnouncement(snapshot, announced) {
|
||
const chip = pointerChip(snapshot);
|
||
return chip && snapshot !== announced
|
||
? { ...chip, screenPx: snapshot.screenPx }
|
||
: null;
|
||
}
|
||
|
||
/**
|
||
* Track the pointer over one element (the globe canvas).
|
||
* @param {object} options
|
||
* @param {HTMLElement} options.element Canvas to observe.
|
||
* @param {(x: number, y: number) => object|null} options.pick Lazy pick at
|
||
* element-relative CSS pixels: `{entity?, ground?, radiusKm?}`.
|
||
* @param {() => number} [options.now] Monotonic clock (ms).
|
||
* @param {() => string|null} [options.cameraKey] Camera identity; a dwell
|
||
* pick taken under another camera pose is never reused.
|
||
* @param {Function} [options.setTimer]
|
||
* @param {Function} [options.clearTimer]
|
||
*/
|
||
export function createPointerTracker({
|
||
element,
|
||
pick,
|
||
now = () => performance.now(),
|
||
cameraKey = () => null,
|
||
setTimer = (fn, ms) => setTimeout(fn, ms),
|
||
clearTimer = (id) => clearTimeout(id),
|
||
}) {
|
||
let inside = false;
|
||
let x = 0;
|
||
let y = 0;
|
||
let lastMoveAt = -Infinity;
|
||
let anchor = null;
|
||
let dwell = null;
|
||
let dwellTimer = null;
|
||
let held = null;
|
||
let active = null;
|
||
let destroyed = false;
|
||
const listeners = new AbortController();
|
||
|
||
function size() {
|
||
return {
|
||
width: element?.clientWidth || element?.width || 0,
|
||
height: element?.clientHeight || element?.height || 0,
|
||
};
|
||
}
|
||
|
||
function safePick(px, py) {
|
||
try {
|
||
return pick?.(px, py) || null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function cancelDwell() {
|
||
if (dwellTimer === null) return;
|
||
clearTimer(dwellTimer);
|
||
dwellTimer = null;
|
||
}
|
||
|
||
function moveTo(px, py) {
|
||
inside = true;
|
||
x = px;
|
||
y = py;
|
||
lastMoveAt = now();
|
||
if (anchor && Math.hypot(px - anchor.x, py - anchor.y) < POINTER_DWELL_PX)
|
||
return;
|
||
anchor = { x: px, y: py };
|
||
dwell = null;
|
||
cancelDwell();
|
||
dwellTimer = setTimer(() => {
|
||
dwellTimer = null;
|
||
if (destroyed || !inside || !anchor) return;
|
||
dwell = { x, y, at: now(), camera: cameraKey(), pick: safePick(x, y) };
|
||
}, POINTER_DWELL_MS);
|
||
}
|
||
|
||
function onMove(event) {
|
||
const rect = element.getBoundingClientRect?.() || { left: 0, top: 0 };
|
||
moveTo(event.clientX - rect.left, event.clientY - rect.top);
|
||
}
|
||
|
||
function onLeave() {
|
||
inside = false;
|
||
anchor = null;
|
||
dwell = null;
|
||
cancelDwell();
|
||
}
|
||
|
||
if (element?.addEventListener) {
|
||
const options = { passive: true, signal: listeners.signal };
|
||
element.addEventListener('pointermove', onMove, options);
|
||
element.addEventListener('pointerdown', onMove, options);
|
||
element.addEventListener('pointerleave', onLeave, options);
|
||
}
|
||
|
||
/** Is the pointer a referent right now? */
|
||
function isFresh(recentMs = POINTER_FRESH_MS) {
|
||
if (destroyed || !inside) return false;
|
||
if (now() - lastMoveAt > Math.min(recentMs, POINTER_FRESH_MS)) return false;
|
||
const { width, height } = size();
|
||
if (!width || !height) return false;
|
||
return (
|
||
x >= POINTER_EDGE_PX &&
|
||
y >= POINTER_EDGE_PX &&
|
||
x <= width - POINTER_EDGE_PX &&
|
||
y <= height - POINTER_EDGE_PX
|
||
);
|
||
}
|
||
|
||
function capture(at, { recentMs = POINTER_FRESH_MS } = {}) {
|
||
if (!isFresh(recentMs)) return { fresh: false, at };
|
||
const { width, height } = size();
|
||
const reuse =
|
||
dwell &&
|
||
dwell.x === x &&
|
||
dwell.y === y &&
|
||
dwell.pick &&
|
||
now() - dwell.at <= POINTER_DWELL_REUSE_MS &&
|
||
dwell.camera === cameraKey()
|
||
? dwell.pick
|
||
: null;
|
||
return buildPointerSnapshot({
|
||
at,
|
||
x,
|
||
y,
|
||
width,
|
||
height,
|
||
pick: reuse || safePick(x, y),
|
||
});
|
||
}
|
||
|
||
return {
|
||
element,
|
||
capture,
|
||
isFresh,
|
||
/** Push-to-talk keydown: snapshot now, keep it for the hold. */
|
||
hold(at = 'keydown') {
|
||
held = { snapshot: capture(at), heldAt: now() };
|
||
return held.snapshot;
|
||
},
|
||
/** The Space gesture ended (tap, release or blur): its snapshot goes too. */
|
||
releaseHold() {
|
||
held = null;
|
||
},
|
||
/** Is a push-to-talk keydown snapshot held for the current gesture? */
|
||
isHeld() {
|
||
return Boolean(held && now() - held.heldAt <= POINTER_HOLD_MS);
|
||
},
|
||
/**
|
||
* Start a user turn. Spoken turns inside a Space hold reuse its keydown
|
||
* snapshot; typed turns and open-mic speech always read the pointer now
|
||
* (open mic only when it moved recently: `recentMs`). The result becomes
|
||
* the turn's referent (null when stale).
|
||
*/
|
||
beginTurn(at, { recentMs = POINTER_FRESH_MS, capturedSnapshot } = {}) {
|
||
// Delayed admitted audio keeps its capture's pointer without replacing a
|
||
// newer physical hold. Undefined preserves existing caller behavior.
|
||
if (capturedSnapshot !== undefined) {
|
||
const snapshot = destroyed ? { fresh: false, at } : capturedSnapshot;
|
||
active = snapshot?.fresh ? snapshot : null;
|
||
return snapshot;
|
||
}
|
||
const reuse =
|
||
at !== 'text' && held && now() - held.heldAt <= POINTER_HOLD_MS;
|
||
const snapshot = reuse ? held.snapshot : capture(at, { recentMs });
|
||
if (!reuse && at !== 'text') held = null;
|
||
active = snapshot?.fresh ? snapshot : null;
|
||
return snapshot;
|
||
},
|
||
/** The current turn's referent, or null. */
|
||
activeSnapshot() {
|
||
return active;
|
||
},
|
||
/** Test and harness hook: place the pointer at element pixels. */
|
||
moveTo(px, py) {
|
||
if (!destroyed) moveTo(Number(px), Number(py));
|
||
},
|
||
/**
|
||
* Session end: forget every snapshot. A Space hold that is starting the
|
||
* next session keeps its keydown snapshot.
|
||
*/
|
||
clear({ keepHold = false } = {}) {
|
||
if (!keepHold) held = null;
|
||
active = null;
|
||
},
|
||
destroy() {
|
||
if (destroyed) return;
|
||
destroyed = true;
|
||
cancelDwell();
|
||
listeners.abort();
|
||
held = null;
|
||
active = null;
|
||
},
|
||
};
|
||
}
|