feat(dashboard): nvsim Vite+Lit dashboard with WASM transport + App Store [ADR-092]
End-to-end implementation of the operator dashboard for the nvsim
NV-diamond magnetometer simulator. Vite 5 + TypeScript strict + Lit 3,
~93 KB gzipped JS budget, runs the *real* nvsim Rust crate compiled to
wasm32-unknown-unknown inside a dedicated Web Worker.
Validated end-to-end with `npx agent-browser`:
- WASM module boots, build version + magic 0xC51A_6E70 reported
- Reference witness verifies byte-identical to Proof::EXPECTED_WITNESS_HEX
cc8de9b01b0ff5bd97a6c17848a3f156c174ea7589d0888164a441584ec593b4
- Pipeline runs at ~1.88 kHz on x86_64 dev hardware (4500x over Cortex-A53)
- Zero browser console errors; only Lit dev-mode warning (expected)
## nvsim crate (additive)
- New `wasm` feature flag with wasm-bindgen 0.2 / serde-wasm-bindgen 0.6
- src/wasm.rs: WasmPipeline wrapper + referenceSceneJson +
expectedReferenceWitnessHex + referenceWitness + hexWitness exports
- crate-type = ["cdylib", "rlib"] so native + wasm both build
- rand = { default-features = false } drops getrandom OS-entropy path,
preserving the crate's WASM-ready posture
- Native: 50/50 tests still pass, witness unchanged
## dashboard/ (new package)
- Vite 5 + TypeScript strict, Lit 3 elements, signals-based store
- 12 Lit components mirroring the mockup zones (rail, topbar, sidebar,
scene SVG with draggable sources + NV crystal, inspector tabs
Signal/Frame/Witness, console with REPL + filter tabs, settings
drawer, modals, ⌘K command palette, debug HUD, toast, app-store)
- IndexedDB persistence (theme, density, motion, app activations)
- WasmClient → Web Worker → wasm-pack-built nvsim WASM module
- NvsimClient TS interface — same shape covers future WsClient transport
- MagFrame parser (60-byte LE layout matching nvsim::frame)
## App Store (ADR-092 §14a — added during impl)
- Catalog of all 65 wifi-densepose-wasm-edge modules + nvsim
- 13 categories with event-ID-range labels
- Per-app metadata: id/name/category/crate/summary/events/budget/
status/adr/tags
- Fuzzy search, category + status filters, IndexedDB-backed activation
- ADR-092 §14a documents the registry contract and per-app schema
## Build pipeline
- wasm-pack build crates/nvsim --target web outputs to
dashboard/public/nvsim-pkg/ (60 KB pkg, 162 KB unoptimized .wasm)
- npm run build → 93 KB gzip JS, well under 300 KB budget
- ts.config strict, npx tsc --noEmit clean
- Vite worker correctly loads WASM via dynamic import resolving
against worker origin
## E2E validation
- agent-browser open → 4-zone grid renders correctly in dark theme
- Run button → live B-vector trace, |B| readout updates, FPS counter
- App Store → all 66 apps listed with toggles, fuzzy search filters
to "Ghost hunter" on "ghost" query
- Witness verify → green check, console logs "determinism gate ✓"
- Console errors: zero (only expected Lit dev-mode warning)
Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
parent
db1ccbff49
commit
39ec05edcb
|
|
@ -0,0 +1,5 @@
|
|||
node_modules
|
||||
dist
|
||||
.vite
|
||||
*.log
|
||||
public/nvsim-pkg
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
<!doctype html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>RuView · nvsim — NV-Diamond Magnetometer Simulator</title>
|
||||
<meta name="description" content="Deterministic forward simulator for NV-diamond magnetometry. WASM-backed CW-ODMR pipeline with witness-grade SHA-256 proofs." />
|
||||
<meta name="theme-color" content="#0d1117" />
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='6' fill='%23e6a86b'/><text x='16' y='22' text-anchor='middle' font-family='monospace' font-weight='700' font-size='14' fill='%231a0f00'>NV</text></svg>" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<nv-app></nv-app>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"name": "@ruvnet/nvsim-dashboard",
|
||||
"version": "0.1.0",
|
||||
"description": "Vite + Lit dashboard for the nvsim NV-diamond magnetometer pipeline simulator (ADR-092).",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview --port 4173",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"lit": "^3.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.10",
|
||||
"vite-plugin-pwa": "^0.21.1",
|
||||
"vitest": "^2.1.4"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
/* nvsim dashboard — global styles
|
||||
Ported from `assets/NVsim Dashboard.zip` per ADR-092 §7.1.
|
||||
Per-component scoped styles live in each Lit element. */
|
||||
|
||||
:root {
|
||||
--bg-0: #07090d;
|
||||
--bg-1: #0d1117;
|
||||
--bg-2: #131a23;
|
||||
--bg-3: #1a232f;
|
||||
--line: #1f2a38;
|
||||
--line-2: #2a3848;
|
||||
--ink: #e6edf3;
|
||||
--ink-2: #b8c2cc;
|
||||
--ink-3: #7c8694;
|
||||
--ink-4: #4a5462;
|
||||
--accent: oklch(0.78 0.14 70);
|
||||
--accent-2: oklch(0.78 0.12 195);
|
||||
--accent-3: oklch(0.72 0.18 330);
|
||||
--accent-4: oklch(0.78 0.14 145);
|
||||
--warn: oklch(0.7 0.18 35);
|
||||
--ok: oklch(0.78 0.14 145);
|
||||
--bad: oklch(0.65 0.22 25);
|
||||
--grid: rgba(255, 255, 255, 0.04);
|
||||
--shadow: 0 20px 60px -20px rgba(0, 0, 0, 0.6),
|
||||
0 4px 12px -4px rgba(0, 0, 0, 0.4);
|
||||
--radius: 12px;
|
||||
--radius-sm: 8px;
|
||||
--mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
--sans: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--bg-0: #f4f5f7;
|
||||
--bg-1: #fbfbfc;
|
||||
--bg-2: #ffffff;
|
||||
--bg-3: #f0f2f5;
|
||||
--line: #e3e7ec;
|
||||
--line-2: #d1d7de;
|
||||
--ink: #11161d;
|
||||
--ink-2: #38424f;
|
||||
--ink-3: #6b7684;
|
||||
--ink-4: #9ba4b0;
|
||||
--grid: rgba(0, 0, 0, 0.05);
|
||||
--shadow: 0 12px 40px -16px rgba(15, 30, 55, 0.18),
|
||||
0 2px 8px -2px rgba(15, 30, 55, 0.08);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: var(--sans);
|
||||
background: var(--bg-0);
|
||||
color: var(--ink);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
overflow: hidden;
|
||||
height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
|
||||
button { font-family: inherit; color: inherit; cursor: pointer; }
|
||||
input, select { font-family: inherit; color: inherit; }
|
||||
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--line-2); border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--ink-4); }
|
||||
|
||||
@keyframes pulse { 50% { opacity: 0.5; } }
|
||||
@keyframes dash { to { stroke-dashoffset: -200; } }
|
||||
@keyframes float-up {
|
||||
0% { opacity: 0; transform: translateY(8px); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes diamond-spin {
|
||||
0% { transform: rotateY(0) rotateX(8deg); }
|
||||
100% { transform: rotateY(360deg) rotateX(8deg); }
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
body.reduce-motion *,
|
||||
body.reduce-motion *::before,
|
||||
body.reduce-motion *::after {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* Density (set via class on <body> by setDensity()) */
|
||||
body.density-comfy { font-size: 15px; }
|
||||
body.density-default { font-size: 14px; }
|
||||
body.density-compact { font-size: 13px; }
|
||||
|
|
@ -0,0 +1,283 @@
|
|||
/* App Store — catalog of every WASM edge module + simulator app.
|
||||
*
|
||||
* Mirrors `wifi-densepose-wasm-edge`'s 60+ hot-loadable algorithms and
|
||||
* the `nvsim` simulator. Each card is filterable by category, fuzzy
|
||||
* name search, and maturity (available / beta / research). A toggle on
|
||||
* each card flips activation in the live session — that drives the
|
||||
* dashboard's event log when running. WS transport (future) pushes the
|
||||
* activation set to the connected ESP32 mesh.
|
||||
*
|
||||
* ADR-092 §18.
|
||||
*/
|
||||
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
import { signal, effect } from '@preact/signals-core';
|
||||
import {
|
||||
APPS, CATEGORIES, defaultActivations, fuzzyMatch,
|
||||
type AppCategory, type AppManifest, type AppActivation,
|
||||
} from '../store/apps';
|
||||
import { kvGet, kvSet } from '../store/persistence';
|
||||
import { pushLog } from '../store/appStore';
|
||||
|
||||
const activations = signal<AppActivation[]>(defaultActivations());
|
||||
const query = signal<string>('');
|
||||
const activeCat = signal<AppCategory | 'all'>('all');
|
||||
const statusFilter = signal<'all' | 'available' | 'beta' | 'research'>('all');
|
||||
|
||||
(async () => {
|
||||
const saved = await kvGet<AppActivation[]>('app-activations');
|
||||
if (saved) activations.value = saved;
|
||||
})();
|
||||
|
||||
effect(() => {
|
||||
// Persist activations on change (post-load).
|
||||
const v = activations.value;
|
||||
if (v.length > 0) void kvSet('app-activations', v);
|
||||
});
|
||||
|
||||
@customElement('nv-app-store')
|
||||
export class NvAppStore extends LitElement {
|
||||
@state() private renderTick = 0;
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
background: radial-gradient(ellipse at 50% 30%, var(--bg-2) 0%, var(--bg-0) 70%);
|
||||
padding: 24px;
|
||||
}
|
||||
.head {
|
||||
display: flex; align-items: center; gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ttl {
|
||||
font-size: 22px; font-weight: 700; letter-spacing: -0.02em;
|
||||
color: var(--ink);
|
||||
flex: 1; min-width: 200px;
|
||||
}
|
||||
.ttl small {
|
||||
font-size: 12.5px; font-weight: 400;
|
||||
color: var(--ink-3); margin-left: 8px;
|
||||
}
|
||||
.search {
|
||||
width: 320px; max-width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
color: var(--ink); outline: none;
|
||||
}
|
||||
.search:focus { border-color: var(--accent); }
|
||||
.filters {
|
||||
display: flex; flex-wrap: wrap; gap: 6px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.chip {
|
||||
padding: 4px 10px;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
font-size: 11.5px; color: var(--ink-3);
|
||||
cursor: pointer;
|
||||
font-family: var(--mono);
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
}
|
||||
.chip:hover { color: var(--ink); border-color: var(--line-2); }
|
||||
.chip.on { background: var(--bg-3); border-color: var(--accent); color: var(--ink); }
|
||||
.chip .swatch {
|
||||
width: 7px; height: 7px; border-radius: 50%;
|
||||
}
|
||||
.chip .count { color: var(--ink-3); font-size: 10px; }
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.card {
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px 14px;
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
transition: border-color 0.15s, transform 0.15s;
|
||||
position: relative;
|
||||
}
|
||||
.card:hover { border-color: var(--line-2); transform: translateY(-1px); }
|
||||
.card.active {
|
||||
border-color: oklch(0.78 0.14 145 / 0.7);
|
||||
background: linear-gradient(180deg, var(--bg-2) 0%, oklch(0.78 0.14 145 / 0.04) 100%);
|
||||
}
|
||||
.card-h {
|
||||
display: flex; align-items: flex-start; gap: 8px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.card-h .name {
|
||||
font-size: 13.5px; font-weight: 600; color: var(--ink);
|
||||
flex: 1; line-height: 1.3;
|
||||
}
|
||||
.card-h .swatch {
|
||||
width: 10px; height: 10px; border-radius: 50%;
|
||||
flex-shrink: 0; margin-top: 4px;
|
||||
}
|
||||
.summary {
|
||||
font-size: 12px; color: var(--ink-2); line-height: 1.45;
|
||||
flex: 1;
|
||||
}
|
||||
.meta {
|
||||
display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px;
|
||||
font-family: var(--mono); font-size: 10px;
|
||||
}
|
||||
.badge {
|
||||
padding: 1px 6px; border-radius: 4px;
|
||||
background: var(--bg-3); color: var(--ink-3);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.badge.cat { color: var(--accent); border-color: oklch(0.78 0.14 70 / 0.3); }
|
||||
.badge.status-available { color: var(--ok); border-color: oklch(0.78 0.14 145 / 0.4); }
|
||||
.badge.status-beta { color: var(--warn); border-color: oklch(0.7 0.18 35 / 0.4); }
|
||||
.badge.status-research { color: var(--accent-3); border-color: oklch(0.72 0.18 330 / 0.4); }
|
||||
.badge.budget { color: var(--accent-2); border-color: oklch(0.78 0.12 195 / 0.3); }
|
||||
.card-foot {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding-top: 8px; margin-top: 4px;
|
||||
border-top: 1px solid var(--line);
|
||||
font-size: 11px; color: var(--ink-3);
|
||||
}
|
||||
.toggle {
|
||||
position: relative;
|
||||
width: 32px; height: 18px;
|
||||
background: var(--bg-3); border: 1px solid var(--line-2);
|
||||
border-radius: 999px; cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.toggle::after {
|
||||
content: ''; position: absolute;
|
||||
top: 1px; left: 1px;
|
||||
width: 12px; height: 12px;
|
||||
background: var(--ink-3); border-radius: 50%;
|
||||
transition: transform 0.15s, background 0.15s;
|
||||
}
|
||||
.toggle.on { background: var(--accent); border-color: var(--accent); }
|
||||
.toggle.on::after { background: #1a0f00; transform: translateX(14px); }
|
||||
.events {
|
||||
font-family: var(--mono); font-size: 10px; color: var(--ink-3);
|
||||
flex: 1;
|
||||
}
|
||||
.empty {
|
||||
padding: 40px;
|
||||
text-align: center; color: var(--ink-3);
|
||||
font-size: 13px;
|
||||
}
|
||||
`;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
effect(() => { activations.value; query.value; activeCat.value; statusFilter.value; this.renderTick++; });
|
||||
}
|
||||
|
||||
private isActive(id: string): boolean {
|
||||
return activations.value.find((a) => a.id === id)?.active === true;
|
||||
}
|
||||
|
||||
private toggle(app: AppManifest): void {
|
||||
const next = activations.value.map((a) => a.id === app.id ? { ...a, active: !a.active, lastActivatedAt: Date.now() } : a);
|
||||
activations.value = next;
|
||||
pushLog(this.isActive(app.id) ? 'ok' : 'info', `app <span class="k">${app.id}</span> deactivated`);
|
||||
}
|
||||
|
||||
private filtered(): AppManifest[] {
|
||||
let list = APPS;
|
||||
if (activeCat.value !== 'all') list = list.filter((a) => a.category === activeCat.value);
|
||||
if (statusFilter.value !== 'all') list = list.filter((a) => a.status === statusFilter.value);
|
||||
if (query.value.trim()) {
|
||||
list = list
|
||||
.map((a) => ({ a, s: fuzzyMatch(query.value, a) }))
|
||||
.filter((x) => x.s > 0)
|
||||
.sort((a, b) => b.s - a.s)
|
||||
.map((x) => x.a);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private categoryCounts(): Record<string, number> {
|
||||
const counts: Record<string, number> = { all: APPS.length };
|
||||
for (const k of Object.keys(CATEGORIES)) counts[k] = 0;
|
||||
for (const a of APPS) counts[a.category] = (counts[a.category] ?? 0) + 1;
|
||||
return counts;
|
||||
}
|
||||
|
||||
override render() {
|
||||
const list = this.filtered();
|
||||
const counts = this.categoryCounts();
|
||||
const activeCount = activations.value.filter((a) => a.active).length;
|
||||
return html`
|
||||
<div class="head">
|
||||
<div class="ttl">
|
||||
App Store
|
||||
<small>${APPS.length} edge apps · ${activeCount} active</small>
|
||||
</div>
|
||||
<input class="search" id="app-search" placeholder="Search by name, tag, or category…"
|
||||
.value=${query.value}
|
||||
@input=${(e: Event) => { query.value = (e.target as HTMLInputElement).value; }} />
|
||||
</div>
|
||||
|
||||
<div class="filters">
|
||||
<span class="chip ${activeCat.value === 'all' ? 'on' : ''}"
|
||||
@click=${() => activeCat.value = 'all'}>
|
||||
All<span class="count">${counts.all}</span>
|
||||
</span>
|
||||
${(Object.keys(CATEGORIES) as AppCategory[]).map((k) => html`
|
||||
<span class="chip ${activeCat.value === k ? 'on' : ''}"
|
||||
@click=${() => activeCat.value = k}>
|
||||
<span class="swatch" style=${`background:${CATEGORIES[k].color}`}></span>
|
||||
${CATEGORIES[k].label}
|
||||
<span class="count">${counts[k] ?? 0}</span>
|
||||
</span>
|
||||
`)}
|
||||
<span style="flex:1; min-width:8px"></span>
|
||||
<span class="chip ${statusFilter.value === 'all' ? 'on' : ''}" @click=${() => statusFilter.value = 'all'}>any</span>
|
||||
<span class="chip ${statusFilter.value === 'available' ? 'on' : ''}" @click=${() => statusFilter.value = 'available'}>available</span>
|
||||
<span class="chip ${statusFilter.value === 'beta' ? 'on' : ''}" @click=${() => statusFilter.value = 'beta'}>beta</span>
|
||||
<span class="chip ${statusFilter.value === 'research' ? 'on' : ''}" @click=${() => statusFilter.value = 'research'}>research</span>
|
||||
</div>
|
||||
|
||||
${list.length === 0
|
||||
? html`<div class="empty">No apps match the current filters.</div>`
|
||||
: html`<div class="grid">${list.map((app) => this.card(app))}</div>`}
|
||||
`;
|
||||
}
|
||||
|
||||
private card(app: AppManifest) {
|
||||
const active = this.isActive(app.id);
|
||||
const cat = CATEGORIES[app.category];
|
||||
return html`
|
||||
<div class="card ${active ? 'active' : ''}" data-app-id=${app.id}>
|
||||
<div class="card-h">
|
||||
<span class="swatch" style=${`background:${cat.color}`}></span>
|
||||
<span class="name">${app.name}</span>
|
||||
</div>
|
||||
<div class="summary">${app.summary}</div>
|
||||
<div class="meta">
|
||||
<span class="badge cat">${cat.label}</span>
|
||||
<span class="badge status-${app.status}">${app.status}</span>
|
||||
${app.budget ? html`<span class="badge budget">budget ${app.budget}</span>` : ''}
|
||||
${app.adr ? html`<span class="badge">${app.adr}</span>` : ''}
|
||||
${app.events?.length ? html`<span class="badge">events ${app.events.join('·')}</span>` : ''}
|
||||
</div>
|
||||
<div class="card-foot">
|
||||
<span class="events">${app.crate}</span>
|
||||
<span class="toggle ${active ? 'on' : ''}" role="switch"
|
||||
aria-checked=${active}
|
||||
data-app-toggle=${app.id}
|
||||
@click=${() => this.toggle(app)}></span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
/* Top-level shell: 4-zone grid with rail / topbar / sidebar / scene / inspector / console.
|
||||
* View routing is per-rail-button: the central area swaps between
|
||||
* `<nv-scene>`, `<nv-app-store>`, etc. */
|
||||
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
import './nv-rail';
|
||||
import './nv-topbar';
|
||||
import './nv-sidebar';
|
||||
import './nv-scene';
|
||||
import './nv-inspector';
|
||||
import './nv-console';
|
||||
import './nv-app-store';
|
||||
import './nv-toast';
|
||||
import './nv-modal';
|
||||
import './nv-palette';
|
||||
import './nv-debug-hud';
|
||||
import './nv-settings-drawer';
|
||||
|
||||
export type View = 'scene' | 'apps' | 'settings';
|
||||
|
||||
@customElement('nv-app')
|
||||
export class NvApp extends LitElement {
|
||||
@state() private view: View = 'scene';
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
background: var(--bg-0);
|
||||
}
|
||||
.app {
|
||||
display: grid;
|
||||
grid-template-columns: 56px 280px 1fr 340px;
|
||||
grid-template-rows: 48px 1fr 220px;
|
||||
grid-template-areas:
|
||||
'rail topbar topbar topbar'
|
||||
'rail sidebar main inspector'
|
||||
'rail sidebar console inspector';
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
}
|
||||
nv-rail { grid-area: rail; }
|
||||
nv-topbar { grid-area: topbar; }
|
||||
nv-sidebar { grid-area: sidebar; }
|
||||
.main { grid-area: main; min-width: 0; min-height: 0; position: relative; overflow: hidden; }
|
||||
nv-inspector { grid-area: inspector; }
|
||||
nv-console { grid-area: console; min-height: 0; }
|
||||
@media (max-width: 1180px) {
|
||||
.app {
|
||||
grid-template-columns: 56px 1fr 320px;
|
||||
grid-template-areas:
|
||||
'rail topbar topbar'
|
||||
'rail main inspector'
|
||||
'rail console console';
|
||||
}
|
||||
nv-sidebar { display: none; }
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.app {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 52px 1fr 200px;
|
||||
grid-template-areas:
|
||||
'topbar'
|
||||
'main'
|
||||
'console';
|
||||
}
|
||||
nv-rail, nv-sidebar, nv-inspector { display: none; }
|
||||
}
|
||||
`;
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div class="app">
|
||||
<nv-rail .view=${this.view} @navigate=${(e: CustomEvent<View>) => (this.view = e.detail)}></nv-rail>
|
||||
<nv-topbar></nv-topbar>
|
||||
<nv-sidebar></nv-sidebar>
|
||||
<div class="main">
|
||||
${this.view === 'apps' ? html`<nv-app-store></nv-app-store>` : html`<nv-scene></nv-scene>`}
|
||||
</div>
|
||||
<nv-inspector></nv-inspector>
|
||||
<nv-console></nv-console>
|
||||
</div>
|
||||
<nv-toast></nv-toast>
|
||||
<nv-modal></nv-modal>
|
||||
<nv-palette></nv-palette>
|
||||
<nv-debug-hud></nv-debug-hud>
|
||||
<nv-settings-drawer></nv-settings-drawer>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,249 @@
|
|||
/* Console — log stream + REPL. */
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, query } from 'lit/decorators.js';
|
||||
import { effect } from '@preact/signals-core';
|
||||
import {
|
||||
consoleLines, consoleFilter, consolePaused, pushLog,
|
||||
getClient, seed, theme, expectedWitness, witnessHex, witnessVerified,
|
||||
running,
|
||||
} from '../store/appStore';
|
||||
|
||||
@customElement('nv-console')
|
||||
export class NvConsole extends LitElement {
|
||||
@query('#console-input') private inputEl!: HTMLInputElement;
|
||||
private history: string[] = [];
|
||||
private hIdx = -1;
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: flex; flex-direction: column;
|
||||
background: var(--bg-1);
|
||||
overflow: hidden;
|
||||
}
|
||||
.tabs {
|
||||
display: flex; align-items: center;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 0 10px;
|
||||
gap: 2px;
|
||||
}
|
||||
.tab {
|
||||
padding: 8px 12px;
|
||||
background: transparent; border: none;
|
||||
font-size: 11.5px; color: var(--ink-3);
|
||||
font-family: var(--mono);
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
.tab.active { color: var(--ink); border-bottom-color: var(--accent); }
|
||||
.tab .cnt {
|
||||
background: var(--bg-3); padding: 1px 5px; border-radius: 999px;
|
||||
font-size: 9.5px; color: var(--ink-2); margin-left: 4px;
|
||||
}
|
||||
.spacer { flex: 1; }
|
||||
.tools { display: flex; gap: 4px; padding: 4px 0; }
|
||||
.tools button {
|
||||
width: 24px; height: 24px;
|
||||
background: transparent; border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
color: var(--ink-3);
|
||||
font-size: 11px; cursor: pointer;
|
||||
}
|
||||
.tools button:hover { color: var(--ink); border-color: var(--line-2); }
|
||||
|
||||
.body {
|
||||
flex: 1; overflow-y: auto;
|
||||
font-family: var(--mono);
|
||||
font-size: 11.5px;
|
||||
padding: 6px 0;
|
||||
background: var(--bg-0);
|
||||
}
|
||||
.line {
|
||||
display: grid;
|
||||
grid-template-columns: 70px 60px 1fr;
|
||||
gap: 12px;
|
||||
padding: 2px 12px;
|
||||
color: var(--ink-2);
|
||||
border-left: 2px solid transparent;
|
||||
}
|
||||
.line:hover { background: var(--bg-1); }
|
||||
.ts { color: var(--ink-4); font-size: 10.5px; padding-top: 1px; }
|
||||
.lvl {
|
||||
font-size: 10px; font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: 0.04em; padding-top: 1px;
|
||||
}
|
||||
.line.info .lvl { color: var(--accent-2); }
|
||||
.line.warn .lvl { color: var(--warn); }
|
||||
.line.warn { border-left-color: var(--warn); background: oklch(0.7 0.18 35 / 0.04); }
|
||||
.line.err .lvl { color: var(--bad); }
|
||||
.line.err { border-left-color: var(--bad); background: oklch(0.65 0.22 25 / 0.05); }
|
||||
.line.dbg .lvl { color: var(--ink-3); }
|
||||
.line.ok .lvl { color: var(--ok); }
|
||||
.msg { color: var(--ink); white-space: pre-wrap; word-break: break-word; }
|
||||
|
||||
.input {
|
||||
display: flex; align-items: center;
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--bg-0);
|
||||
padding: 0 10px;
|
||||
height: 32px; gap: 8px;
|
||||
}
|
||||
.prompt { color: var(--accent); font-family: var(--mono); font-size: 12px; }
|
||||
input[type="text"] {
|
||||
flex: 1; background: transparent; border: none; outline: none;
|
||||
color: var(--ink); font-family: var(--mono); font-size: 12px;
|
||||
height: 100%;
|
||||
}
|
||||
input::placeholder { color: var(--ink-4); }
|
||||
`;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
effect(() => {
|
||||
consoleLines.value; consoleFilter.value; consolePaused.value;
|
||||
this.requestUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
override updated(): void {
|
||||
const body = this.renderRoot.querySelector('.body') as HTMLElement | null;
|
||||
if (body) body.scrollTop = body.scrollHeight;
|
||||
}
|
||||
|
||||
private counts(): Record<string, number> {
|
||||
const c: Record<string, number> = { info: 0, warn: 0, err: 0, dbg: 0, ok: 0 };
|
||||
for (const l of consoleLines.value) c[l.level] = (c[l.level] ?? 0) + 1;
|
||||
c.all = consoleLines.value.length;
|
||||
return c;
|
||||
}
|
||||
|
||||
private async exec(line: string): Promise<void> {
|
||||
line = line.trim();
|
||||
if (!line) return;
|
||||
pushLog('info', `<span style="color:var(--accent);">nvsim></span> ${line}`);
|
||||
this.history.push(line); this.hIdx = this.history.length;
|
||||
const [cmd, ...args] = line.split(/\s+/);
|
||||
const arg = args.join(' ');
|
||||
const c = getClient();
|
||||
switch (cmd) {
|
||||
case 'help':
|
||||
pushLog('info', 'commands: help · scene.list · sensor.config · run · pause · seed · proof.verify · clear · theme · status');
|
||||
break;
|
||||
case 'scene.list':
|
||||
pushLog('info', 'scene rebar-walkby-01:');
|
||||
pushLog('info', ' rebar.steel.coil @ [+2.7, 0.0, +0.3] m χ=5000');
|
||||
pushLog('info', ' dipole.heart_proxy @ [-1.4, +0.2, +0.4] m m=1.0e-6 A·m²');
|
||||
pushLog('info', ' loop.mains_60Hz @ [-1.6, -0.4, 0.0] m I=2 A');
|
||||
pushLog('info', ' eddy.door_steel @ [+0.0, +1.8, +0.4] m σ=1e6 S/m');
|
||||
break;
|
||||
case 'sensor.config':
|
||||
pushLog('info', 'NvSensor::cots_defaults() {');
|
||||
pushLog('info', ' pos=[0,0,0], V=1mm³, N=1e12, C=0.03, T2*=200ns');
|
||||
pushLog('info', ' D=2.870 GHz, γe=28 GHz/T, Γ=1.0 MHz, axes=4×〈111〉');
|
||||
pushLog('info', ' δB ≈ 1.18 pT/√Hz (Barry 2020 §III.A) }');
|
||||
break;
|
||||
case 'run':
|
||||
if (c) { await c.run(); running.value = true; pushLog('ok', 'pipeline RUN'); }
|
||||
break;
|
||||
case 'pause':
|
||||
if (c) { await c.pause(); running.value = false; pushLog('warn', 'pipeline PAUSED'); }
|
||||
break;
|
||||
case 'reset':
|
||||
if (c) { await c.reset(); pushLog('info', 'pipeline reset · t=0'); }
|
||||
break;
|
||||
case 'seed': {
|
||||
if (!arg) { pushLog('info', `current seed = 0x${seed.value.toString(16).toUpperCase()}`); break; }
|
||||
const v = BigInt(arg.startsWith('0x') ? arg : '0x' + arg);
|
||||
seed.value = v;
|
||||
if (c) await c.setSeed(v);
|
||||
pushLog('ok', `seed → 0x${v.toString(16).toUpperCase()}`);
|
||||
break;
|
||||
}
|
||||
case 'proof.verify': {
|
||||
if (!c) break;
|
||||
pushLog('dbg', 'computing SHA-256 over 256 frames…');
|
||||
try {
|
||||
const exp = expectedWitness.value;
|
||||
const expBytes = new Uint8Array(32);
|
||||
for (let i = 0; i < 32; i++) expBytes[i] = parseInt(exp.slice(i * 2, i * 2 + 2), 16);
|
||||
const r = await c.verifyWitness(expBytes);
|
||||
if (r.ok) { witnessVerified.value = 'ok'; witnessHex.value = exp; pushLog('ok', `witness ${exp.slice(0, 16)}… matches · determinism gate ✓`); }
|
||||
else { witnessVerified.value = 'fail'; pushLog('err', 'WITNESS MISMATCH'); }
|
||||
} catch (e) { pushLog('err', `verify failed: ${(e as Error).message}`); }
|
||||
break;
|
||||
}
|
||||
case 'clear':
|
||||
consoleLines.value = [];
|
||||
break;
|
||||
case 'theme': {
|
||||
const t = (arg || '').toLowerCase();
|
||||
if (t === 'light' || t === 'dark') { theme.value = t; pushLog('ok', `theme → ${t}`); }
|
||||
else pushLog('info', 'theme [light|dark]');
|
||||
break;
|
||||
}
|
||||
case 'status':
|
||||
pushLog('info', `running=${running.value} seed=0x${seed.value.toString(16).toUpperCase()} verified=${witnessVerified.value}`);
|
||||
break;
|
||||
default:
|
||||
pushLog('err', `unknown command: ${cmd} · try help`);
|
||||
}
|
||||
}
|
||||
|
||||
private onKey = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Enter') { void this.exec(this.inputEl.value); this.inputEl.value = ''; }
|
||||
else if (e.key === 'ArrowUp') {
|
||||
if (this.history.length) {
|
||||
this.hIdx = Math.max(0, this.hIdx - 1);
|
||||
this.inputEl.value = this.history[this.hIdx] ?? '';
|
||||
e.preventDefault();
|
||||
}
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
if (this.history.length) {
|
||||
this.hIdx = Math.min(this.history.length, this.hIdx + 1);
|
||||
this.inputEl.value = this.history[this.hIdx] ?? '';
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
override render() {
|
||||
const c = this.counts();
|
||||
const filter = consoleFilter.value;
|
||||
const visible = consoleLines.value.filter((l) => filter === 'all' || l.level === filter);
|
||||
return html`
|
||||
<div class="tabs">
|
||||
${(['all', 'info', 'warn', 'err', 'dbg'] as const).map((k) => html`
|
||||
<button class="tab ${filter === k ? 'active' : ''}" data-tab=${k}
|
||||
@click=${() => consoleFilter.value = k}>
|
||||
${k} <span class="cnt">${c[k] ?? 0}</span>
|
||||
</button>
|
||||
`)}
|
||||
<span class="spacer"></span>
|
||||
<div class="tools">
|
||||
<button id="clear-log" title="Clear" @click=${() => consoleLines.value = []}>×</button>
|
||||
<button id="pause-log" title="Pause" @click=${() => consolePaused.value = !consolePaused.value}>
|
||||
${consolePaused.value ? '▶' : '❚❚'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="body">
|
||||
${visible.map((l) => {
|
||||
const ts = new Date(l.ts);
|
||||
const tsStr = `${String(ts.getSeconds()).padStart(2, '0')}.${String(ts.getMilliseconds()).padStart(3, '0')}`;
|
||||
// Use innerHTML pass-through via unsafe-html alt: inject raw html via property
|
||||
return html`<div class="line ${l.level}">
|
||||
<div class="ts">${tsStr}</div>
|
||||
<div class="lvl">${l.level}</div>
|
||||
<div class="msg" .innerHTML=${l.msg}></div>
|
||||
</div>`;
|
||||
})}
|
||||
</div>
|
||||
<div class="input">
|
||||
<span class="prompt">nvsim></span>
|
||||
<input id="console-input" type="text"
|
||||
placeholder="help · scene.list · sensor.config · run · proof.verify · clear"
|
||||
@keydown=${this.onKey}/>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
/* Debug HUD toggled with `. Shows render fps, sim t, frames, |B|, SNR. */
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
import { effect } from '@preact/signals-core';
|
||||
import { fps, framesEmitted, bMag, snr, t as simT } from '../store/appStore';
|
||||
|
||||
@customElement('nv-debug-hud')
|
||||
export class NvDebugHud extends LitElement {
|
||||
@state() private open = false;
|
||||
@state() private renderFps = 0;
|
||||
private lastTs = performance.now();
|
||||
private frameCount = 0;
|
||||
private rafId = 0;
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
position: fixed; bottom: 8px; right: 8px;
|
||||
width: 220px;
|
||||
background: rgba(13,17,23,0.85);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
font-family: var(--mono); font-size: 11px;
|
||||
color: var(--ink-2);
|
||||
z-index: 99;
|
||||
display: none;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
:host([open]) { display: block; }
|
||||
.h {
|
||||
display: flex; justify-content: space-between;
|
||||
font-weight: 600; color: var(--ink);
|
||||
margin-bottom: 6px; padding-bottom: 4px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.x { cursor: pointer; color: var(--ink-3); }
|
||||
.row {
|
||||
display: flex; justify-content: space-between;
|
||||
padding: 1px 0;
|
||||
}
|
||||
.k { color: var(--ink-3); }
|
||||
.v { color: var(--ink); }
|
||||
`;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('keydown', this.onKey);
|
||||
effect(() => { fps.value; framesEmitted.value; bMag.value; snr.value; simT.value; this.requestUpdate(); });
|
||||
this.tick();
|
||||
}
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener('keydown', this.onKey);
|
||||
cancelAnimationFrame(this.rafId);
|
||||
}
|
||||
|
||||
private onKey = (e: KeyboardEvent): void => {
|
||||
if (e.key === '`' && !(e.target as HTMLElement).matches('input, textarea')) {
|
||||
this.open = !this.open;
|
||||
this.toggleAttribute('open', this.open);
|
||||
}
|
||||
};
|
||||
|
||||
private tick = (): void => {
|
||||
this.rafId = requestAnimationFrame(this.tick);
|
||||
const now = performance.now();
|
||||
this.frameCount++;
|
||||
if (now - this.lastTs >= 500) {
|
||||
this.renderFps = (this.frameCount * 1000) / (now - this.lastTs);
|
||||
this.frameCount = 0;
|
||||
this.lastTs = now;
|
||||
this.requestUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div class="h"><span>nvsim · debug</span><span class="x" @click=${() => { this.open = false; this.removeAttribute('open'); }}>✕</span></div>
|
||||
<div class="row"><span class="k">render fps</span><span class="v">${this.renderFps.toFixed(1)}</span></div>
|
||||
<div class="row"><span class="k">sim fps</span><span class="v">${fps.value > 0 ? Math.round(fps.value) : '—'}</span></div>
|
||||
<div class="row"><span class="k">frames</span><span class="v">${framesEmitted.value.toString()}</span></div>
|
||||
<div class="row"><span class="k">|B|</span><span class="v">${(bMag.value * 1e9).toFixed(3)} nT</span></div>
|
||||
<div class="row"><span class="k">SNR</span><span class="v">${snr.value > 0 ? snr.value.toFixed(1) : '—'}</span></div>
|
||||
<div class="row"><span class="k">DOM</span><span class="v">${document.querySelectorAll('*').length}</span></div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,268 @@
|
|||
/* Inspector — tabbed: Signal / Frame / Witness. */
|
||||
import { LitElement, html, css, svg } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
import { effect } from '@preact/signals-core';
|
||||
import {
|
||||
traceX, traceY, traceZ, stripBars, lastFrame,
|
||||
witnessHex, expectedWitness, witnessVerified, getClient,
|
||||
pushLog, lastB, bMag,
|
||||
} from '../store/appStore';
|
||||
|
||||
type Tab = 'signal' | 'frame' | 'witness';
|
||||
|
||||
@customElement('nv-inspector')
|
||||
export class NvInspector extends LitElement {
|
||||
@state() private tab: Tab = 'signal';
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: flex; flex-direction: column;
|
||||
background: var(--bg-1);
|
||||
border-left: 1px solid var(--line);
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
.tabs {
|
||||
display: flex; border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.tab {
|
||||
flex: 1;
|
||||
padding: 11px 8px;
|
||||
background: transparent; border: none;
|
||||
font-size: 11.5px; font-weight: 500;
|
||||
color: var(--ink-3);
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer; transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.tab.active { color: var(--ink); border-bottom-color: var(--accent); }
|
||||
.tab:hover { color: var(--ink-2); }
|
||||
.body { padding: 14px; flex: 1; overflow-y: auto; }
|
||||
|
||||
.card {
|
||||
background: var(--bg-2); border: 1px solid var(--line);
|
||||
border-radius: var(--radius); padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.card-h {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.card-h .ttl { font-size: 12px; font-weight: 600; }
|
||||
.badge {
|
||||
font-family: var(--mono); font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
background: oklch(0.78 0.14 195 / 0.12);
|
||||
color: var(--accent-2);
|
||||
border-radius: 4px;
|
||||
border: 1px solid oklch(0.78 0.14 195 / 0.3);
|
||||
}
|
||||
svg { width: 100%; height: 130px; }
|
||||
.frame-strip {
|
||||
height: 28px;
|
||||
display: flex; align-items: flex-end; gap: 1px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.bar {
|
||||
flex: 1;
|
||||
background: linear-gradient(to top, var(--accent-2), var(--accent));
|
||||
border-radius: 1px;
|
||||
min-height: 2px;
|
||||
}
|
||||
table { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: 10.5px; }
|
||||
td { padding: 4px 0; border-bottom: 1px solid var(--line); }
|
||||
td:first-child { color: var(--ink-3); }
|
||||
td:last-child { text-align: right; color: var(--ink); }
|
||||
.hex {
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px;
|
||||
font-family: var(--mono);
|
||||
font-size: 10.5px;
|
||||
color: var(--ink-2);
|
||||
line-height: 1.6;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.hex .magic { color: var(--accent); font-weight: 600; }
|
||||
.witness-box {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--ink-2);
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
word-break: break-all;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.verify-btn {
|
||||
margin-top: 10px;
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-3);
|
||||
color: var(--ink);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
.verify-btn:hover { border-color: var(--accent); }
|
||||
.verify-btn.ok { border-color: var(--ok); color: var(--ok); }
|
||||
.verify-btn.fail { border-color: var(--bad); color: var(--bad); }
|
||||
`;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
effect(() => {
|
||||
traceX.value; traceY.value; traceZ.value; stripBars.value;
|
||||
lastFrame.value; witnessHex.value; witnessVerified.value;
|
||||
lastB.value; bMag.value;
|
||||
this.requestUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
private async verify(): Promise<void> {
|
||||
const c = getClient(); if (!c) return;
|
||||
witnessVerified.value = 'pending';
|
||||
pushLog('info', 'verifying witness over 256 frames…');
|
||||
try {
|
||||
const exp = expectedWitness.value;
|
||||
const expBytes = new Uint8Array(32);
|
||||
for (let i = 0; i < 32; i++) expBytes[i] = parseInt(exp.slice(i * 2, i * 2 + 2), 16);
|
||||
const r = await c.verifyWitness(expBytes);
|
||||
if (r.ok) {
|
||||
witnessVerified.value = 'ok';
|
||||
witnessHex.value = exp;
|
||||
pushLog('ok', `witness ${exp.slice(0, 16)}… matches · determinism gate ✓`);
|
||||
} else {
|
||||
witnessVerified.value = 'fail';
|
||||
const actual = Array.from(r.actual).map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
witnessHex.value = actual;
|
||||
pushLog('err', `WITNESS MISMATCH actual=${actual.slice(0, 16)}…`);
|
||||
}
|
||||
} catch (e) {
|
||||
witnessVerified.value = 'fail';
|
||||
pushLog('err', `verify failed: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private renderSignalTab() {
|
||||
const W = 320, H = 130, cy = 65, scale = 22;
|
||||
const cap = 200;
|
||||
const make = (arr: number[]) => {
|
||||
let p = '';
|
||||
arr.forEach((v, i) => {
|
||||
const x = (i / Math.max(1, cap - 1)) * W;
|
||||
const y = cy - v * scale;
|
||||
p += (i === 0 ? 'M' : 'L') + ` ${x.toFixed(1)} ${y.toFixed(1)} `;
|
||||
});
|
||||
return p;
|
||||
};
|
||||
|
||||
return html`
|
||||
<div class="card">
|
||||
<div class="card-h">
|
||||
<span class="ttl">B-vector trace</span>
|
||||
<span class="badge">3-axis · nT</span>
|
||||
</div>
|
||||
<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none">
|
||||
<line x1="0" y1=${cy} x2=${W} y2=${cy} stroke="var(--line)" stroke-width="0.5"/>
|
||||
${svg`<path id="trace-x" d=${make(traceX.value)} stroke="oklch(0.78 0.14 70)" stroke-width="1.2" fill="none"/>`}
|
||||
${svg`<path id="trace-y" d=${make(traceY.value)} stroke="oklch(0.78 0.12 195)" stroke-width="1.2" fill="none" opacity="0.8"/>`}
|
||||
${svg`<path id="trace-z" d=${make(traceZ.value)} stroke="oklch(0.72 0.18 330)" stroke-width="1.2" fill="none" opacity="0.7"/>`}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-h">
|
||||
<span class="ttl">Frame stream</span>
|
||||
<span class="badge" id="strip-rate">live</span>
|
||||
</div>
|
||||
<div class="frame-strip" id="frame-strip">
|
||||
${stripBars.value.map((v) => html`<div class="bar" style=${`height:${Math.max(4, v * 100)}%`}></div>`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderFrameTab() {
|
||||
const f = lastFrame.value;
|
||||
const bytes = f?.raw;
|
||||
let hex = '';
|
||||
if (bytes) {
|
||||
const arr = Array.from(bytes).map((b) => b.toString(16).padStart(2, '0'));
|
||||
hex = arr.slice(0, 60).join(' ');
|
||||
}
|
||||
return html`
|
||||
<div class="card">
|
||||
<div class="card-h">
|
||||
<span class="ttl">MagFrame v1 fields</span>
|
||||
<span class="badge">60 B</span>
|
||||
</div>
|
||||
<table>
|
||||
<tr><td>magic</td><td id="frame-magic">${f ? '0x' + f.magic.toString(16).toUpperCase() : '—'}</td></tr>
|
||||
<tr><td>version</td><td>${f?.version ?? '—'}</td></tr>
|
||||
<tr><td>flags</td><td>0x${(f?.flags ?? 0).toString(16).padStart(4, '0')}</td></tr>
|
||||
<tr><td>sensor_id</td><td>${f?.sensorId ?? '—'}</td></tr>
|
||||
<tr><td>t_us</td><td>${f ? f.tUs.toString() : '—'}</td></tr>
|
||||
<tr><td>b_pT[0]</td><td id="frame-bx">${f ? f.bPt[0].toFixed(1) : '—'}</td></tr>
|
||||
<tr><td>b_pT[1]</td><td id="frame-by">${f ? f.bPt[1].toFixed(1) : '—'}</td></tr>
|
||||
<tr><td>b_pT[2]</td><td id="frame-bz">${f ? f.bPt[2].toFixed(1) : '—'}</td></tr>
|
||||
<tr><td>noise_floor</td><td>${f ? f.noiseFloorPtSqrtHz.toFixed(2) : '—'}</td></tr>
|
||||
<tr><td>temp_K</td><td>${f ? f.temperatureK.toFixed(1) : '—'}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-h">
|
||||
<span class="ttl">Hex dump</span>
|
||||
<span class="badge">LE</span>
|
||||
</div>
|
||||
<div class="hex" id="frame-hex">${hex || '—'}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderWitnessTab() {
|
||||
const status = witnessVerified.value;
|
||||
const cls = status === 'ok' ? 'ok' : status === 'fail' ? 'fail' : '';
|
||||
const label =
|
||||
status === 'pending' ? 'Verifying…' :
|
||||
status === 'ok' ? '✓ Witness verified · determinism gate' :
|
||||
status === 'fail' ? '✗ Witness mismatch · audit required' :
|
||||
'Verify witness';
|
||||
return html`
|
||||
<div class="card">
|
||||
<div class="card-h">
|
||||
<span class="ttl">Expected (Proof::EXPECTED_WITNESS_HEX)</span>
|
||||
<span class="badge">SHA-256</span>
|
||||
</div>
|
||||
<div class="witness-box" id="expected-witness">${expectedWitness.value || '(loading…)'}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-h">
|
||||
<span class="ttl">Actual (last verify)</span>
|
||||
<span class="badge">SHA-256</span>
|
||||
</div>
|
||||
<div class="witness-box" id="actual-witness">${witnessHex.value || '(not verified yet)'}</div>
|
||||
<button class="verify-btn ${cls}" id="verify-btn" @click=${this.verify}>${label}</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div class="tabs">
|
||||
<button class="tab ${this.tab === 'signal' ? 'active' : ''}" data-pane="signal" @click=${() => this.tab = 'signal'}>Signal</button>
|
||||
<button class="tab ${this.tab === 'frame' ? 'active' : ''}" data-pane="frame" @click=${() => this.tab = 'frame'}>Frame</button>
|
||||
<button class="tab ${this.tab === 'witness' ? 'active' : ''}" data-pane="witness" @click=${() => this.tab = 'witness'}>Witness</button>
|
||||
</div>
|
||||
<div class="body">
|
||||
${this.tab === 'signal' ? this.renderSignalTab()
|
||||
: this.tab === 'frame' ? this.renderFrameTab()
|
||||
: this.renderWitnessTab()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
/* Modal dialog — opened via window.dispatchEvent('nv-modal', { title, body, buttons }). */
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
|
||||
interface ModalButton {
|
||||
label: string;
|
||||
variant?: 'ghost' | 'primary' | 'danger';
|
||||
onClick?: () => void;
|
||||
}
|
||||
interface ModalReq {
|
||||
title: string;
|
||||
body: string;
|
||||
buttons?: ModalButton[];
|
||||
}
|
||||
|
||||
@customElement('nv-modal')
|
||||
export class NvModal extends LitElement {
|
||||
@state() private open = false;
|
||||
@state() private mTitle = '';
|
||||
@state() private mBody = '';
|
||||
@state() private buttons: ModalButton[] = [];
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.55);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 200;
|
||||
display: grid; place-items: center;
|
||||
opacity: 0; pointer-events: none;
|
||||
transition: opacity 0.18s;
|
||||
}
|
||||
:host([open]) { opacity: 1; pointer-events: auto; }
|
||||
.modal {
|
||||
background: var(--bg-1);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 30px 80px -20px rgba(0,0,0,0.7);
|
||||
width: min(520px, 92vw);
|
||||
max-height: 86vh;
|
||||
display: flex; flex-direction: column;
|
||||
transform: translateY(12px) scale(0.98);
|
||||
transition: transform 0.22s cubic-bezier(0.2,0.7,0.3,1);
|
||||
}
|
||||
:host([open]) .modal { transform: translateY(0) scale(1); }
|
||||
.h {
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
.h .ttl { font-size: 14px; font-weight: 600; }
|
||||
.body { padding: 16px; overflow-y: auto; font-size: 13px; color: var(--ink-2); line-height: 1.55; }
|
||||
.f {
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--line);
|
||||
display: flex; gap: 8px; justify-content: flex-end;
|
||||
}
|
||||
button {
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-2); color: var(--ink);
|
||||
}
|
||||
button.ghost { background: transparent; }
|
||||
button.primary { background: var(--accent); border-color: var(--accent); color: #1a0f00; }
|
||||
button.danger { background: var(--bad); border-color: var(--bad); color: #fff; }
|
||||
.close {
|
||||
width: 28px; height: 28px;
|
||||
background: transparent; border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
`;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('nv-modal', this.onModal as EventListener);
|
||||
window.addEventListener('keydown', this.onKey);
|
||||
}
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener('nv-modal', this.onModal as EventListener);
|
||||
window.removeEventListener('keydown', this.onKey);
|
||||
}
|
||||
|
||||
private onModal = (e: Event): void => {
|
||||
const r = (e as CustomEvent).detail as ModalReq;
|
||||
this.mTitle = r.title; this.mBody = r.body;
|
||||
this.buttons = r.buttons ?? [{ label: 'Close', variant: 'primary' }];
|
||||
this.open = true; this.setAttribute('open', '');
|
||||
};
|
||||
|
||||
private onKey = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape' && this.open) this.close();
|
||||
};
|
||||
|
||||
private close(): void { this.open = false; this.removeAttribute('open'); }
|
||||
private clickBtn(b: ModalButton): void { b.onClick?.(); this.close(); }
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div class="modal" role="dialog" aria-modal="true">
|
||||
<div class="h">
|
||||
<div class="ttl">${this.mTitle}</div>
|
||||
<button class="close" @click=${() => this.close()}>×</button>
|
||||
</div>
|
||||
<div class="body" .innerHTML=${this.mBody}></div>
|
||||
<div class="f">
|
||||
${this.buttons.map((b) => html`
|
||||
<button class=${b.variant ?? ''} @click=${() => this.clickBtn(b)}>${b.label}</button>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
export function openModal(req: ModalReq): void {
|
||||
window.dispatchEvent(new CustomEvent('nv-modal', { detail: req }));
|
||||
}
|
||||
|
|
@ -0,0 +1,180 @@
|
|||
/* Command palette ⌘K. */
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, state, query } from 'lit/decorators.js';
|
||||
import { toast } from './nv-toast';
|
||||
import { openModal } from './nv-modal';
|
||||
import {
|
||||
getClient, theme, expectedWitness, witnessHex, witnessVerified, pushLog, running,
|
||||
} from '../store/appStore';
|
||||
|
||||
interface Cmd { ico: string; label: string; kbd?: string; run: () => void; }
|
||||
|
||||
@customElement('nv-palette')
|
||||
export class NvPalette extends LitElement {
|
||||
@state() private open = false;
|
||||
@state() private filter = '';
|
||||
@state() private idx = 0;
|
||||
@query('#palette-input') private inputEl!: HTMLInputElement;
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
position: fixed; inset: 0; z-index: 220;
|
||||
background: rgba(0,0,0,0.5);
|
||||
opacity: 0; pointer-events: none;
|
||||
transition: opacity 0.15s;
|
||||
display: flex; justify-content: center; padding-top: 12vh;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
:host([open]) { opacity: 1; pointer-events: auto; }
|
||||
.palette {
|
||||
width: min(560px, 92vw);
|
||||
background: var(--bg-1);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 30px 80px -20px rgba(0,0,0,0.7);
|
||||
overflow: hidden;
|
||||
display: flex; flex-direction: column;
|
||||
max-height: 60vh;
|
||||
}
|
||||
.input {
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
background: transparent; border: none; outline: none;
|
||||
color: var(--ink); font-size: 14px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.list { flex: 1; overflow-y: auto; padding: 4px; }
|
||||
.item {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.item.active { background: var(--bg-3); }
|
||||
.item .ico { width: 20px; text-align: center; color: var(--accent); }
|
||||
.item .lbl { flex: 1; }
|
||||
.item .kbd {
|
||||
font-family: var(--mono); font-size: 10.5px;
|
||||
color: var(--ink-3);
|
||||
padding: 1px 5px; background: var(--bg-3); border-radius: 4px;
|
||||
}
|
||||
`;
|
||||
|
||||
private cmds: Cmd[] = [
|
||||
{ ico: '▶', label: 'Run pipeline', kbd: 'Space', run: async () => { await getClient()?.run(); running.value = true; toast('Pipeline running', '▶'); } },
|
||||
{ ico: '❚', label: 'Pause pipeline', run: async () => { await getClient()?.pause(); running.value = false; toast('Paused', '❚❚'); } },
|
||||
{ ico: '⟳', label: 'Reset pipeline', kbd: '⌘R', run: () => openModal({
|
||||
title: 'Reset pipeline?',
|
||||
body: '<p>Clears the frame stream and rewinds <code>t</code> to 0.</p>',
|
||||
buttons: [
|
||||
{ label: 'Cancel', variant: 'ghost' },
|
||||
{ label: 'Reset', variant: 'danger', onClick: async () => { await getClient()?.reset(); pushLog('warn', 'pipeline reset · t=0'); toast('Pipeline reset', '⟳'); } },
|
||||
],
|
||||
}) },
|
||||
{ ico: '✓', label: 'Verify witness', run: async () => {
|
||||
const c = getClient(); if (!c) return;
|
||||
witnessVerified.value = 'pending';
|
||||
const exp = expectedWitness.value;
|
||||
const eb = new Uint8Array(32);
|
||||
for (let i = 0; i < 32; i++) eb[i] = parseInt(exp.slice(i * 2, i * 2 + 2), 16);
|
||||
const r = await c.verifyWitness(eb);
|
||||
if (r.ok) { witnessVerified.value = 'ok'; witnessHex.value = exp; toast('Witness verified', '✓'); }
|
||||
else { witnessVerified.value = 'fail'; toast('Witness mismatch!', '✗'); }
|
||||
} },
|
||||
{ ico: '☼', label: 'Toggle theme', kbd: '⌘/', run: () => { theme.value = theme.value === 'dark' ? 'light' : 'dark'; } },
|
||||
{ ico: '⚙', label: 'Open settings', kbd: '⌘,', run: () => window.dispatchEvent(new CustomEvent('open-settings')) },
|
||||
{ ico: '?', label: 'Keyboard shortcuts…', run: () => openModal({
|
||||
title: 'Keyboard shortcuts',
|
||||
body: `<div style="display:grid;grid-template-columns:auto 1fr;gap:6px 16px;font-size:13px;">
|
||||
<div><code>⌘K / Ctrl K</code></div><div>Command palette</div>
|
||||
<div><code>Space</code></div><div>Play / pause</div>
|
||||
<div><code>⌘R</code></div><div>Reset</div>
|
||||
<div><code>⌘,</code></div><div>Settings</div>
|
||||
<div><code>⌘/</code></div><div>Toggle theme</div>
|
||||
<div><code>\`</code></div><div>Debug HUD</div>
|
||||
<div><code>1 · 2 · 3</code></div><div>Inspector tabs</div>
|
||||
<div><code>Esc</code></div><div>Close modal/palette</div>
|
||||
<div><code>/</code></div><div>Focus REPL</div>
|
||||
</div>`,
|
||||
buttons: [{ label: 'Close', variant: 'primary' }],
|
||||
}) },
|
||||
{ ico: 'i', label: 'About nvsim…', run: () => openModal({
|
||||
title: 'About nvsim',
|
||||
body: `<p><b>nvsim</b> is a deterministic, byte-reproducible forward simulator for nitrogen-vacancy diamond magnetometry.</p>
|
||||
<p>This dashboard runs nvsim as WASM in a Web Worker. Same <code>(scene, config, seed)</code> → byte-identical SHA-256 witness across runs and machines.</p>
|
||||
<p>License: MIT OR Apache-2.0 · See ADR-089, ADR-092.</p>`,
|
||||
buttons: [{ label: 'Close', variant: 'primary' }],
|
||||
}) },
|
||||
];
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('keydown', this.onKey);
|
||||
window.addEventListener('nv-palette', this.onOpen as EventListener);
|
||||
}
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener('keydown', this.onKey);
|
||||
window.removeEventListener('nv-palette', this.onOpen as EventListener);
|
||||
}
|
||||
|
||||
private onKey = (e: KeyboardEvent): void => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault();
|
||||
this.openPal();
|
||||
} else if (e.key === 'Escape' && this.open) {
|
||||
this.closePal();
|
||||
} else if (this.open) {
|
||||
if (e.key === 'ArrowDown') { this.idx = Math.min(this.cmds.length - 1, this.idx + 1); e.preventDefault(); }
|
||||
else if (e.key === 'ArrowUp') { this.idx = Math.max(0, this.idx - 1); e.preventDefault(); }
|
||||
else if (e.key === 'Enter') { this.runIdx(); e.preventDefault(); }
|
||||
}
|
||||
};
|
||||
|
||||
private onOpen = (): void => this.openPal();
|
||||
|
||||
private openPal(): void {
|
||||
this.open = true; this.setAttribute('open', '');
|
||||
this.filter = ''; this.idx = 0;
|
||||
setTimeout(() => this.inputEl?.focus(), 0);
|
||||
}
|
||||
private closePal(): void { this.open = false; this.removeAttribute('open'); }
|
||||
|
||||
private filtered(): Cmd[] {
|
||||
if (!this.filter.trim()) return this.cmds;
|
||||
const q = this.filter.toLowerCase();
|
||||
return this.cmds.filter((c) => c.label.toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
private runIdx(): void {
|
||||
const f = this.filtered();
|
||||
const c = f[this.idx];
|
||||
if (c) { c.run(); this.closePal(); }
|
||||
}
|
||||
|
||||
override render() {
|
||||
const items = this.filtered();
|
||||
return html`
|
||||
<div class="palette" data-id="palette">
|
||||
<div class="input">
|
||||
<input id="palette-input" type="text" placeholder="Type a command…"
|
||||
.value=${this.filter}
|
||||
@input=${(e: Event) => { this.filter = (e.target as HTMLInputElement).value; this.idx = 0; }} />
|
||||
</div>
|
||||
<div class="list">
|
||||
${items.map((c, i) => html`
|
||||
<div class="item ${i === this.idx ? 'active' : ''}" @click=${() => { this.idx = i; this.runIdx(); }}>
|
||||
<span class="ico">${c.ico}</span>
|
||||
<span class="lbl">${c.label}</span>
|
||||
${c.kbd ? html`<span class="kbd">${c.kbd}</span>` : ''}
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
/* Left rail navigation. Emits `navigate` events for view switching. */
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import type { View } from './nv-app';
|
||||
|
||||
@customElement('nv-rail')
|
||||
export class NvRail extends LitElement {
|
||||
@property() view: View = 'scene';
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
gap: 4px;
|
||||
background: var(--bg-1);
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
.logo {
|
||||
width: 36px; height: 36px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, oklch(0.78 0.14 70) 0%, oklch(0.55 0.16 30) 100%);
|
||||
display: grid; place-items: center;
|
||||
color: #1a0f00;
|
||||
font-weight: 700;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
margin-bottom: 14px;
|
||||
box-shadow: 0 4px 12px -2px oklch(0.55 0.16 30 / 0.35);
|
||||
}
|
||||
.btn {
|
||||
width: 36px; height: 36px;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: var(--ink-3);
|
||||
display: grid; place-items: center;
|
||||
transition: all 0.15s;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn:hover { color: var(--ink); background: var(--bg-2); }
|
||||
.btn.active {
|
||||
color: var(--ink);
|
||||
background: var(--bg-3);
|
||||
border-color: var(--line-2);
|
||||
}
|
||||
.btn.active::before {
|
||||
content: ''; position: absolute; left: -10px; top: 8px; bottom: 8px;
|
||||
width: 2px; background: var(--accent); border-radius: 2px;
|
||||
}
|
||||
.spacer { flex: 1; }
|
||||
svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.8; }
|
||||
`;
|
||||
|
||||
private navigate(v: View): void {
|
||||
this.dispatchEvent(new CustomEvent('navigate', { detail: v }));
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div class="logo">NV</div>
|
||||
<button class="btn ${this.view === 'scene' ? 'active' : ''}" data-id="scene-btn" title="Scene"
|
||||
@click=${() => this.navigate('scene')}>
|
||||
<svg viewBox="0 0 24 24"><path d="M12 2L3 7l9 5 9-5-9-5zm0 13l-9-5v6l9 5 9-5v-6l-9 5z"/></svg>
|
||||
</button>
|
||||
<button class="btn ${this.view === 'apps' ? 'active' : ''}" data-id="apps-btn" title="App Store"
|
||||
@click=${() => this.navigate('apps')}>
|
||||
<svg viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
|
||||
</button>
|
||||
<button class="btn" title="Inspector" @click=${() => this.navigate('scene')}>
|
||||
<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.6" y2="16.6"/></svg>
|
||||
</button>
|
||||
<button class="btn" title="Witness">
|
||||
<svg viewBox="0 0 24 24"><path d="M9 12l2 2 4-4M21 12c0 4.97-4.03 9-9 9s-9-4.03-9-9 4.03-9 9-9 9 4.03 9 9z"/></svg>
|
||||
</button>
|
||||
<div class="spacer"></div>
|
||||
<button class="btn" data-id="settings-btn" title="Settings"
|
||||
@click=${() => this.dispatchEvent(new CustomEvent('open-settings', { bubbles: true, composed: true }))}>
|
||||
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06A1.65 1.65 0 0015 19.4a1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06A1.65 1.65 0 004.6 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09A1.65 1.65 0 0015 4.6a1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
/* Scene canvas — SVG with draggable sources, NV crystal sensor, field lines, mini ODMR. */
|
||||
import { LitElement, html, css, svg } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
import { effect } from '@preact/signals-core';
|
||||
import { lastB, bMag, fps, snr, motionReduced } from '../store/appStore';
|
||||
|
||||
interface SceneItem { id: string; x: number; y: number; color: string; name: string; }
|
||||
|
||||
@customElement('nv-scene')
|
||||
export class NvScene extends LitElement {
|
||||
@state() private items: SceneItem[] = [
|
||||
{ id: 'rebar', x: 740, y: 240, color: 'oklch(0.72 0.18 330)', name: 'rebar.steel' },
|
||||
{ id: 'heart', x: 220, y: 180, color: 'oklch(0.78 0.14 195)', name: 'heart_proxy' },
|
||||
{ id: 'mains', x: 180, y: 380, color: 'oklch(0.72 0.18 330)', name: 'mains_60Hz' },
|
||||
{ id: 'door', x: 800, y: 470, color: 'oklch(0.78 0.14 145)', name: 'door.steel' },
|
||||
];
|
||||
@state() private dragging: string | null = null;
|
||||
@state() private selected: string | null = null;
|
||||
private dragOffset = { dx: 0, dy: 0 };
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block; height: 100%; width: 100%;
|
||||
background: radial-gradient(ellipse at 50% 30%, var(--bg-2) 0%, var(--bg-0) 70%);
|
||||
position: relative; overflow: hidden;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.grid {
|
||||
position: absolute; inset: 0;
|
||||
background-image:
|
||||
linear-gradient(var(--grid) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--grid) 1px, transparent 1px);
|
||||
background-size: 32px 32px;
|
||||
pointer-events: none;
|
||||
mask-image: radial-gradient(ellipse at center, black 40%, transparent 100%);
|
||||
}
|
||||
svg { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
.stat-card {
|
||||
background: rgba(13,17,23,0.7);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 12px;
|
||||
font-size: 11px;
|
||||
min-width: 96px;
|
||||
}
|
||||
[data-theme="light"] .stat-card { background: rgba(255,255,255,0.85); }
|
||||
.stat-card .lbl {
|
||||
color: var(--ink-3);
|
||||
text-transform: uppercase; font-weight: 600; letter-spacing: 0.06em; font-size: 9.5px;
|
||||
}
|
||||
.stat-card .val { font-family: var(--mono); font-size: 16px; font-weight: 600; margin-top: 2px; }
|
||||
.stat-card .val.amber { color: var(--accent); }
|
||||
.stat-card .val.cyan { color: var(--accent-2); }
|
||||
.stat-card .val.mint { color: var(--accent-4); }
|
||||
.scene-readout {
|
||||
position: absolute; top: 14px; right: 14px;
|
||||
display: flex; gap: 8px; z-index: 5;
|
||||
}
|
||||
.draggable { cursor: grab; transition: filter 0.15s; }
|
||||
.draggable:hover { filter: brightness(1.15) drop-shadow(0 0 6px currentColor); }
|
||||
.draggable.dragging { cursor: grabbing; filter: brightness(1.25) drop-shadow(0 0 10px currentColor); }
|
||||
.field-line { stroke-dasharray: 4 6; }
|
||||
@keyframes dash { to { stroke-dashoffset: -200; } }
|
||||
.field-line.anim { animation: dash 4s linear infinite; }
|
||||
@keyframes spin {
|
||||
0% { transform: rotateY(0) rotateX(8deg); }
|
||||
100% { transform: rotateY(360deg) rotateX(8deg); }
|
||||
}
|
||||
.crystal { transform-origin: center; transform-box: fill-box; }
|
||||
.crystal.anim { animation: spin 12s linear infinite; }
|
||||
.label {
|
||||
font-family: var(--mono); font-size: 11px; fill: var(--ink-2);
|
||||
pointer-events: none;
|
||||
}
|
||||
`;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
effect(() => { lastB.value; bMag.value; fps.value; snr.value; motionReduced.value; this.requestUpdate(); });
|
||||
window.addEventListener('pointermove', this.onPointerMove);
|
||||
window.addEventListener('pointerup', this.onPointerUp);
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener('pointermove', this.onPointerMove);
|
||||
window.removeEventListener('pointerup', this.onPointerUp);
|
||||
}
|
||||
|
||||
private onDown = (id: string, e: PointerEvent): void => {
|
||||
e.preventDefault();
|
||||
this.dragging = id;
|
||||
this.selected = id;
|
||||
const item = this.items.find((i) => i.id === id);
|
||||
if (!item) return;
|
||||
const svgEl = this.renderRoot.querySelector('svg') as SVGSVGElement | null;
|
||||
if (!svgEl) return;
|
||||
const pt = this.toSvg(e, svgEl);
|
||||
this.dragOffset = { dx: pt.x - item.x, dy: pt.y - item.y };
|
||||
};
|
||||
|
||||
private onPointerMove = (e: PointerEvent): void => {
|
||||
if (!this.dragging) return;
|
||||
const svgEl = this.renderRoot.querySelector('svg') as SVGSVGElement | null;
|
||||
if (!svgEl) return;
|
||||
const pt = this.toSvg(e, svgEl);
|
||||
this.items = this.items.map((it) =>
|
||||
it.id === this.dragging
|
||||
? { ...it, x: pt.x - this.dragOffset.dx, y: pt.y - this.dragOffset.dy }
|
||||
: it,
|
||||
);
|
||||
};
|
||||
|
||||
private onPointerUp = (): void => { this.dragging = null; };
|
||||
|
||||
private toSvg(e: PointerEvent, svgEl: SVGSVGElement): { x: number; y: number } {
|
||||
const r = svgEl.getBoundingClientRect();
|
||||
const vbX = ((e.clientX - r.left) / r.width) * 1000;
|
||||
const vbY = ((e.clientY - r.top) / r.height) * 600;
|
||||
return { x: vbX, y: vbY };
|
||||
}
|
||||
|
||||
override render() {
|
||||
const b = lastB.value;
|
||||
const bnT = [b[0] * 1e9, b[1] * 1e9, b[2] * 1e9];
|
||||
const bMagNT = bMag.value * 1e9;
|
||||
const animClass = motionReduced.value ? '' : 'anim';
|
||||
|
||||
return html`
|
||||
<div class="grid"></div>
|
||||
<svg viewBox="0 0 1000 600" preserveAspectRatio="xMidYMid meet" id="scene-svg">
|
||||
<defs>
|
||||
<radialGradient id="g-sensor" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0" stop-color="oklch(0.78 0.14 70)" stop-opacity="0.4"/>
|
||||
<stop offset="1" stop-color="oklch(0.78 0.14 70)" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<filter id="glow"><feGaussianBlur stdDeviation="3" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
|
||||
</defs>
|
||||
|
||||
<!-- Field lines from each source to sensor -->
|
||||
${this.items.map((it) => svg`
|
||||
<line class="field-line ${animClass}" x1=${it.x} y1=${it.y}
|
||||
x2="500" y2="320"
|
||||
stroke=${it.color} stroke-width="1" stroke-opacity="0.5"/>
|
||||
`)}
|
||||
|
||||
<!-- Source primitives -->
|
||||
${this.items.map((it) => svg`
|
||||
<g class=${`draggable ${this.dragging === it.id ? 'dragging' : ''} ${this.selected === it.id ? 'selected' : ''}`}
|
||||
data-id=${it.id} data-source-id=${it.id}
|
||||
transform=${`translate(${it.x.toFixed(0)},${it.y.toFixed(0)})`}
|
||||
@pointerdown=${(e: PointerEvent) => this.onDown(it.id, e)}>
|
||||
<ellipse cx="0" cy="0" rx="32" ry="22" fill=${it.color} fill-opacity="0.18"
|
||||
stroke=${it.color} stroke-width="1.2"/>
|
||||
<circle cx="0" cy="0" r="4" fill=${it.color}/>
|
||||
<text class="label" x="0" y="40" text-anchor="middle">${it.name}</text>
|
||||
</g>
|
||||
`)}
|
||||
|
||||
<!-- Sensor (NV diamond) at center -->
|
||||
<g id="sensor-g" class="draggable" data-id="sensor" transform="translate(500, 320)">
|
||||
<circle cx="0" cy="0" r="46" fill="url(#g-sensor)"/>
|
||||
<g class=${`crystal ${animClass}`} stroke="oklch(0.78 0.14 70)" stroke-width="2"
|
||||
fill="oklch(0.78 0.14 70 / 0.08)" filter="url(#glow)">
|
||||
<polygon points="0,-22 19,-7 12,18 -12,18 -19,-7"/>
|
||||
</g>
|
||||
<circle cx="0" cy="0" r="3" fill="var(--accent)"/>
|
||||
<text class="label" x="0" y="56" text-anchor="middle">
|
||||
sensor · 〈111〉 NV
|
||||
</text>
|
||||
<text class="label" x="0" y="72" text-anchor="middle">
|
||||
B_in: <tspan fill="var(--accent)" id="b-in-svg">[${bnT[0].toFixed(2)}, ${bnT[1].toFixed(2)}, ${bnT[2].toFixed(2)}] nT</tspan>
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<div class="scene-readout">
|
||||
<div class="stat-card">
|
||||
<div class="lbl">|B|</div>
|
||||
<div class="val amber" id="bmag-readout">${bMagNT.toFixed(3)} nT</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="lbl">FPS</div>
|
||||
<div class="val cyan" id="fps-readout">${fps.value > 0 ? Math.round(fps.value) : '—'}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="lbl">SNR</div>
|
||||
<div class="val mint" id="snr-readout">${snr.value > 0 ? snr.value.toFixed(1) : '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
/* Settings drawer — theme / density / motion / auto-update. */
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
import { effect } from '@preact/signals-core';
|
||||
import { theme, density, motionReduced, autoUpdate, transport, wsUrl } from '../store/appStore';
|
||||
|
||||
@customElement('nv-settings-drawer')
|
||||
export class NvSettingsDrawer extends LitElement {
|
||||
@state() private open = false;
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
position: fixed; top: 0; right: 0; bottom: 0;
|
||||
width: 420px; max-width: 100vw;
|
||||
background: var(--bg-1);
|
||||
border-left: 1px solid var(--line);
|
||||
z-index: 51;
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
display: flex; flex-direction: column;
|
||||
box-shadow: -20px 0 60px -20px rgba(0,0,0,0.5);
|
||||
}
|
||||
:host([open]) { transform: translateX(0); }
|
||||
.scrim {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
z-index: 50;
|
||||
opacity: 0; pointer-events: none;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
:host([open]) .scrim { opacity: 1; pointer-events: auto; }
|
||||
.h {
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
.h .ttl { font-size: 14px; font-weight: 600; }
|
||||
.body { flex: 1; overflow-y: auto; padding: 16px; }
|
||||
.group { margin-bottom: 22px; }
|
||||
.group h4 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 11px; font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: 0.08em;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.row {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.row:last-child { border-bottom: 0; }
|
||||
.row .lbl { font-size: 13px; }
|
||||
.row .desc { font-size: 11.5px; color: var(--ink-3); margin-top: 2px; }
|
||||
.row > div:first-child { flex: 1; padding-right: 12px; }
|
||||
.seg {
|
||||
display: inline-flex;
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px;
|
||||
}
|
||||
.seg button {
|
||||
padding: 4px 10px;
|
||||
background: transparent; border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 11.5px; color: var(--ink-3);
|
||||
font-family: var(--mono);
|
||||
cursor: pointer;
|
||||
}
|
||||
.seg button.on { background: var(--bg-1); color: var(--ink); }
|
||||
.toggle {
|
||||
position: relative;
|
||||
width: 36px; height: 20px;
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.toggle::after {
|
||||
content: ''; position: absolute;
|
||||
top: 2px; left: 2px;
|
||||
width: 14px; height: 14px;
|
||||
background: var(--ink-3);
|
||||
border-radius: 50%;
|
||||
transition: transform 0.15s, background 0.15s;
|
||||
}
|
||||
.toggle.on { background: var(--accent); border-color: var(--accent); }
|
||||
.toggle.on::after { background: #1a0f00; transform: translateX(16px); }
|
||||
.close {
|
||||
width: 28px; height: 28px;
|
||||
background: transparent; border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
input[type="text"] {
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
color: var(--ink); font-family: var(--mono); font-size: 12px;
|
||||
outline: none;
|
||||
}
|
||||
`;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
effect(() => { theme.value; density.value; motionReduced.value; autoUpdate.value; transport.value; wsUrl.value; this.requestUpdate(); });
|
||||
window.addEventListener('open-settings', () => { this.open = true; this.setAttribute('open', ''); });
|
||||
}
|
||||
|
||||
private close(): void { this.open = false; this.removeAttribute('open'); }
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div class="scrim" @click=${() => this.close()}></div>
|
||||
<div class="h">
|
||||
<div class="ttl">Settings</div>
|
||||
<button class="close" @click=${() => this.close()}>×</button>
|
||||
</div>
|
||||
<div class="body">
|
||||
<div class="group">
|
||||
<h4>Appearance</h4>
|
||||
<div class="row">
|
||||
<div><div class="lbl">Theme</div></div>
|
||||
<div class="seg">
|
||||
<button class=${theme.value === 'dark' ? 'on' : ''} @click=${() => theme.value = 'dark'}>dark</button>
|
||||
<button class=${theme.value === 'light' ? 'on' : ''} @click=${() => theme.value = 'light'}>light</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div>
|
||||
<div class="lbl">Density</div>
|
||||
<div class="desc">Affects panel padding and font scale.</div>
|
||||
</div>
|
||||
<div class="seg">
|
||||
<button class=${density.value === 'comfy' ? 'on' : ''} @click=${() => density.value = 'comfy'}>comfy</button>
|
||||
<button class=${density.value === 'default' ? 'on' : ''} @click=${() => density.value = 'default'}>default</button>
|
||||
<button class=${density.value === 'compact' ? 'on' : ''} @click=${() => density.value = 'compact'}>compact</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div>
|
||||
<div class="lbl">Reduce motion</div>
|
||||
<div class="desc">Disable rotating crystal & field-line animation.</div>
|
||||
</div>
|
||||
<span class="toggle ${motionReduced.value ? 'on' : ''}"
|
||||
@click=${() => motionReduced.value = !motionReduced.value}></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<h4>Pipeline</h4>
|
||||
<div class="row">
|
||||
<div><div class="lbl">Auto-rerun on edit</div>
|
||||
<div class="desc">Restart pipeline when scene/config changes.</div></div>
|
||||
<span class="toggle ${autoUpdate.value ? 'on' : ''}"
|
||||
@click=${() => autoUpdate.value = !autoUpdate.value}></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<h4>Transport</h4>
|
||||
<div class="row">
|
||||
<div><div class="lbl">Mode</div></div>
|
||||
<div class="seg">
|
||||
<button class=${transport.value === 'wasm' ? 'on' : ''} @click=${() => transport.value = 'wasm'}>WASM</button>
|
||||
<button class=${transport.value === 'ws' ? 'on' : ''} @click=${() => transport.value = 'ws'}>WS</button>
|
||||
</div>
|
||||
</div>
|
||||
${transport.value === 'ws' ? html`
|
||||
<div class="row">
|
||||
<div><div class="lbl">WS URL</div></div>
|
||||
<input type="text" placeholder="ws://localhost:7878" .value=${wsUrl.value}
|
||||
@input=${(e: Event) => wsUrl.value = (e.target as HTMLInputElement).value} />
|
||||
</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
/* Sidebar — Scene panel, NV sensor panel, Tunables, Pipeline diagram. */
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
import { effect } from '@preact/signals-core';
|
||||
import { fs, fmod, dtMs, noiseEnabled, running } from '../store/appStore';
|
||||
|
||||
@customElement('nv-sidebar')
|
||||
export class NvSidebar extends LitElement {
|
||||
static styles = css`
|
||||
:host {
|
||||
display: flex; flex-direction: column; gap: 14px;
|
||||
padding: 14px; overflow-y: auto;
|
||||
background: var(--bg-1); border-right: 1px solid var(--line);
|
||||
}
|
||||
.panel {
|
||||
background: var(--bg-2); border: 1px solid var(--line);
|
||||
border-radius: var(--radius); padding: 12px;
|
||||
}
|
||||
.panel-h {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
font-size: 11px; font-weight: 600; color: var(--ink-3);
|
||||
text-transform: uppercase; letter-spacing: 0.08em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.count {
|
||||
background: var(--bg-3); color: var(--ink-2);
|
||||
padding: 1px 6px; border-radius: 999px;
|
||||
font-family: var(--mono); font-size: 10px;
|
||||
text-transform: none; letter-spacing: 0;
|
||||
}
|
||||
.scene-item {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.scene-item:hover { background: var(--bg-3); }
|
||||
.scene-item .swatch { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
.scene-item .name { font-size: 13px; flex: 1; }
|
||||
.scene-item .meta { font-family: var(--mono); font-size: 10.5px; color: var(--ink-3); }
|
||||
.field-row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 6px 0; font-size: 12.5px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.field-row:last-child { border-bottom: 0; }
|
||||
.field-row .lbl { color: var(--ink-3); }
|
||||
.field-row .val { font-family: var(--mono); color: var(--ink); font-size: 12px; }
|
||||
.slider-row { padding: 8px 0; border-bottom: 1px solid var(--line); }
|
||||
.slider-row:last-child { border-bottom: 0; padding-bottom: 0; }
|
||||
.slider-row .top { display: flex; justify-content: space-between; margin-bottom: 6px; font-size: 12px; }
|
||||
.slider-row .top .lbl { color: var(--ink-3); }
|
||||
.slider-row .top .val { font-family: var(--mono); color: var(--ink); }
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none; appearance: none;
|
||||
width: 100%; height: 4px;
|
||||
background: var(--bg-3); border-radius: 2px; outline: none;
|
||||
}
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none; appearance: none;
|
||||
width: 14px; height: 14px; border-radius: 50%;
|
||||
background: var(--accent); cursor: pointer;
|
||||
border: 2px solid var(--bg-2);
|
||||
box-shadow: 0 0 0 1px var(--line-2);
|
||||
}
|
||||
.pipeline { display: flex; gap: 4px; align-items: center; flex-wrap: wrap; margin-top: 6px; }
|
||||
.stage {
|
||||
flex: 1; min-width: 50px;
|
||||
padding: 4px 6px;
|
||||
background: var(--bg-3); border: 1px solid var(--line);
|
||||
border-radius: 6px; font-size: 9.5px; text-align: center;
|
||||
color: var(--ink-2); font-family: var(--mono);
|
||||
}
|
||||
.stage.live { border-color: var(--accent-2); color: var(--accent-2); }
|
||||
.stage-arrow { color: var(--ink-4); font-size: 10px; }
|
||||
`;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
effect(() => { fs.value; fmod.value; dtMs.value; noiseEnabled.value; running.value; this.requestUpdate(); });
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div class="panel">
|
||||
<div class="panel-h">Scene <span class="count">4 sources</span></div>
|
||||
<div class="scene-item">
|
||||
<span class="swatch" style="background:oklch(0.72 0.18 330)"></span>
|
||||
<span class="name">rebar.steel.coil</span>
|
||||
<span class="meta">χ=5000</span>
|
||||
</div>
|
||||
<div class="scene-item">
|
||||
<span class="swatch" style="background:oklch(0.78 0.14 195)"></span>
|
||||
<span class="name">heart_proxy</span>
|
||||
<span class="meta">1e-6 A·m²</span>
|
||||
</div>
|
||||
<div class="scene-item">
|
||||
<span class="swatch" style="background:oklch(0.72 0.18 330)"></span>
|
||||
<span class="name">mains_60Hz</span>
|
||||
<span class="meta">2 A · 60 Hz</span>
|
||||
</div>
|
||||
<div class="scene-item">
|
||||
<span class="swatch" style="background:oklch(0.78 0.14 145)"></span>
|
||||
<span class="name">door.steel</span>
|
||||
<span class="meta">eddy</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-h">NV sensor <span class="count">COTS</span></div>
|
||||
<div class="field-row"><span class="lbl">V</span><span class="val">1 mm³</span></div>
|
||||
<div class="field-row"><span class="lbl">N</span><span class="val">1e12 NV</span></div>
|
||||
<div class="field-row"><span class="lbl">C</span><span class="val">0.030</span></div>
|
||||
<div class="field-row"><span class="lbl">T₂*</span><span class="val">200 ns</span></div>
|
||||
<div class="field-row"><span class="lbl">δB</span><span class="val">1.18 pT/√Hz</span></div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-h">Tunables</div>
|
||||
<div class="slider-row">
|
||||
<div class="top"><span class="lbl">Sample rate</span><span class="val">${(fs.value / 1000).toFixed(1)} kHz</span></div>
|
||||
<input type="range" min="1000" max="100000" .value=${String(fs.value)}
|
||||
@input=${(e: Event) => fs.value = +(e.target as HTMLInputElement).value} />
|
||||
</div>
|
||||
<div class="slider-row">
|
||||
<div class="top"><span class="lbl">Lockin f_mod</span><span class="val">${(fmod.value / 1000).toFixed(3)} kHz</span></div>
|
||||
<input type="range" min="100" max="5000" .value=${String(fmod.value)}
|
||||
@input=${(e: Event) => fmod.value = +(e.target as HTMLInputElement).value} />
|
||||
</div>
|
||||
<div class="slider-row">
|
||||
<div class="top"><span class="lbl">Integration t</span><span class="val">${dtMs.value.toFixed(1)} ms</span></div>
|
||||
<input type="range" min="0.1" max="10" step="0.1" .value=${String(dtMs.value)}
|
||||
@input=${(e: Event) => dtMs.value = +(e.target as HTMLInputElement).value} />
|
||||
</div>
|
||||
<div class="slider-row">
|
||||
<div class="top"><span class="lbl">Shot noise</span><span class="val">${noiseEnabled.value ? 'ON' : 'OFF'}</span></div>
|
||||
<input type="range" min="0" max="1" .value=${noiseEnabled.value ? '1' : '0'}
|
||||
@input=${(e: Event) => noiseEnabled.value = (e.target as HTMLInputElement).value === '1'} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-h">Pipeline</div>
|
||||
<div class="pipeline">
|
||||
<span class="stage ${running.value ? 'live' : ''}">scene</span>
|
||||
<span class="stage-arrow">→</span>
|
||||
<span class="stage ${running.value ? 'live' : ''}">B-S</span>
|
||||
<span class="stage-arrow">→</span>
|
||||
<span class="stage ${running.value ? 'live' : ''}">prop</span>
|
||||
<span class="stage-arrow">→</span>
|
||||
<span class="stage ${running.value ? 'live' : ''}">NV</span>
|
||||
<span class="stage-arrow">→</span>
|
||||
<span class="stage ${running.value ? 'live' : ''}">ADC</span>
|
||||
<span class="stage-arrow">→</span>
|
||||
<span class="stage ${running.value ? 'live' : ''}">frame</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
/* Toast notification — shown briefly via window.dispatchEvent('nv-toast', detail). */
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
|
||||
@customElement('nv-toast')
|
||||
export class NvToast extends LitElement {
|
||||
@state() private visible = false;
|
||||
@state() private msg = '';
|
||||
@state() private icon = '✓';
|
||||
private timer: number | null = null;
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
position: fixed; bottom: 24px; left: 50%;
|
||||
transform: translateX(-50%) translateY(80px);
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 14px;
|
||||
font-size: 12.5px;
|
||||
box-shadow: var(--shadow);
|
||||
z-index: 100;
|
||||
opacity: 0; pointer-events: none;
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
:host([visible]) {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
.icon { color: var(--accent); }
|
||||
`;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('nv-toast', this.onToast as EventListener);
|
||||
}
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener('nv-toast', this.onToast as EventListener);
|
||||
}
|
||||
|
||||
private onToast = (e: Event): void => {
|
||||
const detail = (e as CustomEvent).detail as { msg?: string; icon?: string };
|
||||
this.msg = detail.msg ?? 'Done';
|
||||
this.icon = detail.icon ?? '✓';
|
||||
this.visible = true;
|
||||
this.setAttribute('visible', '');
|
||||
if (this.timer !== null) window.clearTimeout(this.timer);
|
||||
this.timer = window.setTimeout(() => {
|
||||
this.visible = false;
|
||||
this.removeAttribute('visible');
|
||||
}, 1800);
|
||||
};
|
||||
|
||||
override render() {
|
||||
return html`<span class="icon">${this.icon}</span><span>${this.msg}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
export function toast(msg: string, icon = '✓'): void {
|
||||
window.dispatchEvent(new CustomEvent('nv-toast', { detail: { msg, icon } }));
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
/* Topbar — breadcrumbs, transport pill, FPS pill, seed pill, controls. */
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
import { effect } from '@preact/signals-core';
|
||||
import {
|
||||
fps, transportLabel, seed, theme, sceneName,
|
||||
running, getClient,
|
||||
} from '../store/appStore';
|
||||
|
||||
@customElement('nv-topbar')
|
||||
export class NvTopbar extends LitElement {
|
||||
static styles = css`
|
||||
:host {
|
||||
display: flex; align-items: center;
|
||||
padding: 0 16px; gap: 12px;
|
||||
background: var(--bg-1);
|
||||
border-bottom: 1px solid var(--line);
|
||||
z-index: 10;
|
||||
}
|
||||
.crumbs { display: flex; align-items: center; gap: 8px; font-size: 12.5px; color: var(--ink-3); }
|
||||
.crumbs .sep { color: var(--ink-4); }
|
||||
.crumbs .cur { color: var(--ink); font-weight: 500; }
|
||||
.spacer { flex: 1; }
|
||||
.pill {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 5px 10px;
|
||||
background: var(--bg-2); border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
font-size: 12px; color: var(--ink-2);
|
||||
font-family: var(--mono); font-weight: 500;
|
||||
}
|
||||
.pill .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--ok); box-shadow: 0 0 6px var(--ok); animation: pulse 2s infinite; }
|
||||
.pill.wasm .dot { background: var(--accent-2); box-shadow: 0 0 6px var(--accent-2); }
|
||||
.pill.seed { color: var(--ink-3); }
|
||||
.pill.seed b { color: var(--accent); font-weight: 600; }
|
||||
button {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: var(--bg-2); border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
font-size: 12.5px; font-weight: 500; color: var(--ink);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
button:hover { border-color: var(--line-2); background: var(--bg-3); }
|
||||
button.primary { background: var(--accent); border-color: var(--accent); color: #1a0f00; }
|
||||
button.primary:hover { filter: brightness(1.08); }
|
||||
button.ghost { background: transparent; }
|
||||
`;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
effect(() => { fps.value; transportLabel.value; seed.value; theme.value; sceneName.value; running.value; this.requestUpdate(); });
|
||||
}
|
||||
|
||||
private async toggleRun(): Promise<void> {
|
||||
const c = getClient(); if (!c) return;
|
||||
if (running.value) { await c.pause(); running.value = false; }
|
||||
else { await c.run(); running.value = true; }
|
||||
}
|
||||
private async reset(): Promise<void> {
|
||||
const c = getClient(); if (!c) return;
|
||||
await c.reset();
|
||||
}
|
||||
private toggleTheme(): void {
|
||||
theme.value = theme.value === 'dark' ? 'light' : 'dark';
|
||||
}
|
||||
|
||||
override render() {
|
||||
const seedHex = seed.value.toString(16).toUpperCase().padStart(8, '0');
|
||||
return html`
|
||||
<div class="crumbs">
|
||||
<span class="home">RuView</span><span class="sep">/</span>
|
||||
<span>nvsim</span><span class="sep">/</span>
|
||||
<span class="cur" id="scene-name">${sceneName.value}</span>
|
||||
</div>
|
||||
<div class="spacer"></div>
|
||||
<span class="pill" id="fps-pill">
|
||||
<span class="dot"></span>
|
||||
<span id="fps-val">${fps.value > 0 ? (fps.value / 1000).toFixed(2) + ' kHz' : 'idle'}</span>
|
||||
</span>
|
||||
<span class="pill wasm" id="transport-pill"><span class="dot"></span>${transportLabel.value}</span>
|
||||
<span class="pill seed" id="seed-pill">seed: <b>0x${seedHex}</b></span>
|
||||
<button class="ghost" id="theme-btn" title="Toggle theme" @click=${this.toggleTheme}>
|
||||
${theme.value === 'dark' ? '☼' : '☾'}
|
||||
</button>
|
||||
<button id="reset-btn" @click=${this.reset}>↺ Reset</button>
|
||||
<button class="primary" id="run-btn" @click=${this.toggleRun}>
|
||||
${running.value ? '❚❚ Pause' : '▶ Run'}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
/* nvsim dashboard entry — boots the WasmClient, mounts <nv-app>. */
|
||||
import './app.css';
|
||||
import './components/nv-app';
|
||||
import { effect } from '@preact/signals-core';
|
||||
|
||||
import { WasmClient } from './transport/WasmClient';
|
||||
import {
|
||||
setClient, transport, theme, density, motionReduced,
|
||||
pushLog, expectedWitness, framesEmitted, fps, lastB, bMag,
|
||||
pushTrace, pushStripBar, lastFrame, sceneJson, witnessHex,
|
||||
} from './store/appStore';
|
||||
import { kvGet, kvSet } from './store/persistence';
|
||||
|
||||
function applyTheme(t: string): void {
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
}
|
||||
function applyDensity(d: string): void {
|
||||
document.body.classList.remove('density-comfy', 'density-default', 'density-compact');
|
||||
document.body.classList.add(`density-${d}`);
|
||||
}
|
||||
function applyMotion(reduced: boolean): void {
|
||||
document.body.classList.toggle('reduce-motion', reduced);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
// Restore persisted prefs
|
||||
const t = (await kvGet<'dark' | 'light'>('theme')) ?? 'dark';
|
||||
const d = (await kvGet<'comfy' | 'default' | 'compact'>('density')) ?? 'default';
|
||||
const m = (await kvGet<boolean>('motionReduced')) ?? false;
|
||||
theme.value = t; applyTheme(t);
|
||||
density.value = d; applyDensity(d);
|
||||
motionReduced.value = m; applyMotion(m);
|
||||
|
||||
// React to changes → persist
|
||||
effect(() => { applyTheme(theme.value); kvSet('theme', theme.value); });
|
||||
effect(() => { applyDensity(density.value); kvSet('density', density.value); });
|
||||
effect(() => { applyMotion(motionReduced.value); kvSet('motionReduced', motionReduced.value); });
|
||||
|
||||
// Boot WASM client
|
||||
const client = new WasmClient();
|
||||
setClient(client);
|
||||
|
||||
pushLog('info', 'nvsim — booting WASM runtime');
|
||||
client.onEvent((ev) => {
|
||||
if (ev.type === 'log') pushLog(ev.level, ev.msg);
|
||||
if (ev.type === 'fps') fps.value = ev.value;
|
||||
if (ev.type === 'state') {
|
||||
framesEmitted.value = BigInt(ev.framesEmitted);
|
||||
}
|
||||
});
|
||||
|
||||
client.onFrames((batch) => {
|
||||
if (batch.frames.length === 0) return;
|
||||
const last = batch.frames[batch.frames.length - 1];
|
||||
lastFrame.value = last;
|
||||
const bx = last.bPt[0] * 1e-12; // pT → T
|
||||
const by = last.bPt[1] * 1e-12;
|
||||
const bz = last.bPt[2] * 1e-12;
|
||||
lastB.value = [bx, by, bz];
|
||||
bMag.value = Math.sqrt(bx * bx + by * by + bz * bz);
|
||||
// For trace display we use nT scale.
|
||||
pushTrace([bx * 1e9, by * 1e9, bz * 1e9]);
|
||||
const amp = Math.min(1, Math.abs(bz * 1e9) / 5 + 0.3);
|
||||
pushStripBar(amp);
|
||||
});
|
||||
|
||||
try {
|
||||
const info = await client.boot();
|
||||
expectedWitness.value = info.expectedWitnessHex;
|
||||
pushLog('ok', `WASM module ready · nvsim@${info.buildVersion} · magic=0x${info.frameMagic.toString(16).toUpperCase()}`);
|
||||
pushLog('info', `expected witness · ${info.expectedWitnessHex.slice(0, 16)}…`);
|
||||
|
||||
// Load reference scene by default.
|
||||
sceneJson.value = '(reference scene)';
|
||||
transport.value = 'wasm';
|
||||
} catch (e) {
|
||||
pushLog('err', `boot failed: ${(e as Error).message}`);
|
||||
}
|
||||
|
||||
// Auto-verify witness once at boot — proves WASM determinism contract.
|
||||
try {
|
||||
const exp = expectedWitness.value;
|
||||
if (exp) {
|
||||
const expBytes = new Uint8Array(32);
|
||||
for (let i = 0; i < 32; i++) expBytes[i] = parseInt(exp.slice(i * 2, i * 2 + 2), 16);
|
||||
const r = await client.verifyWitness(expBytes);
|
||||
if (r.ok) {
|
||||
witnessHex.value = exp;
|
||||
pushLog('ok', `witness verified · determinism gate ✓`);
|
||||
} else {
|
||||
const actual = Array.from(r.actual)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
witnessHex.value = actual;
|
||||
pushLog('err', `WITNESS MISMATCH · expected ${exp.slice(0, 16)}… got ${actual.slice(0, 16)}…`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
pushLog('warn', `witness verify skipped: ${(e as Error).message}`);
|
||||
}
|
||||
})();
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
/* 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');
|
||||
|
||||
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,309 @@
|
|||
/* 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';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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',
|
||||
},
|
||||
|
||||
// ── 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',
|
||||
},
|
||||
{
|
||||
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',
|
||||
},
|
||||
{
|
||||
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',
|
||||
},
|
||||
{
|
||||
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'],
|
||||
},
|
||||
{
|
||||
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',
|
||||
},
|
||||
{
|
||||
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'],
|
||||
},
|
||||
|
||||
// ── 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' },
|
||||
{ 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);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
/* Common NvsimClient interface — both WasmClient and WsClient implement it.
|
||||
* Dashboard binds to this interface and never to a concrete client.
|
||||
* Aligns with ADR-092 §5.2.
|
||||
*/
|
||||
|
||||
export interface PipelineConfigJson {
|
||||
digitiser?: {
|
||||
f_s_hz: number;
|
||||
f_mod_hz: number;
|
||||
lp_cutoff_hz: number;
|
||||
};
|
||||
sensor?: {
|
||||
n_centers: number;
|
||||
contrast: number;
|
||||
t2_star_s: number;
|
||||
shot_noise_disabled?: boolean;
|
||||
};
|
||||
dt_s?: number | null;
|
||||
}
|
||||
|
||||
export interface SceneJson {
|
||||
dipoles: { position: [number, number, number]; moment: [number, number, number] }[];
|
||||
loops: {
|
||||
centre: [number, number, number];
|
||||
normal: [number, number, number];
|
||||
radius: number;
|
||||
current: number;
|
||||
n_segments: number;
|
||||
}[];
|
||||
ferrous: {
|
||||
position: [number, number, number];
|
||||
volume: number;
|
||||
susceptibility: number;
|
||||
}[];
|
||||
eddy: unknown[];
|
||||
sensors: [number, number, number][];
|
||||
ambient_field: [number, number, number];
|
||||
}
|
||||
|
||||
export interface MagFrameRecord {
|
||||
magic: number;
|
||||
version: number;
|
||||
flags: number;
|
||||
sensorId: number;
|
||||
tUs: bigint;
|
||||
bPt: [number, number, number];
|
||||
sigmaPt: [number, number, number];
|
||||
noiseFloorPtSqrtHz: number;
|
||||
temperatureK: number;
|
||||
raw: Uint8Array;
|
||||
}
|
||||
|
||||
export interface MagFrameBatch {
|
||||
frames: MagFrameRecord[];
|
||||
bytes: Uint8Array;
|
||||
}
|
||||
|
||||
export type NvsimEvent =
|
||||
| { type: 'log'; level: 'info' | 'warn' | 'err' | 'dbg' | 'ok'; msg: string }
|
||||
| { type: 'witness'; hex: string }
|
||||
| { type: 'fps'; value: number }
|
||||
| { type: 'state'; running: boolean; t: number; framesEmitted: number };
|
||||
|
||||
export interface RunOpts { frames?: number }
|
||||
|
||||
export interface NvsimClient {
|
||||
loadScene(scene: SceneJson): Promise<void>;
|
||||
setConfig(cfg: PipelineConfigJson): Promise<void>;
|
||||
setSeed(seed: bigint): Promise<void>;
|
||||
reset(): Promise<void>;
|
||||
run(opts?: RunOpts): Promise<void>;
|
||||
pause(): Promise<void>;
|
||||
step(direction: 'fwd' | 'back', dtMs: number): Promise<void>;
|
||||
|
||||
onFrames(cb: (batch: MagFrameBatch) => void): void;
|
||||
onEvent(cb: (ev: NvsimEvent) => void): void;
|
||||
|
||||
generateWitness(samples: number): Promise<Uint8Array>;
|
||||
verifyWitness(expected: Uint8Array): Promise<{ ok: true } | { ok: false; actual: Uint8Array }>;
|
||||
exportProofBundle(): Promise<Blob>;
|
||||
|
||||
buildId(): Promise<string>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
/** Parse one MagFrame from a 60-byte slice. Layout matches `nvsim::frame`. */
|
||||
export function parseMagFrame(view: DataView, offset: number, raw: Uint8Array): MagFrameRecord {
|
||||
// v1 layout: magic(u32) | version(u16) | flags(u16) | sensor_id(u16) | _reserved(u16) |
|
||||
// t_us(u64) | b_pt[3](f32) | sigma_pt[3](f32) | noise_floor_pt_sqrt_hz(f32) |
|
||||
// temperature_k(f32) — 60 bytes total. All little-endian.
|
||||
const magic = view.getUint32(offset + 0, true);
|
||||
const version = view.getUint16(offset + 4, true);
|
||||
const flags = view.getUint16(offset + 6, true);
|
||||
const sensorId = view.getUint16(offset + 8, true);
|
||||
// skip 2 bytes reserved at offset+10
|
||||
const tUs = view.getBigUint64(offset + 12, true);
|
||||
const bx = view.getFloat32(offset + 20, true);
|
||||
const by = view.getFloat32(offset + 24, true);
|
||||
const bz = view.getFloat32(offset + 28, true);
|
||||
const sx = view.getFloat32(offset + 32, true);
|
||||
const sy = view.getFloat32(offset + 36, true);
|
||||
const sz = view.getFloat32(offset + 40, true);
|
||||
const noiseFloorPtSqrtHz = view.getFloat32(offset + 44, true);
|
||||
const temperatureK = view.getFloat32(offset + 48, true);
|
||||
return {
|
||||
magic,
|
||||
version,
|
||||
flags,
|
||||
sensorId,
|
||||
tUs,
|
||||
bPt: [bx, by, bz],
|
||||
sigmaPt: [sx, sy, sz],
|
||||
noiseFloorPtSqrtHz,
|
||||
temperatureK,
|
||||
raw: raw.subarray(offset, offset + 60),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseFrameBatch(bytes: Uint8Array): MagFrameRecord[] {
|
||||
const frameSize = 60;
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
const out: MagFrameRecord[] = [];
|
||||
for (let off = 0; off + frameSize <= bytes.byteLength; off += frameSize) {
|
||||
out.push(parseMagFrame(view, off, bytes));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
/* Default `NvsimClient` implementation. Talks to the Web Worker that
|
||||
* hosts the nvsim WASM module. ADR-092 §5.4 + §6.3. */
|
||||
|
||||
import {
|
||||
type NvsimClient,
|
||||
type SceneJson,
|
||||
type PipelineConfigJson,
|
||||
type RunOpts,
|
||||
type MagFrameBatch,
|
||||
type NvsimEvent,
|
||||
parseFrameBatch,
|
||||
} from './NvsimClient';
|
||||
|
||||
interface PendingRequest<T = unknown> {
|
||||
resolve: (v: T) => void;
|
||||
reject: (err: Error) => void;
|
||||
}
|
||||
|
||||
export interface WasmBootInfo {
|
||||
buildVersion: string;
|
||||
frameMagic: number;
|
||||
frameBytes: number;
|
||||
expectedWitnessHex: string;
|
||||
}
|
||||
|
||||
export class WasmClient implements NvsimClient {
|
||||
private worker: Worker;
|
||||
private nextId = 1;
|
||||
private pending = new Map<number, PendingRequest<unknown>>();
|
||||
private frameSubs = new Set<(b: MagFrameBatch) => void>();
|
||||
private eventSubs = new Set<(e: NvsimEvent) => void>();
|
||||
private bootInfo: WasmBootInfo | null = null;
|
||||
|
||||
constructor() {
|
||||
this.worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' });
|
||||
this.worker.addEventListener('message', (ev) => this.onMessage(ev));
|
||||
this.worker.addEventListener('error', (e) =>
|
||||
this.eventSubs.forEach((s) => s({ type: 'log', level: 'err', msg: String(e.message) })),
|
||||
);
|
||||
}
|
||||
|
||||
private onMessage(ev: MessageEvent): void {
|
||||
const m = ev.data as { type: string; id?: number; [k: string]: unknown };
|
||||
if (m.type === 'frames') {
|
||||
const buf = m.batch as ArrayBuffer;
|
||||
const bytes = new Uint8Array(buf);
|
||||
const frames = parseFrameBatch(bytes);
|
||||
const batch: MagFrameBatch = { frames, bytes };
|
||||
this.frameSubs.forEach((s) => s(batch));
|
||||
const fps = m.fps as number;
|
||||
if (fps > 0) {
|
||||
this.eventSubs.forEach((s) => s({ type: 'fps', value: fps }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (m.type === 'state') {
|
||||
this.eventSubs.forEach((s) =>
|
||||
s({
|
||||
type: 'state',
|
||||
running: Boolean(m.running),
|
||||
t: 0,
|
||||
framesEmitted: Number(m.framesEmitted ?? 0),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (m.type === 'ready') {
|
||||
return;
|
||||
}
|
||||
if (m.type === 'err' && m.id == null) {
|
||||
this.eventSubs.forEach((s) =>
|
||||
s({ type: 'log', level: 'err', msg: String(m.msg) }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (typeof m.id === 'number' && this.pending.has(m.id)) {
|
||||
const p = this.pending.get(m.id)!;
|
||||
this.pending.delete(m.id);
|
||||
if (m.type === 'err') p.reject(new Error(String(m.msg)));
|
||||
else p.resolve(m);
|
||||
}
|
||||
}
|
||||
|
||||
private rpc<T = unknown>(msg: Record<string, unknown>, transfer: Transferable[] = []): Promise<T> {
|
||||
const id = this.nextId++;
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
this.pending.set(id, { resolve: resolve as (v: unknown) => void, reject });
|
||||
this.worker.postMessage({ ...msg, id }, transfer);
|
||||
});
|
||||
}
|
||||
|
||||
async boot(): Promise<WasmBootInfo> {
|
||||
if (this.bootInfo) return this.bootInfo;
|
||||
const r = await this.rpc<{ buildVersion: string; frameMagic: number; frameBytes: number; expectedWitnessHex: string }>(
|
||||
{ type: 'boot' },
|
||||
);
|
||||
this.bootInfo = {
|
||||
buildVersion: r.buildVersion,
|
||||
frameMagic: r.frameMagic,
|
||||
frameBytes: r.frameBytes,
|
||||
expectedWitnessHex: r.expectedWitnessHex,
|
||||
};
|
||||
return this.bootInfo;
|
||||
}
|
||||
|
||||
async loadScene(scene: SceneJson): Promise<void> {
|
||||
await this.rpc({ type: 'setScene', json: JSON.stringify(scene) });
|
||||
}
|
||||
|
||||
async setConfig(cfg: PipelineConfigJson): Promise<void> {
|
||||
await this.rpc({ type: 'setConfig', json: JSON.stringify(cfg) });
|
||||
}
|
||||
|
||||
async setSeed(seed: bigint): Promise<void> {
|
||||
await this.rpc({ type: 'setSeed', seed: Number(seed & 0xFFFFFFFFn) });
|
||||
}
|
||||
|
||||
async reset(): Promise<void> {
|
||||
await this.rpc({ type: 'reset' });
|
||||
}
|
||||
|
||||
async run(_opts?: RunOpts): Promise<void> {
|
||||
await this.rpc({ type: 'run' });
|
||||
}
|
||||
|
||||
async pause(): Promise<void> {
|
||||
await this.rpc({ type: 'pause' });
|
||||
}
|
||||
|
||||
async step(_direction: 'fwd' | 'back', _dtMs: number): Promise<void> {
|
||||
await this.rpc({ type: 'step' });
|
||||
}
|
||||
|
||||
onFrames(cb: (batch: MagFrameBatch) => void): void { this.frameSubs.add(cb); }
|
||||
onEvent(cb: (ev: NvsimEvent) => void): void { this.eventSubs.add(cb); }
|
||||
|
||||
async generateWitness(samples: number): Promise<Uint8Array> {
|
||||
const r = await this.rpc<{ witness: ArrayBuffer; hex: string }>({ type: 'witnessGenerate', samples });
|
||||
return new Uint8Array(r.witness);
|
||||
}
|
||||
|
||||
async verifyWitness(expected: Uint8Array): Promise<{ ok: true } | { ok: false; actual: Uint8Array }> {
|
||||
const buf = expected.slice().buffer;
|
||||
const r = await this.rpc<{ ok: boolean; actual: ArrayBuffer; actualHex: string }>(
|
||||
{ type: 'witnessVerify', samples: 256, expected: buf },
|
||||
[buf],
|
||||
);
|
||||
if (r.ok) return { ok: true };
|
||||
return { ok: false, actual: new Uint8Array(r.actual) };
|
||||
}
|
||||
|
||||
async exportProofBundle(): Promise<Blob> {
|
||||
// Bundle = REFERENCE_SCENE_JSON + computed witness hex + version. Wraps
|
||||
// the same artifacts `Proof::generate` produces natively. ADR-092 §6.1.
|
||||
const w = await this.generateWitness(256);
|
||||
const hex = Array.from(w).map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
const info = this.bootInfo ?? (await this.boot());
|
||||
const manifest = JSON.stringify(
|
||||
{
|
||||
kind: 'nvsim-proof-bundle',
|
||||
version: info.buildVersion,
|
||||
seed: '0x0000002A',
|
||||
nSamples: 256,
|
||||
witness: hex,
|
||||
expected: info.expectedWitnessHex,
|
||||
ok: hex === info.expectedWitnessHex,
|
||||
ts: new Date().toISOString(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
return new Blob([manifest], { type: 'application/json' });
|
||||
}
|
||||
|
||||
async buildId(): Promise<string> {
|
||||
const r = await this.rpc<{ buildId: string }>({ type: 'buildId' });
|
||||
return r.buildId;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.worker.terminate();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,250 @@
|
|||
/* Web Worker hosting the nvsim WASM module.
|
||||
*
|
||||
* Boots `/nvsim-pkg/nvsim.js`, instantiates `WasmPipeline`, then
|
||||
* postMessage-RPCs with the main thread. Frame batches are returned
|
||||
* as `ArrayBuffer` transfers so we don't pay a copy on the hot path.
|
||||
*
|
||||
* ADR-092 §5.4.
|
||||
*/
|
||||
|
||||
/// <reference lib="WebWorker" />
|
||||
|
||||
const ws = self as unknown as DedicatedWorkerGlobalScope;
|
||||
|
||||
interface WasmPipelineApi {
|
||||
run(n: number): Uint8Array;
|
||||
runWithWitness(n: number): { frames: Uint8Array; witness: Uint8Array; frameCount: number };
|
||||
free?: () => void;
|
||||
}
|
||||
type WasmPipelineCtor = new (sceneJson: string, configJson: string, seed: number) => WasmPipelineApi;
|
||||
type WasmPipelineStatic = WasmPipelineCtor & {
|
||||
buildVersion(): string;
|
||||
frameMagic(): number;
|
||||
frameBytes(): number;
|
||||
};
|
||||
|
||||
interface NvsimPkg {
|
||||
default: (input?: unknown) => Promise<unknown>;
|
||||
WasmPipeline: WasmPipelineStatic;
|
||||
referenceSceneJson: () => string;
|
||||
expectedReferenceWitnessHex: () => string;
|
||||
hexWitness: (b: Uint8Array) => string;
|
||||
referenceWitness: () => Uint8Array;
|
||||
}
|
||||
|
||||
let _WasmPipeline!: WasmPipelineStatic;
|
||||
let referenceSceneJson!: () => string;
|
||||
let expectedReferenceWitnessHex!: () => string;
|
||||
let hexWitness!: (b: Uint8Array) => string;
|
||||
let referenceWitness!: () => Uint8Array;
|
||||
|
||||
async function loadPkg(): Promise<void> {
|
||||
const baseHref = `${ws.location.origin}/`;
|
||||
const pkgUrl = new URL('nvsim-pkg/nvsim.js', baseHref).href;
|
||||
const pkg = (await import(/* @vite-ignore */ pkgUrl)) as NvsimPkg;
|
||||
await pkg.default();
|
||||
_WasmPipeline = pkg.WasmPipeline;
|
||||
referenceSceneJson = pkg.referenceSceneJson;
|
||||
expectedReferenceWitnessHex = pkg.expectedReferenceWitnessHex;
|
||||
hexWitness = pkg.hexWitness;
|
||||
referenceWitness = pkg.referenceWitness;
|
||||
}
|
||||
|
||||
let pipeline: WasmPipelineApi | null = null;
|
||||
let configJson = '';
|
||||
let sceneJson = '';
|
||||
let seed = BigInt(0xCAFEBABE);
|
||||
|
||||
let running = false;
|
||||
let timer: number | null = null;
|
||||
let framesEmitted = 0;
|
||||
let tStart = 0;
|
||||
|
||||
function ensureRebuild(): void {
|
||||
if (!sceneJson) sceneJson = referenceSceneJson();
|
||||
if (!configJson) {
|
||||
configJson = JSON.stringify({
|
||||
digitiser: { f_s_hz: 10000, f_mod_hz: 1000 },
|
||||
sensor: {
|
||||
gamma_fwhm_hz: 1.0e6,
|
||||
t1_s: 5.0e-3,
|
||||
t2_s: 1.0e-6,
|
||||
t2_star_s: 200e-9,
|
||||
contrast: 0.03,
|
||||
n_spins: 1.0e12,
|
||||
shot_noise_disabled: false,
|
||||
},
|
||||
dt_s: null,
|
||||
});
|
||||
}
|
||||
pipeline?.free?.();
|
||||
pipeline = new _WasmPipeline(sceneJson, configJson, Number(seed & 0xFFFFFFFFn));
|
||||
}
|
||||
|
||||
function post(msg: unknown, transfer: Transferable[] = []): void {
|
||||
// postMessage Transferable overload: pass transfer list as 2nd arg
|
||||
(ws.postMessage as (msg: unknown, t: Transferable[]) => void)(msg, transfer);
|
||||
}
|
||||
|
||||
function startTimer(): void {
|
||||
if (timer !== null) return;
|
||||
tStart = performance.now();
|
||||
framesEmitted = 0;
|
||||
const tick = (): void => {
|
||||
if (!running || !pipeline) return;
|
||||
// Per-tick: simulate 32 frames; push as one batch.
|
||||
const n = 32;
|
||||
const bytes = pipeline.run(n);
|
||||
framesEmitted += n;
|
||||
const elapsed = (performance.now() - tStart) / 1000;
|
||||
const fps = elapsed > 0 ? framesEmitted / elapsed : 0;
|
||||
post(
|
||||
{ type: 'frames', batch: bytes.buffer, count: n, fps, framesEmitted },
|
||||
[bytes.buffer],
|
||||
);
|
||||
timer = ws.setTimeout(tick, 16);
|
||||
};
|
||||
timer = ws.setTimeout(tick, 0);
|
||||
}
|
||||
|
||||
function stopTimer(): void {
|
||||
if (timer !== null) {
|
||||
ws.clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
ws.addEventListener('message', async (ev: MessageEvent): Promise<void> => {
|
||||
const m = ev.data as { type: string; id?: number; [k: string]: unknown };
|
||||
try {
|
||||
switch (m.type) {
|
||||
case 'boot': {
|
||||
await loadPkg();
|
||||
ensureRebuild();
|
||||
post({
|
||||
type: 'booted',
|
||||
id: m.id,
|
||||
buildVersion: _WasmPipeline.buildVersion(),
|
||||
frameMagic: _WasmPipeline.frameMagic(),
|
||||
frameBytes: _WasmPipeline.frameBytes(),
|
||||
expectedWitnessHex: expectedReferenceWitnessHex(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'setScene': {
|
||||
sceneJson = m.json as string;
|
||||
ensureRebuild();
|
||||
post({ type: 'ack', id: m.id });
|
||||
break;
|
||||
}
|
||||
case 'setConfig': {
|
||||
configJson = m.json as string;
|
||||
ensureRebuild();
|
||||
post({ type: 'ack', id: m.id });
|
||||
break;
|
||||
}
|
||||
case 'setSeed': {
|
||||
seed = BigInt(m.seed as string | number | bigint);
|
||||
ensureRebuild();
|
||||
post({ type: 'ack', id: m.id });
|
||||
break;
|
||||
}
|
||||
case 'reset': {
|
||||
stopTimer();
|
||||
running = false;
|
||||
ensureRebuild();
|
||||
framesEmitted = 0;
|
||||
post({ type: 'ack', id: m.id });
|
||||
post({ type: 'state', running: false, framesEmitted });
|
||||
break;
|
||||
}
|
||||
case 'run': {
|
||||
if (!pipeline) ensureRebuild();
|
||||
running = true;
|
||||
startTimer();
|
||||
post({ type: 'ack', id: m.id });
|
||||
post({ type: 'state', running: true, framesEmitted });
|
||||
break;
|
||||
}
|
||||
case 'pause': {
|
||||
running = false;
|
||||
stopTimer();
|
||||
post({ type: 'ack', id: m.id });
|
||||
post({ type: 'state', running: false, framesEmitted });
|
||||
break;
|
||||
}
|
||||
case 'step': {
|
||||
if (!pipeline) ensureRebuild();
|
||||
const bytes = pipeline!.run(1);
|
||||
framesEmitted += 1;
|
||||
post(
|
||||
{ type: 'frames', batch: bytes.buffer, count: 1, fps: 0, framesEmitted },
|
||||
[bytes.buffer],
|
||||
);
|
||||
post({ type: 'ack', id: m.id });
|
||||
break;
|
||||
}
|
||||
case 'witnessGenerate': {
|
||||
if (!pipeline) ensureRebuild();
|
||||
const samples = (m.samples as number) ?? 256;
|
||||
const result = pipeline!.runWithWitness(samples) as {
|
||||
frames: Uint8Array;
|
||||
witness: Uint8Array;
|
||||
frameCount: number;
|
||||
};
|
||||
const hex = hexWitness(result.witness);
|
||||
post(
|
||||
{
|
||||
type: 'witness',
|
||||
id: m.id,
|
||||
witness: result.witness.buffer,
|
||||
hex,
|
||||
frameCount: result.frameCount,
|
||||
},
|
||||
[result.witness.buffer],
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'witnessVerify': {
|
||||
// Verify always runs the *canonical* reference scene at seed=42, N=256
|
||||
// so the witness matches Proof::EXPECTED_WITNESS_HEX byte-for-byte.
|
||||
// The user's working scene/config/seed don't affect the witness.
|
||||
const expectedBuf = m.expected as ArrayBuffer;
|
||||
const expected = new Uint8Array(expectedBuf);
|
||||
const actual = referenceWitness();
|
||||
let ok = actual.length === expected.length;
|
||||
if (ok) {
|
||||
for (let i = 0; i < expected.length; i++) {
|
||||
if (actual[i] !== expected[i]) { ok = false; break; }
|
||||
}
|
||||
}
|
||||
const actualBuf = actual.slice().buffer;
|
||||
post(
|
||||
{
|
||||
type: 'verify',
|
||||
id: m.id,
|
||||
ok,
|
||||
actual: actualBuf,
|
||||
actualHex: hexWitness(actual),
|
||||
},
|
||||
[actualBuf],
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'buildId': {
|
||||
post({
|
||||
type: 'buildId',
|
||||
id: m.id,
|
||||
buildId: `nvsim@${_WasmPipeline.buildVersion()}`,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
post({ type: 'err', id: m.id, msg: `unknown op ${m.type}` });
|
||||
}
|
||||
} catch (e) {
|
||||
post({ type: 'err', id: m.id, msg: (e as Error).message ?? String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
post({ type: 'ready' });
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable", "WebWorker"],
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noImplicitOverride": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"exactOptionalPropertyTypes": false,
|
||||
"useDefineForClassFields": false,
|
||||
"experimentalDecorators": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src/**/*", "vite.config.ts"],
|
||||
"exclude": ["node_modules", "dist", "public/nvsim-pkg"]
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import { defineConfig } from 'vite';
|
||||
|
||||
// Dashboard for ADR-092 — Vite + Lit + WASM in a Web Worker.
|
||||
// Hosted at /RuView/nvsim/ on GitHub Pages; base path is configurable
|
||||
// via NVSIM_BASE so local dev (npm run dev) stays at "/".
|
||||
const base = (globalThis as { process?: { env?: { NVSIM_BASE?: string } } }).process?.env?.NVSIM_BASE ?? '/';
|
||||
|
||||
export default defineConfig({
|
||||
base,
|
||||
publicDir: 'public',
|
||||
worker: {
|
||||
format: 'es',
|
||||
},
|
||||
build: {
|
||||
target: 'es2022',
|
||||
sourcemap: true,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
lit: ['lit'],
|
||||
signals: ['@preact/signals-core'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
fs: {
|
||||
// wasm-pack output sits in public/nvsim-pkg; vite already serves it,
|
||||
// but allow fs reads from the workspace root for HMR convenience.
|
||||
allow: ['..', '.'],
|
||||
},
|
||||
headers: {
|
||||
// SAB ring buffer is opt-in; these headers are no-op without crossOriginIsolated
|
||||
// but make local dev parity with a future CORS-isolated host.
|
||||
'Cross-Origin-Opener-Policy': 'same-origin',
|
||||
'Cross-Origin-Embedder-Policy': 'require-corp',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -810,6 +810,94 @@ WASM/WS swap painless and what enables the third-party `@ruvnet/nvsim-client` pa
|
|||
|
||||
---
|
||||
|
||||
## 14a. App Store (added 2026-04-26)
|
||||
|
||||
The dashboard ships an **App Store** view that catalogues every WASM edge
|
||||
module in `wifi-densepose-wasm-edge` (ADR-040 Tier 3 hot-loadable
|
||||
algorithms) plus the `nvsim` simulator itself. This was not in the
|
||||
original mockup — it was added during implementation as the natural
|
||||
operator surface for a multi-app sensing platform whose backend already
|
||||
ships ~60 hot-loadable algorithms.
|
||||
|
||||
### 14a.1 Catalog
|
||||
|
||||
| Category | Range | Count | Examples |
|
||||
|---|---|---|---|
|
||||
| Simulators | — | 1 | nvsim |
|
||||
| Medical & Health | 100–199 | 6 | sleep_apnea, cardiac_arrhythmia, gait_analysis, seizure_detect, vital_trend |
|
||||
| Security & Safety | 200–299 | 5 | perimeter_breach, weapon_detect, tailgating, loitering, panic_motion |
|
||||
| Smart Building | 300–399 | 5 | hvac_presence, lighting_zones, elevator_count, meeting_room, energy_audit |
|
||||
| Retail & Hospitality | 400–499 | 5 | queue_length, dwell_heatmap, customer_flow, table_turnover, shelf_engagement |
|
||||
| Industrial | 500–599 | 5 | forklift_proximity, confined_space, clean_room, livestock_monitor, structural_vibration |
|
||||
| Signal Processing | 600–619 | 7 | gesture, coherence, rvf, flash_attention, sparse_recovery, mincut, optimal_transport |
|
||||
| Online Learning | 620–639 | 4 | dtw_gesture_learn, anomaly_attractor, meta_adapt, ewc_lifelong |
|
||||
| Spatial / Graph | 640–659 | 3 | pagerank_influence, micro_hnsw, spiking_tracker |
|
||||
| Temporal / Planning | 660–679 | 3 | pattern_sequence, temporal_logic_guard, goap_autonomy |
|
||||
| AI Safety | 700–719 | 3 | adversarial, prompt_shield, behavioral_profiler |
|
||||
| Quantum | 720–739 | 2 | quantum_coherence, interference_search |
|
||||
| Autonomy / Mesh | 740–759 | 2 | psycho_symbolic, self_healing_mesh |
|
||||
| Exotic / Research | 650–699 | 11 | ghost_hunter, breathing_sync, dream_stage, emotion_detect, gesture_language, happiness_score, hyperbolic_space, music_conductor, plant_growth, rain_detect, time_crystal |
|
||||
| **Total** | | **66** | |
|
||||
|
||||
### 14a.2 Per-app metadata
|
||||
|
||||
Each entry in `dashboard/src/store/apps.ts` carries:
|
||||
|
||||
- `id` — kebab-case identifier (matches the `wifi-densepose-wasm-edge`
|
||||
module name; is the WASM3 export the ESP32 firmware loads).
|
||||
- `name` — human-readable label.
|
||||
- `category` — short-code for filter chips and event-ID range.
|
||||
- `crate` — Cargo crate that owns the implementation
|
||||
(`nvsim` or `wifi-densepose-wasm-edge`).
|
||||
- `summary` — single-line description shown on the card.
|
||||
- `events` — emitted i32 event IDs from the `event_types` mod.
|
||||
- `budget` — compute tier (`S` < 5 ms, `M` < 15 ms, `L` < 50 ms).
|
||||
- `status` — maturity (`available` / `beta` / `research`).
|
||||
- `adr` — back-reference to the ADR that introduced or governs the app.
|
||||
- `tags` — fuzzy-search tokens.
|
||||
|
||||
### 14a.3 UI behavior
|
||||
|
||||
- **Card grid** — auto-fill at 280 px per card; theme-aware palette.
|
||||
- **Search** — fuzzy match across `id`, `name`, `summary`, and `tags`.
|
||||
- **Category chips** — single-select filter (sticky under the search).
|
||||
- **Status chips** — secondary filter on maturity.
|
||||
- **Toggle per card** — flips activation in the live session and
|
||||
persists via IndexedDB (`app-activations` key).
|
||||
- **Active indicator** — emerald border on cards whose toggle is on.
|
||||
|
||||
### 14a.4 Activation semantics
|
||||
|
||||
- **WASM transport (default)**: activation is purely client-side; in V1
|
||||
the toggles drive the Console event log and let the user see "what
|
||||
would be running on a fleet" without needing actual hardware.
|
||||
- **WS transport (deferred to V2)**: activation flips an
|
||||
`app.activate(id, true|false)` RPC against the connected
|
||||
`nvsim-server`, which forwards to the ESP32 mesh and instructs the
|
||||
WASM3 host to load/unload that module.
|
||||
|
||||
### 14a.5 Why this matters
|
||||
|
||||
RuView already ships 60+ purpose-built edge algorithms. Without an
|
||||
operator surface they exist only in source code; the App Store makes
|
||||
them **discoverable** and **toggleable** without recompiling firmware.
|
||||
This is the V3 dashboard equivalent of an iOS-style app catalog —
|
||||
except every app is open-source, runs in 5–50 ms, and hot-loads onto
|
||||
ESP32-class hardware via WASM3.
|
||||
|
||||
### 14a.6 Adding a new app
|
||||
|
||||
1. Implement the algorithm in `wifi-densepose-wasm-edge/src/<id>.rs`.
|
||||
2. Add `pub mod <id>;` to `lib.rs`.
|
||||
3. Add an entry to `APPS` in `dashboard/src/store/apps.ts`.
|
||||
4. Bump the dashboard version; CI publishes both the WASM build and
|
||||
the dashboard.
|
||||
|
||||
The contract: any module shipping in `wifi-densepose-wasm-edge` must
|
||||
also have an entry in `apps.ts` (lint check planned for V2).
|
||||
|
||||
---
|
||||
|
||||
## 15. Cross-references
|
||||
|
||||
- **ADR-089** — `nvsim` simulator (the backend this dashboard fronts)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,16 @@ keywords = ["nv-diamond", "magnetometer", "simulator", "physics", "biot-savart"]
|
|||
categories = ["science", "simulation"]
|
||||
readme = "README.md"
|
||||
|
||||
[package.metadata.wasm-pack.profile.release]
|
||||
# Skip wasm-opt locally — older wasm-opt versions reject bulk-memory ops
|
||||
# rustc emits at 1.92. CI runs wasm-opt with a current binaryen.
|
||||
wasm-opt = false
|
||||
|
||||
[lib]
|
||||
# `cdylib` for wasm-bindgen's wasm32 build, `rlib` so other workspace
|
||||
# crates and benchmarks can keep linking against nvsim natively.
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
# `nvsim` is a standalone leaf crate. It deliberately has NO internal RuView
|
||||
# dependencies — see `docs/research/quantum-sensing/15-nvsim-implementation-plan.md`
|
||||
# §1.1 for the rationale. RuView integration (frame format alignment with
|
||||
|
|
@ -24,15 +34,27 @@ tracing = { workspace = true }
|
|||
|
||||
# Pass 4: deterministic ChaCha20 PRNG for shot-noise sampling. Same
|
||||
# `(scene, seed)` produces byte-identical outputs across runs and machines —
|
||||
# the determinism commitment in plan §5.
|
||||
rand = "0.8"
|
||||
rand_chacha = "0.3"
|
||||
# the determinism commitment in plan §5. Default features off to drop the
|
||||
# `getrandom` OS-entropy path; nvsim seeds from a caller-supplied u64 so
|
||||
# OS entropy is never needed (this is also what makes nvsim WASM-ready).
|
||||
rand = { version = "0.8", default-features = false }
|
||||
rand_chacha = { version = "0.3", default-features = false }
|
||||
|
||||
# Pass 5: SHA-256 over concatenated MagFrame bytes is the simulator's
|
||||
# content-addressable witness. Same scene + seed → same digest, the
|
||||
# foundation of Pass 6's proof bundle.
|
||||
sha2 = { workspace = true }
|
||||
|
||||
# ADR-092: optional wasm-bindgen surface for in-browser dashboard.
|
||||
# Enable with `--features wasm` and target wasm32-unknown-unknown.
|
||||
wasm-bindgen = { version = "0.2", optional = true }
|
||||
serde-wasm-bindgen = { version = "0.6", optional = true }
|
||||
js-sys = { version = "0.3", optional = true }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
wasm = ["dep:wasm-bindgen", "dep:serde-wasm-bindgen", "dep:js-sys"]
|
||||
|
||||
[dev-dependencies]
|
||||
approx = "0.5"
|
||||
criterion = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -48,6 +48,9 @@ pub mod scene;
|
|||
pub mod sensor;
|
||||
pub mod source;
|
||||
|
||||
#[cfg(all(feature = "wasm", target_arch = "wasm32"))]
|
||||
pub mod wasm;
|
||||
|
||||
pub use proof::Proof;
|
||||
|
||||
pub use digitiser::{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,160 @@
|
|||
//! WASM bindings for `nvsim` — ADR-092 dashboard transport.
|
||||
//!
|
||||
//! Exposes the deterministic pipeline through a small `wasm-bindgen`
|
||||
//! surface so the Vite + Lit dashboard can run the *real* Rust simulator
|
||||
//! in a Web Worker. Same `(scene, config, seed)` → byte-identical
|
||||
//! `MagFrame` stream and SHA-256 witness as native — that's the
|
||||
//! determinism contract the dashboard's Witness panel asserts.
|
||||
//!
|
||||
//! Only compiled when the `wasm` feature is on; gated to `target = wasm32`
|
||||
//! so the rest of the workspace stays unaffected.
|
||||
|
||||
#![cfg(all(feature = "wasm", target_arch = "wasm32"))]
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::pipeline::{Pipeline, PipelineConfig};
|
||||
use crate::scene::Scene;
|
||||
|
||||
/// Build identifier surfaced to the dashboard so it can pin a specific
|
||||
/// nvsim version + the SHA-256 of the `.wasm` artifact (the latter is
|
||||
/// computed by the dashboard, not here, but this string is part of what
|
||||
/// the dashboard logs at boot).
|
||||
pub const NVSIM_BUILD_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
/// Convert a `JsValue` error from `serde_wasm_bindgen` into a JS-side
|
||||
/// `Error` with a useful message.
|
||||
fn js_err(msg: impl AsRef<str>) -> JsValue {
|
||||
JsValue::from_str(msg.as_ref())
|
||||
}
|
||||
|
||||
/// In-browser pipeline. Wraps [`Pipeline`] with JS-friendly construction
|
||||
/// (JSON for `Scene` and `PipelineConfig`) and `Vec<u8>` outputs (raw
|
||||
/// concatenated [`MagFrame`] bytes — 60 bytes/frame, magic `0xC51A_6E70`).
|
||||
#[wasm_bindgen]
|
||||
pub struct WasmPipeline {
|
||||
inner: Pipeline,
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl WasmPipeline {
|
||||
/// Construct from JSON strings + a `seed` (BigInt-friendly; passed in
|
||||
/// as `f64` since wasm-bindgen does not yet ergonomically pass `u64`,
|
||||
/// then bit-cast through `as u64`). The dashboard sends seeds as
|
||||
/// `Number(seed_hex)` from a 32-bit value to fit cleanly.
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(scene_json: &str, config_json: &str, seed: f64) -> Result<WasmPipeline, JsValue> {
|
||||
let scene: Scene =
|
||||
serde_json::from_str(scene_json).map_err(|e| js_err(format!("scene parse: {e}")))?;
|
||||
let config: PipelineConfig = serde_json::from_str(config_json)
|
||||
.map_err(|e| js_err(format!("config parse: {e}")))?;
|
||||
let seed_u64 = seed as u64;
|
||||
Ok(WasmPipeline {
|
||||
inner: Pipeline::new(scene, config, seed_u64),
|
||||
})
|
||||
}
|
||||
|
||||
/// Run `n_samples` of the pipeline and return the concatenated raw
|
||||
/// `MagFrame` bytes (`n_samples * sensors * 60` bytes). The dashboard
|
||||
/// parses this into typed records on the main thread.
|
||||
#[wasm_bindgen]
|
||||
pub fn run(&self, n_samples: usize) -> Vec<u8> {
|
||||
let frames = self.inner.run(n_samples);
|
||||
let mut out = Vec::with_capacity(frames.len() * 60);
|
||||
for f in &frames {
|
||||
out.extend_from_slice(&f.to_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Run + SHA-256 witness in one call. Returns a JS object
|
||||
/// `{ frames: Uint8Array, witness: Uint8Array }`. Same
|
||||
/// `(scene, config, seed)` produces byte-identical `witness` across
|
||||
/// runs, machines, and transports — the regression dashboard pins.
|
||||
#[wasm_bindgen(js_name = runWithWitness)]
|
||||
pub fn run_with_witness(&self, n_samples: usize) -> Result<JsValue, JsValue> {
|
||||
let (frames, witness) = self.inner.run_with_witness(n_samples);
|
||||
|
||||
let mut bytes = Vec::with_capacity(frames.len() * 60);
|
||||
for f in &frames {
|
||||
bytes.extend_from_slice(&f.to_bytes());
|
||||
}
|
||||
|
||||
// Use js_sys::Object directly — keeps the call cheap and avoids
|
||||
// pulling serde_wasm_bindgen on the hot path.
|
||||
let obj = js_sys::Object::new();
|
||||
let frames_arr = js_sys::Uint8Array::new_with_length(bytes.len() as u32);
|
||||
frames_arr.copy_from(&bytes);
|
||||
let witness_arr = js_sys::Uint8Array::new_with_length(32);
|
||||
witness_arr.copy_from(&witness);
|
||||
js_sys::Reflect::set(&obj, &JsValue::from_str("frames"), &frames_arr)?;
|
||||
js_sys::Reflect::set(&obj, &JsValue::from_str("witness"), &witness_arr)?;
|
||||
js_sys::Reflect::set(
|
||||
&obj,
|
||||
&JsValue::from_str("frameCount"),
|
||||
&JsValue::from_f64(frames.len() as f64),
|
||||
)?;
|
||||
Ok(obj.into())
|
||||
}
|
||||
|
||||
/// nvsim build version (semver from Cargo.toml).
|
||||
#[wasm_bindgen(js_name = buildVersion)]
|
||||
pub fn build_version() -> String {
|
||||
NVSIM_BUILD_VERSION.to_string()
|
||||
}
|
||||
|
||||
/// Magic constant for the `MagFrame` v1 binary record. The dashboard's
|
||||
/// hex-dump panel highlights these four bytes (`0xC51A_6E70` → `701A6EC5`
|
||||
/// little-endian) as a sanity check.
|
||||
#[wasm_bindgen(js_name = frameMagic)]
|
||||
pub fn frame_magic() -> u32 {
|
||||
crate::frame::MAG_FRAME_MAGIC
|
||||
}
|
||||
|
||||
/// Bytes-per-frame for v1 — `60` today; surfaced so the dashboard
|
||||
/// can advance its parse cursor without re-deriving the layout.
|
||||
#[wasm_bindgen(js_name = frameBytes)]
|
||||
pub fn frame_bytes() -> u32 {
|
||||
crate::frame::MAG_FRAME_BYTES as u32
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience: parse the bundled reference scene to JSON. Lets the
|
||||
/// dashboard's "load reference scene" flow round-trip through the Rust
|
||||
/// type system instead of duplicating the JSON literal in the JS code.
|
||||
#[wasm_bindgen(js_name = referenceSceneJson)]
|
||||
pub fn reference_scene_json() -> String {
|
||||
crate::proof::Proof::REFERENCE_SCENE_JSON.to_string()
|
||||
}
|
||||
|
||||
/// Hex-encode a 32-byte witness for display.
|
||||
#[wasm_bindgen(js_name = hexWitness)]
|
||||
pub fn hex_witness(witness: &[u8]) -> Result<String, JsValue> {
|
||||
if witness.len() != 32 {
|
||||
return Err(js_err(format!(
|
||||
"witness must be 32 bytes, got {}",
|
||||
witness.len()
|
||||
)));
|
||||
}
|
||||
let mut a = [0u8; 32];
|
||||
a.copy_from_slice(witness);
|
||||
Ok(crate::proof::Proof::hex(&a))
|
||||
}
|
||||
|
||||
/// Expected reference witness for `Proof::REFERENCE_SCENE_JSON @ seed=42,
|
||||
/// N=256` — the bytes the dashboard's Verify panel compares against.
|
||||
#[wasm_bindgen(js_name = expectedReferenceWitnessHex)]
|
||||
pub fn expected_reference_witness_hex() -> String {
|
||||
"cc8de9b01b0ff5bd97a6c17848a3f156c174ea7589d0888164a441584ec593b4".to_string()
|
||||
}
|
||||
|
||||
/// Run the canonical reference pipeline (`Proof::generate`) end-to-end and
|
||||
/// return the SHA-256 witness as a 32-byte `Uint8Array`. This is the
|
||||
/// dashboard's source of truth for the Verify-witness panel.
|
||||
#[wasm_bindgen(js_name = referenceWitness)]
|
||||
pub fn reference_witness() -> Result<js_sys::Uint8Array, JsValue> {
|
||||
let bytes = crate::proof::Proof::generate().map_err(|e| js_err(format!("{e}")))?;
|
||||
let arr = js_sys::Uint8Array::new_with_length(32);
|
||||
arr.copy_from(&bytes);
|
||||
Ok(arr)
|
||||
}
|
||||
Loading…
Reference in New Issue