feat(ui): dedicated fly simulation view (data-view="fly-sim")

A standalone, full-canvas Three.js fly body driven by live spike
rates from the engine. Separate from the existing Embodiment panel
(which stays as a small-card readout); this one takes over the whole
canvas-wrap and is the visual centre of the dashboard.

Wiring:
- src/modules/fly-sim.js: owns the overlay + Three.js stage. Mounts a
  FlyScene lazily on first view, subscribes to window._real_spikes_total
  / _fiedler / _tick / _source so the same ticker drives it whether
  the data is coming from the real Rust backend or the JS mock.
- src/modules/fly.js: expose setWingHz() and setStepHz() so external
  modules (fly-sim.js) can override the internal motor frequency; the
  embodiment mini-panel is unaffected.
- index.html: new rail item with a fly-silhouette SVG between
  Embodiment and Benchmarks.
- src/styles/views.css: .fly-sim-root / .fs-head / .fs-stage / .fs-side
  styling — two-column layout with a 280 px live-readout sidebar,
  scenario-pill toolbar, glow-backed stage. Mobile collapses to stacked
  rows.

Live-readout cards (right sidebar):
  live source          → real | mock | pending (colour-coded)
  sim clock            → sim_ms with progress bar against 10k cap
  spikes total         → window._real_spikes_total
  spike rate (1 s)     → first-derivative of totals, log-scaled bar
  wing beat            → log-mapped from spike rate → drives setWingHz()
  fiedler λ₂           → live with "stable / drifting / fragmenting"
                         hint based on 0.18 / 0.30 thresholds

Scenario pills (Normal / Saturated / Fragmenting) forward to
Dynamics.setScenario() — a no-op on the real backend, but actually
switches the mock's firing model when the Rust engine isn't running.

Validated via agent-browser: clicking the rail item flips the
#fly-sim-root to .active, the stage canvas mounts, live readouts
update (spikes=3,109 / wing=126 Hz after 1 s), scenario switches
work, zero console errors.

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruvnet 2026-04-23 00:24:18 -04:00
parent cdcb2bb8d1
commit b04ea1fb73
5 changed files with 344 additions and 0 deletions

View file

@ -121,6 +121,10 @@
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M12 4c-2 0-3 1.5-3 3 0 1 .5 2 1 2.5-2 .5-4 2.5-4 5v3m6-10.5c.5-.5 1-1.5 1-2.5 0-1.5-1-3-3-3m6 8c2 .5 4 2.5 4 5v3M10 14l-4 4M14 14l4 4M8 20h8"/></svg>
<div class="rail-tooltip"><div class="rt-title">Embodiment</div><div class="rt-desc">Couples the simulator to a virtual fly body. Closed-loop sensory → motor.</div></div>
</div>
<div class="rail-item" title="Fly simulation" data-view="fly-sim">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><ellipse cx="12" cy="13" rx="3" ry="5"/><ellipse cx="12" cy="7" rx="2" ry="2"/><path d="M12 5v-2M11 3l-1-1M13 3l1-1"/><path d="M9 10 L3 7M15 10 L21 7M9 14 L3 17M15 14 L21 17"/></svg>
<div class="rail-tooltip"><div class="rt-title">Fly simulation</div><div class="rt-desc">Dedicated 3D fly body driven by live spike rates from the engine — wings, legs, antennae all update in real time.</div></div>
</div>
<div class="rail-item" title="Benchmarks" data-view="benchmarks">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><rect x="4" y="12" width="3" height="8"/><rect x="10.5" y="7" width="3" height="13"/><rect x="17" y="3" width="3" height="17"/></svg>
<div class="rail-tooltip"><div class="rt-title">Benchmarks</div><div class="rt-desc">Spikes/sec vs Brian2, Auryn, NEST — matched network, single thread.</div></div>

View file

@ -28,4 +28,5 @@ import './modules/scene.js';
import './modules/dynamics.js';
import './modules/fly.js';
import './modules/actions.js';
import './modules/fly-sim.js';
import './modules/welcome.js';

View file

