mirror of
https://github.com/ruvnet/RuView
synced 2026-08-04 19:31:42 +00:00
feat(nvsim): full simulator stack — Rust crate, dashboard, server, App Store, Ghost Murmur [ADR-089/090/091/092/093]
Squashed merge of feat/nvsim-pipeline-simulator (29 commits). ## Shipped - ADR-089 nvsim crate (Accepted) — 50/50 tests, ~4.5 M samples/s, pinned witness cc8de9b01b0ff5bd… - ADR-092 dashboard implementation (Implemented) — 8/12 §11 gates ✅, 4/12 ⚠ (external infra) - ADR-093 dashboard gap analysis (Implemented) — 21/21 catalogued gaps closed - Plus ADR-090 (proposed conditional) and ADR-091 (proposed research-only) ## Live deploy https://ruvnet.github.io/RuView/nvsim/ ## Infra - nvsim-server Dockerfile + GHCR publish workflow (.github/workflows/nvsim-server-docker.yml) - axe-core + Playwright cross-browser CI (.github/workflows/dashboard-a11y.yml) - gh-pages auto-deploy workflow already in place (preserves observatory + pose-fusion siblings) Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
/* In-browser simulated runtimes for App Store apps.
|
||||
*
|
||||
* Each runtime takes the most recent nvsim MagFrame + a short rolling
|
||||
* history and decides whether to emit one or more app events. Outputs are
|
||||
* illustrative: nvsim produces magnetic-field samples, the wasm-edge
|
||||
* algorithms expect WiFi CSI subcarriers — different physical modalities.
|
||||
* The simulated runtime preserves *event-emission semantics* (the same
|
||||
* i32 event IDs, the same trigger logic shape) so users can see the
|
||||
* cards working without an ESP32 mesh.
|
||||
*
|
||||
* For engineering-grade output, deploy the real `wifi-densepose-wasm-edge`
|
||||
* crate to ESP32 firmware over the WS transport — see ADR-040 / ADR-092 §6.2.
|
||||
*/
|
||||
|
||||
import type { MagFrameRecord } from '../transport/NvsimClient';
|
||||
|
||||
export interface AppEvent {
|
||||
/** Wall-clock timestamp (ms). */
|
||||
ts: number;
|
||||
/** App id that emitted. */
|
||||
appId: string;
|
||||
/** i32 event id from `event_types` mod in wifi-densepose-wasm-edge. */
|
||||
eventId: number;
|
||||
/** Human-readable event name (matches the constant name). */
|
||||
eventName: string;
|
||||
/** Numeric value the app reports (units app-specific). */
|
||||
value: number;
|
||||
/** Optional extra context for the console line. */
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface AppRuntimeContext {
|
||||
frame: MagFrameRecord;
|
||||
bMagT: number;
|
||||
bRecoveredT: [number, number, number];
|
||||
/** Rolling history of |B| in T. Most recent last. */
|
||||
bHistory: number[];
|
||||
/** Time since the runtime was activated (s). */
|
||||
elapsedS: number;
|
||||
/** Per-app scratch state — runtimes can persist counters here. */
|
||||
state: Record<string, number>;
|
||||
}
|
||||
|
||||
export type AppRuntimeFn = (ctx: AppRuntimeContext) => AppEvent | AppEvent[] | null;
|
||||
|
||||
/** Welford-style running-stat helper. */
|
||||
function rollingMean(arr: number[]): number {
|
||||
if (arr.length === 0) return 0;
|
||||
let s = 0;
|
||||
for (const v of arr) s += v;
|
||||
return s / arr.length;
|
||||
}
|
||||
function rollingStd(arr: number[]): number {
|
||||
if (arr.length < 2) return 0;
|
||||
const m = rollingMean(arr);
|
||||
let s = 0;
|
||||
for (const v of arr) s += (v - m) * (v - m);
|
||||
return Math.sqrt(s / (arr.length - 1));
|
||||
}
|
||||
|
||||
/** vital_trend — periodic 1-Hz HR/BR estimate from the B_z oscillation. */
|
||||
const vitalTrend: AppRuntimeFn = (ctx) => {
|
||||
if (ctx.bHistory.length < 64) return null;
|
||||
const last = ctx.state['lastEmitS'] ?? 0;
|
||||
if (ctx.elapsedS - last < 1.0) return null;
|
||||
ctx.state['lastEmitS'] = ctx.elapsedS;
|
||||
|
||||
// Crude HR estimate: count zero-crossings of detrended B_z over the last
|
||||
// 64 samples; treat each crossing pair as one cardiac cycle.
|
||||
const tail = ctx.bHistory.slice(-64);
|
||||
const m = rollingMean(tail);
|
||||
let crossings = 0;
|
||||
for (let i = 1; i < tail.length; i++) {
|
||||
if ((tail[i] - m) * (tail[i - 1] - m) < 0) crossings++;
|
||||
}
|
||||
// 64 samples ≈ 0.65 s at the worker's 32-frame batches × 16 ms tick.
|
||||
const cycles = crossings / 2;
|
||||
const hr = Math.max(40, Math.min(180, Math.round((cycles / 0.65) * 60)));
|
||||
const br = Math.max(8, Math.min(30, Math.round(hr / 4))); // crude proxy
|
||||
|
||||
const evs: AppEvent[] = [
|
||||
{ ts: Date.now(), appId: 'vital_trend', eventId: 100, eventName: 'VITAL_TREND', value: hr, detail: `HR≈${hr} BPM, BR≈${br} br/min` },
|
||||
];
|
||||
if (hr < 60) evs.push({ ts: Date.now(), appId: 'vital_trend', eventId: 103, eventName: 'BRADYCARDIA', value: hr, detail: `HR=${hr} BPM` });
|
||||
else if (hr > 100) evs.push({ ts: Date.now(), appId: 'vital_trend', eventId: 104, eventName: 'TACHYCARDIA', value: hr, detail: `HR=${hr} BPM` });
|
||||
if (br < 12) evs.push({ ts: Date.now(), appId: 'vital_trend', eventId: 101, eventName: 'BRADYPNEA', value: br, detail: `BR=${br} br/min` });
|
||||
else if (br > 24) evs.push({ ts: Date.now(), appId: 'vital_trend', eventId: 102, eventName: 'TACHYPNEA', value: br, detail: `BR=${br} br/min` });
|
||||
return evs;
|
||||
};
|
||||
|
||||
/** occupancy — variance threshold on |B| over a 5-second window. */
|
||||
const occupancy: AppRuntimeFn = (ctx) => {
|
||||
if (ctx.bHistory.length < 32) return null;
|
||||
const last = ctx.state['lastEmitS'] ?? 0;
|
||||
if (ctx.elapsedS - last < 2.0) return null;
|
||||
const std = rollingStd(ctx.bHistory.slice(-128)) * 1e9; // T → nT
|
||||
const occupied = std > 0.01; // empirical threshold for the demo
|
||||
const wasOccupied = (ctx.state['occ'] ?? 0) > 0.5;
|
||||
if (occupied !== wasOccupied) {
|
||||
ctx.state['occ'] = occupied ? 1 : 0;
|
||||
ctx.state['lastEmitS'] = ctx.elapsedS;
|
||||
return {
|
||||
ts: Date.now(),
|
||||
appId: 'occupancy',
|
||||
eventId: occupied ? 300 : 302,
|
||||
eventName: occupied ? 'ZONE_OCCUPIED' : 'ZONE_TRANSITION',
|
||||
value: std,
|
||||
detail: occupied ? `σ(|B|)=${std.toFixed(3)} nT — entered` : `σ(|B|)=${std.toFixed(3)} nT — left`,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/** intrusion — |B| above ambient + dwell timer. */
|
||||
const intrusion: AppRuntimeFn = (ctx) => {
|
||||
const ambient = ctx.state['ambient'] ?? ctx.bMagT;
|
||||
ctx.state['ambient'] = 0.95 * ambient + 0.05 * ctx.bMagT;
|
||||
const exceeds = ctx.bMagT > ambient * 1.5 && ctx.bMagT > 1e-12;
|
||||
const dwellStart = ctx.state['dwellStart'] ?? 0;
|
||||
if (exceeds && dwellStart === 0) {
|
||||
ctx.state['dwellStart'] = ctx.elapsedS;
|
||||
} else if (!exceeds) {
|
||||
ctx.state['dwellStart'] = 0;
|
||||
}
|
||||
if (exceeds && dwellStart > 0 && ctx.elapsedS - dwellStart > 0.5 && (ctx.state['lastEmitS'] ?? 0) < dwellStart) {
|
||||
ctx.state['lastEmitS'] = ctx.elapsedS;
|
||||
return {
|
||||
ts: Date.now(),
|
||||
appId: 'intrusion',
|
||||
eventId: 200,
|
||||
eventName: 'INTRUSION_ALERT',
|
||||
value: ctx.bMagT * 1e9,
|
||||
detail: `|B|=${(ctx.bMagT * 1e9).toFixed(2)} nT > 1.5× ambient (${(ambient * 1e9).toFixed(2)} nT) for ${(ctx.elapsedS - dwellStart).toFixed(1)} s`,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/** coherence — z-score of recent |B| against a longer baseline. */
|
||||
const coherence: AppRuntimeFn = (ctx) => {
|
||||
if (ctx.bHistory.length < 64) return null;
|
||||
const last = ctx.state['lastEmitS'] ?? 0;
|
||||
if (ctx.elapsedS - last < 0.5) return null;
|
||||
ctx.state['lastEmitS'] = ctx.elapsedS;
|
||||
|
||||
const recent = ctx.bHistory.slice(-32);
|
||||
const baseline = ctx.bHistory.slice(-128, -32);
|
||||
if (baseline.length < 32) return null;
|
||||
const mu = rollingMean(baseline);
|
||||
const sd = rollingStd(baseline);
|
||||
if (sd === 0) return null;
|
||||
const recentMean = rollingMean(recent);
|
||||
const z = Math.abs(recentMean - mu) / sd;
|
||||
return {
|
||||
ts: Date.now(),
|
||||
appId: 'coherence',
|
||||
eventId: 2,
|
||||
eventName: 'COHERENCE_SCORE',
|
||||
value: z,
|
||||
detail: `z=${z.toFixed(2)} σ ${z > 3 ? '· DRIFT' : z > 1.5 ? '· marginal' : '· stable'}`,
|
||||
};
|
||||
};
|
||||
|
||||
/** adversarial — detect physically-impossible 1/r³ violation. */
|
||||
const adversarial: AppRuntimeFn = (ctx) => {
|
||||
if (ctx.bHistory.length < 32) return null;
|
||||
const last = ctx.state['lastEmitS'] ?? 0;
|
||||
if (ctx.elapsedS - last < 3.0) return null;
|
||||
|
||||
// Fake "multi-link consistency": compare instantaneous |B| with the
|
||||
// smoothed |B|. A sharp factor-of-N step violates dipole physics
|
||||
// (real 1/r³ source moves continuously).
|
||||
const tail = ctx.bHistory.slice(-32);
|
||||
let maxJump = 0;
|
||||
for (let i = 1; i < tail.length; i++) {
|
||||
const j = Math.abs(Math.log(Math.max(tail[i], 1e-15)) - Math.log(Math.max(tail[i - 1], 1e-15)));
|
||||
if (j > maxJump) maxJump = j;
|
||||
}
|
||||
if (maxJump > 5) {
|
||||
ctx.state['lastEmitS'] = ctx.elapsedS;
|
||||
return {
|
||||
ts: Date.now(),
|
||||
appId: 'adversarial',
|
||||
eventId: 3,
|
||||
eventName: 'ANOMALY_DETECTED',
|
||||
value: maxJump,
|
||||
detail: `log-jump ${maxJump.toFixed(1)} — physically implausible step in |B|`,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/** exo_ghost_hunter — empty-room CSI anomaly detector adapted to the
|
||||
* magnetic noise floor: flag impulsive / periodic / drift / random
|
||||
* patterns and a hidden-presence sub-detector at 0.15-0.5 Hz. */
|
||||
const exoGhostHunter: AppRuntimeFn = (ctx) => {
|
||||
if (ctx.bHistory.length < 128) return null;
|
||||
const last = ctx.state['lastEmitS'] ?? 0;
|
||||
if (ctx.elapsedS - last < 4.0) return null;
|
||||
ctx.state['lastEmitS'] = ctx.elapsedS;
|
||||
|
||||
const tail = ctx.bHistory.slice(-128);
|
||||
const std = rollingStd(tail) * 1e9;
|
||||
// Detect impulsive: max - mean > 4σ
|
||||
const m = rollingMean(tail);
|
||||
let maxDev = 0;
|
||||
for (const v of tail) {
|
||||
const d = Math.abs(v - m);
|
||||
if (d > maxDev) maxDev = d;
|
||||
}
|
||||
const cls: 1 | 3 | 4 = maxDev > 4 * (std * 1e-9) ? 1 // impulsive
|
||||
: ctx.elapsedS > 10 ? 3 // drift bias as a default after warmup
|
||||
: 4; // random
|
||||
const clsName = cls === 1 ? 'impulsive' : cls === 3 ? 'drift' : 'random';
|
||||
return {
|
||||
ts: Date.now(),
|
||||
appId: 'exo_ghost_hunter',
|
||||
eventId: 651,
|
||||
eventName: 'ANOMALY_CLASS',
|
||||
value: cls,
|
||||
detail: `class=${clsName} · σ=${std.toFixed(3)} nT`,
|
||||
};
|
||||
};
|
||||
|
||||
export const APP_RUNTIMES: Record<string, AppRuntimeFn> = {
|
||||
vital_trend: vitalTrend,
|
||||
occupancy,
|
||||
intrusion,
|
||||
coherence,
|
||||
adversarial,
|
||||
exo_ghost_hunter: exoGhostHunter,
|
||||
};
|
||||
|
||||
export function hasRuntime(appId: string): boolean {
|
||||
return appId in APP_RUNTIMES;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/* Application-wide reactive state.
|
||||
*
|
||||
* One signal per logical observable; components subscribe to only the
|
||||
* signals they read. Keeps re-renders surgical even at 1 kHz frame rates.
|
||||
* Persistence lives in `persistence.ts`; this module is pure state.
|
||||
*/
|
||||
import { signal, computed } from '@preact/signals-core';
|
||||
import type { NvsimClient, MagFrameRecord, NvsimEvent } from '../transport/NvsimClient';
|
||||
|
||||
export type Theme = 'dark' | 'light';
|
||||
export type Density = 'comfy' | 'default' | 'compact';
|
||||
export type TransportMode = 'wasm' | 'ws';
|
||||
|
||||
export const transport = signal<TransportMode>('wasm');
|
||||
export const wsUrl = signal<string>('');
|
||||
export const connected = signal<boolean>(false);
|
||||
export const transportError = signal<string | null>(null);
|
||||
|
||||
export const running = signal<boolean>(false);
|
||||
export const paused = signal<boolean>(true);
|
||||
export const speed = signal<number>(1.0);
|
||||
export const t = signal<number>(0); // sim time (s)
|
||||
export const framesEmitted = signal<bigint>(0n);
|
||||
|
||||
export const seed = signal<bigint>(0xCAFEBABEn);
|
||||
|
||||
export const fs = signal<number>(10000); // sample rate Hz
|
||||
export const fmod = signal<number>(1000); // lockin Hz
|
||||
export const dtMs = signal<number>(1.0);
|
||||
export const noiseEnabled = signal<boolean>(true);
|
||||
|
||||
export const theme = signal<Theme>('dark');
|
||||
export const density = signal<Density>('default');
|
||||
export const motionReduced = signal<boolean>(false);
|
||||
export const autoUpdate = signal<boolean>(true);
|
||||
|
||||
export const lastB = signal<[number, number, number]>([0, 0, 0]); // T
|
||||
export const bMag = signal<number>(0);
|
||||
export const snr = signal<number>(0);
|
||||
export const fps = signal<number>(0);
|
||||
|
||||
export const witnessHex = signal<string>('');
|
||||
export const witnessVerified = signal<'pending' | 'ok' | 'fail' | 'idle'>('idle');
|
||||
export const expectedWitness = signal<string>('');
|
||||
|
||||
export const lastFrame = signal<MagFrameRecord | null>(null);
|
||||
export const traceX = signal<number[]>([]);
|
||||
export const traceY = signal<number[]>([]);
|
||||
export const traceZ = signal<number[]>([]);
|
||||
export const stripBars = signal<number[]>([]);
|
||||
|
||||
export const sceneName = signal<string>('rebar-walkby-01');
|
||||
export const sceneJson = signal<string>('');
|
||||
|
||||
export const consolePaused = signal<boolean>(false);
|
||||
export const consoleFilter = signal<'all' | 'info' | 'warn' | 'err' | 'dbg' | 'ok'>('all');
|
||||
|
||||
/** REPL command history, persisted via persistence.ts (kvSet 'repl-history'). */
|
||||
export const replHistory = signal<string[]>([]);
|
||||
export function pushReplHistory(cmd: string): void {
|
||||
const next = replHistory.value.slice();
|
||||
next.push(cmd);
|
||||
while (next.length > 200) next.shift();
|
||||
replHistory.value = next;
|
||||
}
|
||||
|
||||
/** Scene drag positions, persisted via persistence.ts (kvSet 'scene-positions'). */
|
||||
export interface SceneItemPos { id: string; x: number; y: number }
|
||||
export const scenePositions = signal<SceneItemPos[]>([]);
|
||||
|
||||
/** App-runtime emitted events. See appRuntimes.ts. */
|
||||
import type { AppEvent } from './appRuntimes';
|
||||
export const appEvents = signal<AppEvent[]>([]);
|
||||
export const appEventCounts = signal<Record<string, number>>({});
|
||||
|
||||
export function pushAppEvent(ev: AppEvent): void {
|
||||
const next = appEvents.value.slice();
|
||||
next.push(ev);
|
||||
while (next.length > 200) next.shift();
|
||||
appEvents.value = next;
|
||||
|
||||
const c = { ...appEventCounts.value };
|
||||
c[ev.appId] = (c[ev.appId] ?? 0) + 1;
|
||||
appEventCounts.value = c;
|
||||
}
|
||||
|
||||
/** Active app activations — driven by the App Store toggles. Mirrored
|
||||
* from `apps.ts` but exposed as a signal here so `main.ts` can dispatch
|
||||
* frames to active runtimes without importing the App Store component. */
|
||||
export const activeAppIds = signal<Set<string>>(new Set());
|
||||
|
||||
export const transportLabel = computed<string>(() =>
|
||||
transport.value === 'wasm' ? 'wasm' : 'ws',
|
||||
);
|
||||
|
||||
let _client: NvsimClient | null = null;
|
||||
export function setClient(c: NvsimClient): void { _client = c; }
|
||||
export function getClient(): NvsimClient | null { return _client; }
|
||||
|
||||
export interface ConsoleLine {
|
||||
ts: number;
|
||||
level: 'info' | 'warn' | 'err' | 'dbg' | 'ok';
|
||||
msg: string;
|
||||
}
|
||||
export const consoleLines = signal<ConsoleLine[]>([]);
|
||||
const MAX_LINES = 200;
|
||||
|
||||
export function pushLog(level: ConsoleLine['level'], msg: string): void {
|
||||
if (consolePaused.value) return;
|
||||
const next = consoleLines.value.slice();
|
||||
next.push({ ts: Date.now(), level, msg });
|
||||
while (next.length > MAX_LINES) next.shift();
|
||||
consoleLines.value = next;
|
||||
}
|
||||
|
||||
export function pushTrace(b: [number, number, number]): void {
|
||||
const cap = 200;
|
||||
const x = traceX.value.slice(); x.push(b[0]); if (x.length > cap) x.shift();
|
||||
const y = traceY.value.slice(); y.push(b[1]); if (y.length > cap) y.shift();
|
||||
const z = traceZ.value.slice(); z.push(b[2]); if (z.length > cap) z.shift();
|
||||
traceX.value = x;
|
||||
traceY.value = y;
|
||||
traceZ.value = z;
|
||||
}
|
||||
|
||||
export function pushStripBar(amp: number): void {
|
||||
const cap = 48;
|
||||
const next = stripBars.value.slice();
|
||||
next.push(Math.max(0, Math.min(1, amp)));
|
||||
while (next.length > cap) next.shift();
|
||||
stripBars.value = next;
|
||||
}
|
||||
|
||||
export function recordEvent(_ev: NvsimEvent): void {
|
||||
// future: route NvsimEvent into store updates per type. For V1 the
|
||||
// worker pushes B-vector / frame data directly via the data plane.
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
/* RuView Edge App Store registry.
|
||||
*
|
||||
* Catalog of every WASM edge module shipping in the workspace plus the
|
||||
* `nvsim` simulator itself. Each entry maps to a hot-loadable algorithm
|
||||
* the dashboard can run in-browser (WASM transport) or push to a real
|
||||
* ESP32-S3 mesh (WS transport, deployed via WASM3 — ADR-040 Tier 3).
|
||||
*
|
||||
* Categories (ADR-041 event-ID ranges):
|
||||
* med 100–199 Medical & health
|
||||
* sec 200–299 Security & safety
|
||||
* bld 300–399 Smart building
|
||||
* ret 400–499 Retail & hospitality
|
||||
* ind 500–599 Industrial
|
||||
* sig 600–619 Signal-processing primitives
|
||||
* lrn 620–639 Online learning
|
||||
* spt 640–659 Spatial / graph
|
||||
* tmp 640–660 Temporal logic / planning
|
||||
* ais 700–719 AI safety
|
||||
* qnt 720–739 Quantum-flavoured signal
|
||||
* aut 740–759 Autonomy / mesh
|
||||
* exo 650–699 Exotic / research
|
||||
* sim — Pipeline simulators (nvsim)
|
||||
*
|
||||
* The `crate` field names the Cargo crate that owns the implementation.
|
||||
* `wasmEdge` apps are compiled out of `wifi-densepose-wasm-edge`;
|
||||
* `nvsim` apps come from `nvsim`. Future apps may target other crates.
|
||||
*/
|
||||
|
||||
export type AppCategory =
|
||||
| 'sim'
|
||||
| 'med'
|
||||
| 'sec'
|
||||
| 'bld'
|
||||
| 'ret'
|
||||
| 'ind'
|
||||
| 'sig'
|
||||
| 'lrn'
|
||||
| 'spt'
|
||||
| 'tmp'
|
||||
| 'ais'
|
||||
| 'qnt'
|
||||
| 'aut'
|
||||
| 'exo';
|
||||
|
||||
/** What actually happens when a card's toggle is on.
|
||||
* - `running` — the algorithm is genuinely running in the browser right now
|
||||
* (e.g. `nvsim` itself, which is the simulator the dashboard fronts).
|
||||
* - `simulated` — a pared-down version of the algorithm runs against nvsim's
|
||||
* live magnetic frame stream as a *proxy* for its native CSI input.
|
||||
* Emits real i32 event IDs into the console feed; output is illustrative,
|
||||
* not engineering-grade. Listed apps' Rust source is real, builds for
|
||||
* wasm32-unknown-unknown, and passes its native unit tests.
|
||||
* - `mesh-only` — algorithm needs CSI subcarrier data from a real ESP32-S3
|
||||
* mesh (or a future CSI simulator). Toggling persists the selection so
|
||||
* the WS transport can push activation when connected. */
|
||||
export type AppRuntime = 'running' | 'simulated' | 'mesh-only';
|
||||
|
||||
export interface AppManifest {
|
||||
/** Stable kebab-case id; matches the wasm-edge module name (e.g. `med_sleep_apnea`). */
|
||||
id: string;
|
||||
/** Human-readable name. */
|
||||
name: string;
|
||||
/** Category short-code. */
|
||||
category: AppCategory;
|
||||
/** Cargo crate the implementation lives in. */
|
||||
crate: 'nvsim' | 'wifi-densepose-wasm-edge' | string;
|
||||
/** One-liner description. */
|
||||
summary: string;
|
||||
/** Optional longer markdown body. */
|
||||
body?: string;
|
||||
/** Numeric event IDs this app emits (i32 codes from `event_types` mod). */
|
||||
events?: number[];
|
||||
/** Compute budget tier the module advertises. S=<5ms, M=<15ms, L=<50ms. */
|
||||
budget?: 'S' | 'M' | 'L';
|
||||
/** Default activation state when listed. */
|
||||
active?: boolean;
|
||||
/** Tags for fuzzy search and filtering. */
|
||||
tags?: string[];
|
||||
/** "Available", "Beta", or "Research" maturity. */
|
||||
status: 'available' | 'beta' | 'research';
|
||||
/** ADR back-reference. */
|
||||
adr?: string;
|
||||
/** What actually happens when active — see AppRuntime docs. */
|
||||
runtime?: AppRuntime;
|
||||
}
|
||||
|
||||
export const APPS: AppManifest[] = [
|
||||
// ── Pipeline simulators ──────────────────────────────────────────────────
|
||||
{
|
||||
id: 'nvsim',
|
||||
name: 'nvsim — NV-diamond magnetometer',
|
||||
category: 'sim',
|
||||
crate: 'nvsim',
|
||||
summary:
|
||||
'Deterministic forward simulator: scene → Biot–Savart → NV ensemble → ADC → MagFrame stream + SHA-256 witness.',
|
||||
budget: 'L',
|
||||
active: true,
|
||||
status: 'available',
|
||||
tags: ['quantum', 'magnetometer', 'simulator', 'witness', 'wasm'],
|
||||
adr: 'ADR-089',
|
||||
runtime: 'running',
|
||||
},
|
||||
|
||||
// ── Core sensing primitives (ADR-014/040 flagship modules) ───────────────
|
||||
{
|
||||
id: 'gesture',
|
||||
name: 'Gesture (DTW)',
|
||||
category: 'sig',
|
||||
crate: 'wifi-densepose-wasm-edge',
|
||||
summary: 'Dynamic-Time-Warping gesture classifier from CSI motion templates.',
|
||||
events: [1],
|
||||
budget: 'M',
|
||||
status: 'available',
|
||||
tags: ['hci', 'csi', 'classifier', 'dtw'],
|
||||
adr: 'ADR-014',
|
||||
runtime: 'mesh-only',
|
||||
},
|
||||
{
|
||||
id: 'coherence',
|
||||
name: 'Coherence gate',
|
||||
category: 'sig',
|
||||
crate: 'wifi-densepose-wasm-edge',
|
||||
summary: 'Z-score coherence scoring + Accept/PredictOnly/Reject/Recalibrate gate.',
|
||||
events: [2],
|
||||
budget: 'S',
|
||||
status: 'available',
|
||||
tags: ['gate', 'csi', 'coherence', 'drift'],
|
||||
adr: 'ADR-029',
|
||||
runtime: 'simulated',
|
||||
},
|
||||
{
|
||||
id: 'adversarial',
|
||||
name: 'Adversarial-signal detector',
|
||||
category: 'ais',
|
||||
crate: 'wifi-densepose-wasm-edge',
|
||||
summary:
|
||||
'Physically-impossible-signal detector — multi-link consistency, used to flag spoofed CSI.',
|
||||
events: [3],
|
||||
budget: 'M',
|
||||
status: 'available',
|
||||
tags: ['security', 'csi', 'spoofing', 'mesh'],
|
||||
adr: 'ADR-032',
|
||||
runtime: 'simulated',
|
||||
},
|
||||
{
|
||||
id: 'rvf',
|
||||
name: 'RVF — Rust Verified Feature stream',
|
||||
category: 'sig',
|
||||
crate: 'wifi-densepose-wasm-edge',
|
||||
summary: 'Verified-frame builder with SHA-256 hash + version metadata for the feature stream.',
|
||||
budget: 'S',
|
||||
status: 'available',
|
||||
tags: ['witness', 'csi', 'hash'],
|
||||
adr: 'ADR-040',
|
||||
},
|
||||
{
|
||||
id: 'occupancy',
|
||||
name: 'Occupancy estimator',
|
||||
category: 'bld',
|
||||
crate: 'wifi-densepose-wasm-edge',
|
||||
summary: 'Through-wall presence + person-count via CSI amplitude perturbation.',
|
||||
events: [300, 301, 302],
|
||||
budget: 'S',
|
||||
status: 'available',
|
||||
tags: ['csi', 'building', 'presence'],
|
||||
runtime: 'simulated',
|
||||
},
|
||||
{
|
||||
id: 'vital_trend',
|
||||
name: 'Vital-trend monitor',
|
||||
category: 'med',
|
||||
crate: 'wifi-densepose-wasm-edge',
|
||||
summary: 'HR + BR trend tracking with bradycardia/tachycardia/apnea events.',
|
||||
events: [100, 101, 102, 103, 104, 105],
|
||||
budget: 'S',
|
||||
status: 'available',
|
||||
tags: ['medical', 'vitals', 'csi'],
|
||||
adr: 'ADR-021',
|
||||
runtime: 'simulated',
|
||||
},
|
||||
{
|
||||
id: 'intrusion',
|
||||
name: 'Intrusion detector',
|
||||
category: 'sec',
|
||||
crate: 'wifi-densepose-wasm-edge',
|
||||
summary: 'Zone-based intrusion alert from CSI motion patterns.',
|
||||
events: [200, 201],
|
||||
budget: 'S',
|
||||
status: 'available',
|
||||
tags: ['security', 'zone', 'csi'],
|
||||
runtime: 'simulated',
|
||||
},
|
||||
|
||||
// ── Medical & Health (100-series) ────────────────────────────────────────
|
||||
{ id: 'med_sleep_apnea', name: 'Sleep-apnea detector', category: 'med', crate: 'wifi-densepose-wasm-edge', summary: 'Episodic respiratory pause detection during sleep cycles.', events: [105], budget: 'S', status: 'available', tags: ['medical', 'sleep', 'breathing'] },
|
||||
{ id: 'med_cardiac_arrhythmia', name: 'Cardiac arrhythmia', category: 'med', crate: 'wifi-densepose-wasm-edge', summary: 'Beat-to-beat irregularity classifier from cardiac micro-Doppler.', events: [103, 104], budget: 'M', status: 'available', tags: ['medical', 'cardiac', 'arrhythmia'] },
|
||||
{ id: 'med_respiratory_distress', name: 'Respiratory distress', category: 'med', crate: 'wifi-densepose-wasm-edge', summary: 'Distress signature: rapid shallow breathing + accessory-muscle motion.', events: [101, 102], budget: 'S', status: 'available', tags: ['medical', 'breathing', 'icu'] },
|
||||
{ id: 'med_gait_analysis', name: 'Gait analysis', category: 'med', crate: 'wifi-densepose-wasm-edge', summary: 'Stride length, cadence, asymmetry from through-wall CSI pose tracking.', budget: 'M', status: 'available', tags: ['medical', 'gait', 'pose'] },
|
||||
{ id: 'med_seizure_detect', name: 'Seizure detector', category: 'med', crate: 'wifi-densepose-wasm-edge', summary: 'Tonic-clonic seizure motion signature.', budget: 'M', status: 'beta', tags: ['medical', 'neuro'] },
|
||||
|
||||
// ── Security (200-series) ────────────────────────────────────────────────
|
||||
{ id: 'sec_perimeter_breach', name: 'Perimeter breach', category: 'sec', crate: 'wifi-densepose-wasm-edge', summary: 'Approach/departure detection at user-defined boundary segments.', events: [210, 211, 212, 213], budget: 'S', status: 'available', tags: ['security', 'perimeter'] },
|
||||
{ id: 'sec_weapon_detect', name: 'Metal anomaly / weapon', category: 'sec', crate: 'wifi-densepose-wasm-edge', summary: 'Metal-perturbation flag in CSI; potential weapon presence (research).', events: [220, 221, 222], budget: 'M', status: 'research', tags: ['security', 'metal', 'csi'] },
|
||||
{ id: 'sec_tailgating', name: 'Tailgating detector', category: 'sec', crate: 'wifi-densepose-wasm-edge', summary: 'Detect 2+ persons crossing a single-passage threshold.', events: [230, 231, 232], budget: 'S', status: 'available', tags: ['security', 'access-control'] },
|
||||
{ id: 'sec_loitering', name: 'Loitering detector', category: 'sec', crate: 'wifi-densepose-wasm-edge', summary: 'Stationary occupancy past a configurable dwell threshold.', events: [240, 241, 242], budget: 'S', status: 'available', tags: ['security', 'dwell'] },
|
||||
{ id: 'sec_panic_motion', name: 'Panic motion', category: 'sec', crate: 'wifi-densepose-wasm-edge', summary: 'High-energy distress motion: struggle / fleeing pattern.', events: [250, 251, 252], budget: 'S', status: 'beta', tags: ['security', 'distress'] },
|
||||
|
||||
// ── Smart Building (300-series) ──────────────────────────────────────────
|
||||
{ id: 'bld_hvac_presence', name: 'HVAC presence', category: 'bld', crate: 'wifi-densepose-wasm-edge', summary: 'Occupied/activity-level/departure-countdown for HVAC zones.', events: [310, 311, 312], budget: 'S', status: 'available', tags: ['hvac', 'building', 'energy'] },
|
||||
{ id: 'bld_lighting_zones', name: 'Lighting zones', category: 'bld', crate: 'wifi-densepose-wasm-edge', summary: 'Per-zone light on/dim/off cues from occupancy.', events: [320, 321, 322], budget: 'S', status: 'available', tags: ['lighting', 'building'] },
|
||||
{ id: 'bld_elevator_count', name: 'Elevator count', category: 'bld', crate: 'wifi-densepose-wasm-edge', summary: 'Person count inside elevator car from CSI.', events: [330], budget: 'S', status: 'available', tags: ['elevator', 'building'] },
|
||||
{ id: 'bld_meeting_room', name: 'Meeting-room utilization', category: 'bld', crate: 'wifi-densepose-wasm-edge', summary: 'Meeting size + duration analytics for booking systems.', budget: 'S', status: 'available', tags: ['meeting', 'analytics'] },
|
||||
{ id: 'bld_energy_audit', name: 'Energy audit', category: 'bld', crate: 'wifi-densepose-wasm-edge', summary: 'Continuous occupancy-vs-HVAC-state audit for energy savings.', budget: 'M', status: 'available', tags: ['energy', 'audit'] },
|
||||
|
||||
// ── Retail (400-series) ──────────────────────────────────────────────────
|
||||
{ id: 'ret_queue_length', name: 'Queue length', category: 'ret', crate: 'wifi-densepose-wasm-edge', summary: 'Live queue-length tracking for checkout / kiosks.', budget: 'S', status: 'available', tags: ['retail', 'queue'] },
|
||||
{ id: 'ret_dwell_heatmap', name: 'Dwell heatmap', category: 'ret', crate: 'wifi-densepose-wasm-edge', summary: 'Per-zone dwell time accumulation; analytics-only export.', budget: 'M', status: 'available', tags: ['retail', 'heatmap'] },
|
||||
{ id: 'ret_customer_flow', name: 'Customer flow', category: 'ret', crate: 'wifi-densepose-wasm-edge', summary: 'Origin-destination flow graph through a store layout.', budget: 'M', status: 'available', tags: ['retail', 'flow'] },
|
||||
{ id: 'ret_table_turnover', name: 'Table turnover', category: 'ret', crate: 'wifi-densepose-wasm-edge', summary: 'Restaurant table seat / vacate transitions.', budget: 'S', status: 'available', tags: ['retail', 'restaurant'] },
|
||||
{ id: 'ret_shelf_engagement', name: 'Shelf engagement', category: 'ret', crate: 'wifi-densepose-wasm-edge', summary: 'Reach-to-shelf gestures and dwell at product zones.', budget: 'M', status: 'available', tags: ['retail', 'shelf'] },
|
||||
|
||||
// ── Industrial (500-series) ──────────────────────────────────────────────
|
||||
{ id: 'ind_forklift_proximity', name: 'Forklift proximity', category: 'ind', crate: 'wifi-densepose-wasm-edge', summary: 'Worker-near-forklift safety alert.', budget: 'S', status: 'available', tags: ['industrial', 'safety'] },
|
||||
{ id: 'ind_confined_space', name: 'Confined-space monitor', category: 'ind', crate: 'wifi-densepose-wasm-edge', summary: 'Last-person-out detection + presence audit for OSHA confined-space entries.', budget: 'S', status: 'available', tags: ['industrial', 'osha'] },
|
||||
{ id: 'ind_clean_room', name: 'Clean-room PPE / motion', category: 'ind', crate: 'wifi-densepose-wasm-edge', summary: 'Motion patterns consistent with proper PPE-clad movement.', budget: 'M', status: 'beta', tags: ['industrial', 'cleanroom'] },
|
||||
{ id: 'ind_livestock_monitor', name: 'Livestock monitor', category: 'ind', crate: 'wifi-densepose-wasm-edge', summary: 'Vital-sign + activity tracking for stall-bound livestock.', budget: 'M', status: 'beta', tags: ['agriculture', 'livestock'] },
|
||||
{ id: 'ind_structural_vibration', name: 'Structural vibration', category: 'ind', crate: 'wifi-densepose-wasm-edge', summary: 'Building/equipment micro-vibration via CSI phase derivative.', budget: 'M', status: 'research', tags: ['industrial', 'vibration'] },
|
||||
|
||||
// ── Signal primitives (600-series) ───────────────────────────────────────
|
||||
{ id: 'sig_coherence_gate', name: 'Coherence gate (extended)', category: 'sig', crate: 'wifi-densepose-wasm-edge', summary: 'Hysteresis + multi-state coherence gate driving downstream apps.', budget: 'S', status: 'available', tags: ['gate', 'csi'] },
|
||||
{ id: 'sig_flash_attention', name: 'Flash attention (CSI)', category: 'sig', crate: 'wifi-densepose-wasm-edge', summary: 'Edge-friendly attention block for CSI subcarrier weighting.', budget: 'M', status: 'beta', tags: ['attention', 'csi'] },
|
||||
{ id: 'sig_temporal_compress', name: 'Temporal-tensor compress', category: 'sig', crate: 'wifi-densepose-wasm-edge', summary: 'RuVector temporal-tensor compression on the CSI buffer.', budget: 'M', status: 'available', tags: ['compress', 'tensor'] },
|
||||
{ id: 'sig_sparse_recovery', name: 'Sparse recovery', category: 'sig', crate: 'wifi-densepose-wasm-edge', summary: '114→56 subcarrier sparse interpolation via L1 solver.', budget: 'M', status: 'available', tags: ['sparse', 'csi'] },
|
||||
{ id: 'sig_mincut_person_match', name: 'Mincut person-match', category: 'sig', crate: 'wifi-densepose-wasm-edge', summary: 'Min-cut person assignment across multistatic frames.', budget: 'M', status: 'available', tags: ['mincut', 'matching'] },
|
||||
{ id: 'sig_optimal_transport', name: 'Optimal transport', category: 'sig', crate: 'wifi-densepose-wasm-edge', summary: 'OT-based feature alignment between mesh nodes.', budget: 'M', status: 'beta', tags: ['ot', 'alignment'] },
|
||||
|
||||
// ── Online learning ──────────────────────────────────────────────────────
|
||||
{ id: 'lrn_dtw_gesture_learn', name: 'DTW gesture learn', category: 'lrn', crate: 'wifi-densepose-wasm-edge', summary: 'On-device template learning for personalized gesture libraries.', budget: 'M', status: 'beta', tags: ['lifelong', 'gesture'] },
|
||||
{ id: 'lrn_anomaly_attractor', name: 'Anomaly attractor', category: 'lrn', crate: 'wifi-densepose-wasm-edge', summary: 'Novelty detector with dynamic-attractor recall.', budget: 'M', status: 'research', tags: ['novelty', 'lifelong'] },
|
||||
{ id: 'lrn_meta_adapt', name: 'Meta-adapt', category: 'lrn', crate: 'wifi-densepose-wasm-edge', summary: 'Meta-learning adapter for fast site-to-site transfer.', budget: 'L', status: 'research', tags: ['meta-learning'] },
|
||||
{ id: 'lrn_ewc_lifelong', name: 'EWC++ lifelong', category: 'lrn', crate: 'wifi-densepose-wasm-edge', summary: 'Elastic-weight-consolidation gate to avoid catastrophic forgetting.', budget: 'M', status: 'beta', tags: ['lifelong', 'ewc'] },
|
||||
|
||||
// ── Spatial / graph ──────────────────────────────────────────────────────
|
||||
{ id: 'spt_pagerank_influence', name: 'PageRank influence', category: 'spt', crate: 'wifi-densepose-wasm-edge', summary: 'Graph-influence ranking on the multistatic mesh.', budget: 'M', status: 'beta', tags: ['graph', 'pagerank'] },
|
||||
{ id: 'spt_micro_hnsw', name: 'µHNSW vector index', category: 'spt', crate: 'wifi-densepose-wasm-edge', summary: 'Tiny HNSW index for AETHER re-ID embeddings on-device.', budget: 'M', status: 'available', tags: ['hnsw', 'reid'] },
|
||||
{ id: 'spt_spiking_tracker', name: 'Spiking tracker', category: 'spt', crate: 'wifi-densepose-wasm-edge', summary: 'Spiking-network multi-target tracker.', budget: 'L', status: 'research', tags: ['snn', 'tracker'] },
|
||||
|
||||
// ── Temporal / planning ──────────────────────────────────────────────────
|
||||
{ id: 'tmp_pattern_sequence', name: 'Pattern sequence', category: 'tmp', crate: 'wifi-densepose-wasm-edge', summary: 'Sequence-of-events pattern matcher (e.g. ingress→linger→egress).', budget: 'M', status: 'available', tags: ['temporal', 'pattern'] },
|
||||
{ id: 'tmp_temporal_logic_guard', name: 'Temporal logic guard', category: 'tmp', crate: 'wifi-densepose-wasm-edge', summary: 'LTL/MTL safety-property guard over event streams.', budget: 'M', status: 'beta', tags: ['ltl', 'safety'] },
|
||||
{ id: 'tmp_goap_autonomy', name: 'GOAP autonomy', category: 'tmp', crate: 'wifi-densepose-wasm-edge', summary: 'Goal-oriented action planning for adaptive routines.', budget: 'L', status: 'research', tags: ['planning', 'autonomy'] },
|
||||
|
||||
// ── AI safety ────────────────────────────────────────────────────────────
|
||||
{ id: 'ais_prompt_shield', name: 'Prompt shield', category: 'ais', crate: 'wifi-densepose-wasm-edge', summary: 'Edge-side LLM prompt-injection guard for on-device assistants.', budget: 'M', status: 'beta', tags: ['security', 'llm'] },
|
||||
{ id: 'ais_behavioral_profiler', name: 'Behavioral profiler', category: 'ais', crate: 'wifi-densepose-wasm-edge', summary: 'Anomalous-behaviour profiler (drift in motion habits).', budget: 'M', status: 'beta', tags: ['anomaly', 'behaviour'] },
|
||||
|
||||
// ── Quantum-flavoured ────────────────────────────────────────────────────
|
||||
{ id: 'qnt_quantum_coherence', name: 'Quantum coherence', category: 'qnt', crate: 'wifi-densepose-wasm-edge', summary: 'Coherence diagnostics adapted for quantum-sensor signals.', budget: 'M', status: 'research', tags: ['quantum', 'coherence'] },
|
||||
{ id: 'qnt_interference_search', name: 'Interference search', category: 'qnt', crate: 'wifi-densepose-wasm-edge', summary: 'Interferometric anomaly search across mesh viewpoints.', budget: 'L', status: 'research', tags: ['quantum', 'interference'] },
|
||||
|
||||
// ── Autonomy / mesh ──────────────────────────────────────────────────────
|
||||
{ id: 'aut_psycho_symbolic', name: 'Psycho-symbolic agent', category: 'aut', crate: 'wifi-densepose-wasm-edge', summary: 'Symbolic-rule + neural-feature hybrid for low-power autonomy loops.', budget: 'L', status: 'research', tags: ['autonomy', 'symbolic'] },
|
||||
{ id: 'aut_self_healing_mesh', name: 'Self-healing mesh', category: 'aut', crate: 'wifi-densepose-wasm-edge', summary: 'Mesh-topology repair with per-node health gossip.', budget: 'M', status: 'beta', tags: ['mesh', 'health'] },
|
||||
|
||||
// ── Exotic / Research (650-series) ───────────────────────────────────────
|
||||
{ id: 'exo_ghost_hunter', name: 'Ghost hunter (anomaly)', category: 'exo', crate: 'wifi-densepose-wasm-edge', summary: 'Empty-room CSI anomaly detector — impulsive/periodic/drift/random + hidden-presence sub-detector.', events: [650, 651, 652, 653], budget: 'S', status: 'available', tags: ['anomaly', 'paranormal', 'csi'], adr: 'ADR-041', runtime: 'simulated' },
|
||||
{ id: 'exo_breathing_sync', name: 'Breathing sync', category: 'exo', crate: 'wifi-densepose-wasm-edge', summary: 'Multi-person breathing synchrony analytics.', budget: 'M', status: 'beta', tags: ['breathing', 'sync'] },
|
||||
{ id: 'exo_dream_stage', name: 'Dream-stage classifier', category: 'exo', crate: 'wifi-densepose-wasm-edge', summary: 'NREM/REM stage classification from breathing + micro-motion.', budget: 'M', status: 'research', tags: ['sleep', 'rem'] },
|
||||
{ id: 'exo_emotion_detect', name: 'Emotion detector', category: 'exo', crate: 'wifi-densepose-wasm-edge', summary: 'Coarse arousal/valence from breathing + heart-rate variability.', budget: 'M', status: 'research', tags: ['affect'] },
|
||||
{ id: 'exo_gesture_language', name: 'Gesture language', category: 'exo', crate: 'wifi-densepose-wasm-edge', summary: 'Sign-language pattern recognition.', budget: 'L', status: 'research', tags: ['hci', 'sign'] },
|
||||
{ id: 'exo_happiness_score', name: 'Happiness score', category: 'exo', crate: 'wifi-densepose-wasm-edge', summary: 'Aggregate well-being score from co-occupancy + activity dynamics.', budget: 'M', status: 'research', tags: ['affect', 'wellbeing'] },
|
||||
{ id: 'exo_hyperbolic_space', name: 'Hyperbolic space embed', category: 'exo', crate: 'wifi-densepose-wasm-edge', summary: 'Hyperbolic embeddings for hierarchical scene structure.', budget: 'L', status: 'research', tags: ['embedding', 'hyperbolic'] },
|
||||
{ id: 'exo_music_conductor', name: 'Music conductor', category: 'exo', crate: 'wifi-densepose-wasm-edge', summary: 'Map gesture energy to MIDI tempo/dynamics.', budget: 'M', status: 'research', tags: ['midi', 'art'] },
|
||||
{ id: 'exo_plant_growth', name: 'Plant-growth tracker', category: 'exo', crate: 'wifi-densepose-wasm-edge', summary: 'Slow CSI drift tracking for greenhouse foliage growth.', budget: 'L', status: 'research', tags: ['agriculture'] },
|
||||
{ id: 'exo_rain_detect', name: 'Rain detector', category: 'exo', crate: 'wifi-densepose-wasm-edge', summary: 'Outdoor CSI signature of rainfall.', budget: 'M', status: 'research', tags: ['weather'] },
|
||||
{ id: 'exo_time_crystal', name: 'Time-crystal periodicity', category: 'exo', crate: 'wifi-densepose-wasm-edge', summary: 'Periodicity diagnostics with anti-aliasing harmonics.', budget: 'M', status: 'research', tags: ['periodicity'] },
|
||||
];
|
||||
|
||||
export const CATEGORIES: Record<AppCategory, { label: string; color: string; range: string }> = {
|
||||
sim: { label: 'Simulators', color: 'oklch(0.78 0.14 70)', range: '—' },
|
||||
med: { label: 'Medical & Health', color: 'oklch(0.65 0.22 25)', range: '100–199' },
|
||||
sec: { label: 'Security & Safety', color: 'oklch(0.7 0.18 35)', range: '200–299' },
|
||||
bld: { label: 'Smart Building', color: 'oklch(0.78 0.12 195)', range: '300–399' },
|
||||
ret: { label: 'Retail & Hospitality', color: 'oklch(0.78 0.14 145)', range: '400–499' },
|
||||
ind: { label: 'Industrial', color: 'oklch(0.72 0.18 330)', range: '500–599' },
|
||||
sig: { label: 'Signal Processing', color: 'oklch(0.78 0.14 70)', range: '600–619' },
|
||||
lrn: { label: 'Online Learning', color: 'oklch(0.78 0.12 260)', range: '620–639' },
|
||||
spt: { label: 'Spatial / Graph', color: 'oklch(0.7 0.18 100)', range: '640–659' },
|
||||
tmp: { label: 'Temporal / Planning', color: 'oklch(0.7 0.16 50)', range: '660–679' },
|
||||
ais: { label: 'AI Safety', color: 'oklch(0.65 0.22 25)', range: '700–719' },
|
||||
qnt: { label: 'Quantum', color: 'oklch(0.72 0.18 290)', range: '720–739' },
|
||||
aut: { label: 'Autonomy', color: 'oklch(0.78 0.14 145)', range: '740–759' },
|
||||
exo: { label: 'Exotic / Research', color: 'oklch(0.72 0.18 330)', range: '650–699' },
|
||||
};
|
||||
|
||||
export interface AppActivation {
|
||||
id: string;
|
||||
/** Active in the current session. */
|
||||
active: boolean;
|
||||
/** Last activation timestamp. */
|
||||
lastActivatedAt?: number;
|
||||
/** Last event count seen (for the cards' counter). */
|
||||
eventCount?: number;
|
||||
}
|
||||
|
||||
export function defaultActivations(): AppActivation[] {
|
||||
return APPS.map((a) => ({ id: a.id, active: a.active === true, eventCount: 0 }));
|
||||
}
|
||||
|
||||
export function appsByCategory(): Record<AppCategory, AppManifest[]> {
|
||||
const map = {} as Record<AppCategory, AppManifest[]>;
|
||||
for (const c of Object.keys(CATEGORIES) as AppCategory[]) map[c] = [];
|
||||
for (const a of APPS) map[a.category].push(a);
|
||||
return map;
|
||||
}
|
||||
|
||||
export function findApp(id: string): AppManifest | undefined {
|
||||
return APPS.find((a) => a.id === id);
|
||||
}
|
||||
|
||||
export function fuzzyMatch(query: string, app: AppManifest): number {
|
||||
if (!query) return 1;
|
||||
const q = query.toLowerCase();
|
||||
let score = 0;
|
||||
if (app.id.toLowerCase().includes(q)) score += 3;
|
||||
if (app.name.toLowerCase().includes(q)) score += 3;
|
||||
if (app.summary.toLowerCase().includes(q)) score += 1;
|
||||
if (app.tags?.some((t) => t.toLowerCase().includes(q))) score += 2;
|
||||
if (app.category === q) score += 5;
|
||||
return score;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/* IndexedDB-backed persistence for settings and saved scenes.
|
||||
* Mirrors the mockup's `nvsim/kv` store. */
|
||||
|
||||
const DB_NAME = 'nvsim';
|
||||
const DB_VER = 1;
|
||||
const STORE = 'kv';
|
||||
|
||||
let dbPromise: Promise<IDBDatabase> | null = null;
|
||||
|
||||
function openDb(): Promise<IDBDatabase> {
|
||||
if (dbPromise) return dbPromise;
|
||||
dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, DB_VER);
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE);
|
||||
};
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
export async function kvGet<T = unknown>(key: string): Promise<T | undefined> {
|
||||
const db = await openDb();
|
||||
return await new Promise<T | undefined>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE, 'readonly');
|
||||
const r = tx.objectStore(STORE).get(key);
|
||||
r.onsuccess = () => resolve(r.result as T | undefined);
|
||||
r.onerror = () => reject(r.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function kvSet(key: string, value: unknown): Promise<void> {
|
||||
const db = await openDb();
|
||||
return await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE, 'readwrite');
|
||||
tx.objectStore(STORE).put(value, key);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function kvDelete(key: string): Promise<void> {
|
||||
const db = await openDb();
|
||||
return await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE, 'readwrite');
|
||||
tx.objectStore(STORE).delete(key);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user