Files
gods-eye-view/src/voice/pointerContext.js
T
Sameh Khamis 73e789489b chore: drop exports nothing imports
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.
2026-10-22 19:00:40 -07:00

431 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
},
};
}