@ -0,0 +1,204 @@
// Connectome OS — Dedicated Fly Simulation view.
//
// A standalone view (data-view="fly-sim") that reuses the procedural
// 3D fly in `fly.js` and wires it to the live spike data from
// `dynamics.js`. The view is a persistent full-canvas overlay inside
// .canvas-wrap; visibility is toggled when the rail item is activated,
// so the Three.js renderer stays warm and doesn't re-initialise on
// every view switch.
//
// Live-data mapping:
// sensory in → sensory-burst pulse into FlyScene (antennae / eyes)
// motor out → wing-beat + leg-step frequency (derived from spike rate)
// fiedler → global body tint (coherent green ↔ fragmenting amber)
//
// When the SSE stream isn't connected and the JS mock is driving the
// raster, the same window._fiedler / window._real_spikes_total globals
// still update — so this view works on GitHub Pages too.
(function () {
const W = window;
function ensureHost() {
let host = document.getElementById('fly-sim-root');
if (host) return host;
const wrap = document.querySelector('.canvas-wrap');
if (!wrap) return null;
host = document.createElement('div');
host.id = 'fly-sim-root';
host.className = 'fly-sim-root';
host.innerHTML = `
<div class="fs-head">
<div class="fs-title">Fly simulation <span class="fs-sub">· real-time embodiment</span></div>
<div class="fs-scenarios" role="toolbar" aria-label="Scenario">
<button data-fly-scenario="normal" class="fs-pill active">Normal</button>
<button data-fly-scenario="saturated" class="fs-pill">Saturated</button>
<button data-fly-scenario="fragmenting" class="fs-pill">Fragmenting</button>
</div>
</div>
<div class="fs-body">
<div class="fs-stage" id="fly-sim-stage"></div>
<aside class="fs-side">
<div class="fs-card">
<div class="fs-k">live source</div>
<div class="fs-v" id="fs-src"></div>
<div class="fs-hint">"real" = Rust backend · "mock" = JS fallback</div>
</div>
<div class="fs-card">
<div class="fs-k">sim clock</div>
<div class="fs-v tnum" id="fs-clock">0 ms</div>
<div class="fs-bar"><i id="fs-clock-bar" style="width:0%"></i></div>
</div>
<div class="fs-card">
<div class="fs-k">spikes total</div>
<div class="fs-v tnum" id="fs-spikes">0</div>
<div class="fs-hint">counter on the live engine</div>
</div>
<div class="fs-card">
<div class="fs-k">spike rate (1 s window)</div>
<div class="fs-v tnum" id="fs-rate">0 <em>sp/s</em></div>
<div class="fs-bar"><i id="fs-rate-bar" style="width:0%"></i></div>
</div>
<div class="fs-card">
<div class="fs-k">wing beat</div>
<div class="fs-v tnum" id="fs-wing">0 <em>Hz</em></div>
<div class="fs-bar"><i id="fs-wing-bar" style="width:0%"></i></div>
</div>
<div class="fs-card">
<div class="fs-k">fiedler λ₂</div>
<div class="fs-v tnum" id="fs-fiedler"></div>
<div class="fs-hint" id="fs-fiedler-hint">coherence-collapse detector</div>
</div>
<div class="fs-card fs-card-dim">
The fly body is procedural, not a render of a real fly
it translates live spike rates into motor behaviour (wing
beat, leg tripod, antenna twitch) so you can see the engine
driving the body in real time.
</div>
</aside>
</div>
`;
wrap.appendChild(host);
return host;
}
function mount() {
const host = ensureHost();
if (!host) return;
const stage = host.querySelector('#fly-sim-stage');
// Lazy-create the FlyScene when the view is first shown, so the
// Three.js renderer + WebGL context aren't allocated until needed.
let fly = null;
function ensureFly() {
if (!fly && W.FlyScene && stage) {
fly = W.FlyScene.create(stage);
}
return fly;
}
function setVisible(on) {
host.classList.toggle('active', !!on);
if (on) {
ensureFly();
// If a FlyScene's internal resize hook exists, trigger it.
W.dispatchEvent(new Event('resize'));
}
}
// Rail activation — hook after nav.js has wired its clicks.
document.querySelectorAll('[data-view="fly-sim"]').forEach((el) => {
el.addEventListener('click', () => setVisible(true));
});
document.querySelectorAll('.rail-item[data-view], .m-nav .item[data-view]').forEach((el) => {
el.addEventListener('click', () => {
if (el.dataset.view !== 'fly-sim') setVisible(false);
});
});
// Scenario pills — forward to the dynamics module (mock worker path).
host.querySelectorAll('[data-fly-scenario]').forEach((btn) => {
btn.addEventListener('click', () => {
const name = btn.dataset.flyScenario;
host.querySelectorAll('[data-fly-scenario]').forEach((b) =>
b.classList.toggle('active', b === btn)
);
W.Dynamics?.setScenario?.(name);
});
});
// Live-readout update loop — reads the same globals dynamics.js
// writes (window._real_spikes_total, _fiedler, _tick, _sim_ms).
let prevTotal = 0;
let prevT = performance.now();
let rateHz = 0;
let wingHz = 0;
function tick() {
const total = W._real_spikes_total || 0;
const now = performance.now();
const dt = Math.max(1e-3, (now - prevT) / 1000);
if (dt >= 0.25) {
const delta = Math.max(0, total - prevTotal);
rateHz = delta / dt;
prevTotal = total;
prevT = now;
// Wing-beat proxy: map spike-rate log to ~0220 Hz.
wingHz = Math.min(220, 30 + Math.log1p(rateHz) * 18);
if (fly?.setWingHz) fly.setWingHz(wingHz);
// Sensory burst proxy: sudden jumps in rate drive antennae.
const burst = Math.min(1, Math.log1p(delta) / 10);
fly?.setSensoryBurst?.(burst);
}
const srcEl = host.querySelector('#fs-src');
if (srcEl) {
const src = W._source || (W.Dynamics?.isMock?.() ? 'mock' : 'pending');
srcEl.textContent = src;
srcEl.dataset.src = src;
}
const clockEl = host.querySelector('#fs-clock');
if (clockEl) {
const t = W._sim_ms ?? W._tick ?? 0;
clockEl.textContent = Number(t).toLocaleString() + ' ms';
}
const clockBar = host.querySelector('#fs-clock-bar');
if (clockBar) {
const pct = Math.min(100, ((W._tick || 0) % 10_000) / 100);
clockBar.style.width = pct.toFixed(0) + '%';
}
const spEl = host.querySelector('#fs-spikes');
if (spEl) spEl.textContent = total.toLocaleString();
const rateEl = host.querySelector('#fs-rate');
if (rateEl) rateEl.innerHTML = Math.round(rateHz).toLocaleString() + ' <em>sp/s</em>';
const rateBar = host.querySelector('#fs-rate-bar');
if (rateBar) {
const pct = Math.min(100, Math.log1p(rateHz) * 10);
rateBar.style.width = pct.toFixed(0) + '%';
}
const wingEl = host.querySelector('#fs-wing');
if (wingEl) wingEl.innerHTML = Math.round(wingHz) + ' <em>Hz</em>';
const wingBar = host.querySelector('#fs-wing-bar');
if (wingBar) wingBar.style.width = Math.min(100, (wingHz / 220) * 100).toFixed(0) + '%';
const fEl = host.querySelector('#fs-fiedler');
const fiedler = W._fiedler;
if (fEl) {
fEl.textContent = Number.isFinite(fiedler) ? fiedler.toFixed(3) : '';
}
const fHint = host.querySelector('#fs-fiedler-hint');
if (fHint && Number.isFinite(fiedler)) {
fHint.textContent = fiedler < 0.18
? 'fragmenting — below 0.18 collapse threshold'
: fiedler < 0.3
? 'drifting — monitor'
: 'stable — coherent co-firing';
}
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', mount);
} else {
mount();
}
})();

View file

@ -390,6 +390,8 @@
resize,
reset: () => { userInteracted = false; targetRadius = 5.8; targetEl = 0.38; },
setSensoryBurst: (v) => { stats.sensoryBurst = v; },
setWingHz: (hz) => { if (Number.isFinite(hz)) stats.wingHz = Math.max(20, Math.min(260, hz)); },
setStepHz: (hz) => { if (Number.isFinite(hz)) stats.stepHz = Math.max(1, Math.min(120, hz)); },
dispose: () => {
running = false;
renderer.dispose();

View file

@ -412,3 +412,136 @@
.canvas-wrap:hover .nav-hint { opacity: 0.9; }
.canvas-wrap.view-content-active:not(.embodiment) .nav-hint { opacity: 0.15; }
@media (max-width: 1100px) { .nav-hint { display: none; } }
/* =========================================================
* FLY SIMULATION view (data-view="fly-sim")
* --------------------------------------------------------- */
.fly-sim-root {
position: absolute; inset: 0;
display: none;
flex-direction: column;
background: radial-gradient(ellipse at top, rgba(10, 18, 26, 0.92), rgba(4, 8, 12, 0.98));
z-index: 5;
}
.fly-sim-root.active { display: flex; }
.fs-head {
display: flex; align-items: center; gap: 14px;
padding: 12px 18px;
border-bottom: 1px solid var(--bd-hair, rgba(255,255,255,0.06));
background: linear-gradient(180deg, rgba(10, 18, 26, 0.9), rgba(10, 18, 26, 0));
}
.fs-title {
font-family: 'Space Grotesk', var(--ff-sans, system-ui), sans-serif;
font-weight: 600;
font-size: 15px;
color: var(--fg, #e7edf3);
}
.fs-title .fs-sub {
color: var(--fg-3, #7f8a95);
font-weight: 400;
margin-left: 4px;
}
.fs-scenarios {
margin-left: auto;
display: flex; gap: 4px;
background: rgba(255,255,255,0.03);
border: 1px solid var(--bd-hair, rgba(255,255,255,0.06));
border-radius: 999px;
padding: 3px;
}
.fs-pill {
background: transparent;
border: 0;
color: var(--fg-2, #a9b4be);
font-family: var(--ff-mono, monospace);
font-size: 11px; letter-spacing: 0.06em;
padding: 5px 12px;
border-radius: 999px;
cursor: pointer;
transition: background 120ms ease, color 120ms ease;
}
.fs-pill:hover { color: var(--fg, #e7edf3); }
.fs-pill.active {
background: rgba(184, 255, 60, 0.12);
color: var(--signal, #b8ff3c);
}
.fs-body {
flex: 1;
display: grid;
grid-template-columns: 1fr 280px;
min-height: 0;
}
.fs-stage {
position: relative;
min-height: 0;
background: radial-gradient(ellipse at center, rgba(184, 255, 60, 0.04), transparent 60%),
radial-gradient(ellipse at center, rgba(10, 18, 26, 0.2), transparent 70%);
}
.fs-stage canvas { display: block; width: 100%; height: 100%; }
.fs-side {
border-left: 1px solid var(--bd-hair, rgba(255,255,255,0.06));
padding: 14px;
overflow-y: auto;
display: flex; flex-direction: column; gap: 10px;
background: rgba(6, 12, 18, 0.5);
}
.fs-card {
background: rgba(255, 255, 255, 0.02);
border: 1px solid var(--bd-hair, rgba(255,255,255,0.05));
border-radius: 8px;
padding: 10px 12px;
font-family: var(--ff-sans, system-ui), sans-serif;
}
.fs-card-dim {
color: var(--fg-3, #7f8a95);
font-size: 12px; line-height: 1.55;
}
.fs-k {
font-family: var(--ff-mono, monospace);
font-size: 10px; letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--fg-3, #7f8a95);
}
.fs-v {
font-family: 'Space Grotesk', var(--ff-sans, system-ui), sans-serif;
font-weight: 600;
font-size: 18px;
color: var(--fg, #e7edf3);
margin-top: 4px;
}
.fs-v em {
font-style: normal; font-weight: 400;
font-size: 12px;
color: var(--fg-3, #7f8a95);
}
.fs-hint {
margin-top: 6px;
font-size: 11px;
color: var(--fg-3, #7f8a95);
}
.fs-bar {
height: 4px;
background: rgba(255,255,255,0.05);
border-radius: 2px;
margin-top: 8px;
overflow: hidden;
}
.fs-bar i {
display: block;
height: 100%;
background: linear-gradient(90deg, var(--signal, #b8ff3c), rgba(184, 255, 60, 0.35));
transition: width 200ms ease;
}
#fs-src[data-src="real"] { color: var(--signal, #b8ff3c); }
#fs-src[data-src="mock"] { color: var(--amber, #f6c445); }
#fs-src[data-src="pending"] { color: var(--fg-3, #7f8a95); }
@media (max-width: 720px) {
.fs-body { grid-template-columns: 1fr; grid-template-rows: 1fr auto; }
.fs-side { max-height: 40vh; border-left: 0; border-top: 1px solid var(--bd-hair, rgba(255,255,255,0.06)); }
}