mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
One provenanceLine() builds STATE | text | LAYERS for the HUD and get_hud_caption. hasWord is one lookaround regex, capAtWord one grapheme pass. Feed-state helpers are imported from layerSnapshot.js directly, and the kept-whole sanitizer cases are one table.
319 lines
10 KiB
JavaScript
319 lines
10 KiB
JavaScript
import { layerFeedState } from './feedState.js';
|
|
/**
|
|
* Shared layer snapshot envelope for voice and HUD answers.
|
|
*
|
|
* Data Layers chips, `analyst_query`, `get_current_view_state`, and the HUD
|
|
* summary must all name the same feed-state. This module is the one envelope:
|
|
* it classifies `getStats()` through `layerFeedState` and stamps the result
|
|
* so a stale or degraded feed cannot be narrated as live. Cesium-free so the
|
|
* HUD summary proxy can import it from Node.
|
|
*
|
|
* @module data/layerSnapshot
|
|
*/
|
|
|
|
/** Feed states the Data Layers chips and voice/HUD provenance share. */
|
|
export const LAYER_FEED_STATES = Object.freeze([
|
|
'nominal',
|
|
'loading',
|
|
'degraded',
|
|
'stale',
|
|
'partial',
|
|
'fallback',
|
|
'unavailable',
|
|
]);
|
|
|
|
/** Chip labels for enabled layers. Disabled layers read OFF, not a feed-state. */
|
|
export const LAYER_FEED_STATE_LABELS = Object.freeze({
|
|
nominal: 'ON',
|
|
loading: 'LOADING',
|
|
degraded: 'DEGRADED',
|
|
stale: 'STALE',
|
|
partial: 'PARTIAL',
|
|
fallback: 'FALLBACK',
|
|
unavailable: 'UNAVAILABLE',
|
|
});
|
|
|
|
/** Snapshot feed-state including a disabled layer. */
|
|
export const SNAPSHOT_FEED_STATES = Object.freeze([
|
|
...LAYER_FEED_STATES,
|
|
'off',
|
|
]);
|
|
|
|
/**
|
|
* Lower rank is more severe. Voice/HUD `overall` is the worst queried state
|
|
* so a mixed roster cannot hide an unavailable or stale feed behind a nominal
|
|
* neighbour.
|
|
*/
|
|
export const FEED_STATE_SEVERITY = Object.freeze({
|
|
unavailable: 0,
|
|
loading: 1,
|
|
degraded: 2,
|
|
stale: 3,
|
|
fallback: 4,
|
|
partial: 5,
|
|
nominal: 6,
|
|
off: 7,
|
|
});
|
|
|
|
export { layerFeedState } from './feedState.js';
|
|
|
|
function snapshotError(stats) {
|
|
const err = stats?.error || stats?.lastError || stats?.managerRefreshError;
|
|
if (err == null || err === '') return null;
|
|
return err instanceof Error ? err.message : String(err);
|
|
}
|
|
|
|
/**
|
|
* Age of a lastUpdate timestamp in whole seconds.
|
|
* @param {number|null|undefined} lastUpdate Epoch ms.
|
|
* @param {number} [now=Date.now()] Clock for tests.
|
|
* @returns {number|null} Non-negative seconds, or null when unknown.
|
|
*/
|
|
export function snapshotAgeSec(lastUpdate, now = Date.now()) {
|
|
const stamp = Number(lastUpdate);
|
|
if (!Number.isFinite(stamp) || stamp <= 0) return null;
|
|
return Math.max(0, Math.floor((now - stamp) / 1000));
|
|
}
|
|
|
|
/**
|
|
* Compact age label matching the Data Layers meta line.
|
|
* @param {number|null|undefined} lastUpdate Epoch ms.
|
|
* @param {number} [now=Date.now()] Clock for tests.
|
|
* @returns {string|null} `just now` / `12s ago` / `4m ago` / `2h ago`.
|
|
*/
|
|
export function snapshotAgeLabel(lastUpdate, now = Date.now()) {
|
|
const sec = snapshotAgeSec(lastUpdate, now);
|
|
if (sec === null) return null;
|
|
if (sec < 5) return 'just now';
|
|
if (sec < 60) return `${sec}s ago`;
|
|
if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
|
|
return `${Math.floor(sec / 3600)}h ago`;
|
|
}
|
|
|
|
/**
|
|
* Pick the most severe feed-state from a list.
|
|
* @param {Iterable<string|null|undefined>} states Feed-state strings.
|
|
* @returns {'nominal'|'loading'|'degraded'|'stale'|'partial'|'fallback'|'unavailable'|'off'|null}
|
|
*/
|
|
export function worstFeedState(states) {
|
|
let worst = null;
|
|
let worstRank = Number.POSITIVE_INFINITY;
|
|
for (const state of states || []) {
|
|
if (!Object.hasOwn(FEED_STATE_SEVERITY, state)) continue;
|
|
const rank = FEED_STATE_SEVERITY[state];
|
|
if (rank < worstRank) {
|
|
worst = state;
|
|
worstRank = rank;
|
|
}
|
|
}
|
|
return worst;
|
|
}
|
|
|
|
/**
|
|
* One honest layer snapshot for voice/HUD narration.
|
|
*
|
|
* Disabled layers report `feedState: 'off'` — the same reading the chips use —
|
|
* even if leftover stats would otherwise classify as stale. Counts and errors
|
|
* still travel so a disabled layer with a last-known error is inspectable.
|
|
*
|
|
* @param {object} [layer] `DataLayerManager.getAll()` projection, or a layer-like
|
|
* object with `id`, `enabled`, `stats`, and optional `source`/`name`.
|
|
* @param {{ now?: number }} [options]
|
|
* @returns {{
|
|
* id: string|null,
|
|
* name: string|null,
|
|
* enabled: boolean,
|
|
* feedState: string,
|
|
* source: string|null,
|
|
* count: number,
|
|
* lastUpdate: number|null,
|
|
* ageSec: number|null,
|
|
* ageLabel: string|null,
|
|
* error: string|null,
|
|
* }}
|
|
*/
|
|
export function layerSnapshot(layer = {}, { now = Date.now() } = {}) {
|
|
const stats = (layer && typeof layer.stats === 'object' && layer.stats) || {};
|
|
const source = String(stats.source || layer.source || '').trim() || null;
|
|
const enabled = Boolean(layer.enabled);
|
|
const countRaw = Number(stats.count);
|
|
const count = Number.isFinite(countRaw) ? countRaw : 0;
|
|
const lastUpdate = Number.isFinite(Number(stats.lastUpdate))
|
|
? Number(stats.lastUpdate)
|
|
: null;
|
|
const feedState = enabled ? layerFeedState({ ...stats, source }) : 'off';
|
|
return {
|
|
id: layer.id || layer.layerKey || null,
|
|
name: layer.name || layer.label || layer.id || layer.layerKey || null,
|
|
enabled,
|
|
feedState,
|
|
source,
|
|
count,
|
|
lastUpdate,
|
|
ageSec: snapshotAgeSec(lastUpdate, now),
|
|
ageLabel: snapshotAgeLabel(lastUpdate, now),
|
|
error: snapshotError(stats),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Snapshot every layer in a roster.
|
|
* @param {Array<object>} [layers]
|
|
* @param {{ now?: number }} [options]
|
|
* @returns {Array<object>}
|
|
*/
|
|
export function layerSnapshots(layers = [], options) {
|
|
if (!Array.isArray(layers)) return [];
|
|
return layers.map((layer) => layerSnapshot(layer, options));
|
|
}
|
|
|
|
function asSnapshot(entry, now) {
|
|
if (!entry || typeof entry !== 'object') return layerSnapshot({}, { now });
|
|
if (typeof entry.feedState === 'string') return entry;
|
|
return layerSnapshot(entry, { now });
|
|
}
|
|
|
|
/**
|
|
* Mechanical narration note so the model does not have to infer honesty.
|
|
* @param {Array<object>} layers Snapshots.
|
|
* @param {string|null} overall Worst considered feed-state.
|
|
* @returns {string}
|
|
*/
|
|
export function feedProvenanceNote(layers = [], overall = null) {
|
|
if (!overall) {
|
|
return 'No layer snapshot is attached. Do not invent a feed-state or a count.';
|
|
}
|
|
if (overall === 'nominal') {
|
|
return 'Queried enabled layers are nominal. Still name the count scope; do not claim whole-world coverage.';
|
|
}
|
|
if (overall === 'off') {
|
|
const names =
|
|
layers
|
|
.map((s) => s.name || s.id)
|
|
.filter(Boolean)
|
|
.join(', ') || 'the requested layer';
|
|
return `${names} is off. Do not invent a count; say the layer is off and offer to enable it.`;
|
|
}
|
|
const flagged = layers.filter(
|
|
(s) => s.feedState && s.feedState !== 'nominal' && s.feedState !== 'off',
|
|
);
|
|
const bits = flagged.map((s) => {
|
|
const extras = [
|
|
s.source,
|
|
s.ageLabel || snapshotAgeLabel(s.lastUpdate),
|
|
s.error,
|
|
]
|
|
.filter(Boolean)
|
|
.join(', ');
|
|
return `${s.name || s.id} is ${String(s.feedState).toUpperCase()}${extras ? ` (${extras})` : ''}`;
|
|
});
|
|
const body = bits.join('; ') || `${String(overall).toUpperCase()} feed`;
|
|
if (overall === 'partial')
|
|
return `${body}. Narrate that coverage is incomplete with any count; do not claim full coverage.`;
|
|
return `${body}. Narrate that provenance with any count; do not describe this as live.`;
|
|
}
|
|
|
|
/**
|
|
* Shared envelope stamped onto analyst, view-state, and HUD payloads.
|
|
* @param {Array<object>} [snapshots] Snapshots or raw `getAll()` rows.
|
|
* @param {{ now?: number }} [options]
|
|
* @returns {{ overall: string|null, layers: Array<object>, note: string }}
|
|
*/
|
|
export function feedProvenanceEnvelope(
|
|
snapshots = [],
|
|
{ now = Date.now() } = {},
|
|
) {
|
|
const layers = (Array.isArray(snapshots) ? snapshots : []).map((entry) =>
|
|
asSnapshot(entry, now),
|
|
);
|
|
const active = layers.filter((s) => s.feedState && s.feedState !== 'off');
|
|
const overall =
|
|
worstFeedState(active.map((s) => s.feedState)) ||
|
|
(layers.length ? 'off' : null);
|
|
return {
|
|
overall,
|
|
layers,
|
|
note: feedProvenanceNote(layers, overall),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Compact voice-tool layer row: identity + the fields a model must read.
|
|
* @param {object} layer Raw `getAll()` row or snapshot.
|
|
* @param {{ now?: number }} [options]
|
|
* @returns {object}
|
|
*/
|
|
export function viewStateLayerRow(layer, options) {
|
|
const snap = layerSnapshot(layer, options);
|
|
return {
|
|
id: snap.id,
|
|
name: snap.name,
|
|
enabled: snap.enabled,
|
|
count: snap.count,
|
|
error: snap.error,
|
|
feedState: snap.feedState,
|
|
source: snap.source,
|
|
lastUpdate: snap.lastUpdate,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The feed state and up to two layer names of a provenance envelope that is
|
|
* not nominal, or null; the worst layers come first, so the names match the
|
|
* state. Values stay English (`state` is a feed-state id, `names` are
|
|
* `layer.name`); the HUD translates them for display.
|
|
* @param {{ overall?: string|null, layers?: Array<object> }|null} envelope
|
|
* @returns {{ state: string, names: string[] }|null}
|
|
*/
|
|
export function provenanceTag(envelope) {
|
|
const overall = envelope?.overall;
|
|
if (!overall || overall === 'nominal' || overall === 'off') return null;
|
|
const names = (envelope.layers || [])
|
|
.filter(
|
|
(s) => s.feedState && s.feedState !== 'nominal' && s.feedState !== 'off',
|
|
)
|
|
.sort(
|
|
(a, b) =>
|
|
FEED_STATE_SEVERITY[a.feedState] - FEED_STATE_SEVERITY[b.feedState],
|
|
)
|
|
.map((s) => String(s.name || s.id || 'LAYER'))
|
|
.slice(0, 2);
|
|
return { state: overall, names };
|
|
}
|
|
|
|
const upperCase = (word) => word.toUpperCase();
|
|
|
|
/**
|
|
* `STATE | text | LAYERS`: the state leads so a narrow readout never clips
|
|
* it; the layer names trail so they never push out the text.
|
|
* @param {string} text
|
|
* @param {{ state: string, names: string[] }|null} tag From `provenanceTag`.
|
|
* @param {(state: string) => string} [stateLabel]
|
|
* @param {(name: string) => string} [layerLabel]
|
|
* @returns {string}
|
|
*/
|
|
export function provenanceLine(
|
|
text,
|
|
tag,
|
|
stateLabel = upperCase,
|
|
layerLabel = upperCase,
|
|
) {
|
|
if (!tag) return text;
|
|
return [stateLabel(tag.state), text, tag.names.map(layerLabel).join('/')]
|
|
.filter(Boolean)
|
|
.join(' | ');
|
|
}
|
|
|
|
/**
|
|
* Require a matching feedState for warning states: a view that is not
|
|
* nominal falls back on a missing or different state.
|
|
* @param {{ feedState?: string|null }|null} response The proxy's reply.
|
|
* @param {{ overall?: string|null }|null} provenance The context's envelope.
|
|
* @returns {boolean}
|
|
*/
|
|
export function hudSummaryMatchesProvenance(response, provenance) {
|
|
const state = provenance?.overall;
|
|
if (!state || state === 'nominal' || state === 'off') return true;
|
|
return response?.feedState === state;
|
|
}
|