mirror of
https://github.com/ruvnet/RuVector.git
synced 2026-05-24 05:43:58 +00:00
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:
parent
cdcb2bb8d1
commit
b04ea1fb73
5 changed files with 344 additions and 0 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
204
examples/connectome-fly/ui/src/modules/fly-sim.js
Normal file
204
examples/connectome-fly/ui/src/modules/fly-sim.js
Normal 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 ~0–220 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();
|
||||
}
|
||||
})();
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)); }
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue