diff --git a/examples/connectome-fly/ui/index.html b/examples/connectome-fly/ui/index.html index afd45b56..2d467653 100644 --- a/examples/connectome-fly/ui/index.html +++ b/examples/connectome-fly/ui/index.html @@ -121,6 +121,10 @@
Embodiment
Couples the simulator to a virtual fly body. Closed-loop sensory → motor.
+
+ +
Fly simulation
Dedicated 3D fly body driven by live spike rates from the engine — wings, legs, antennae all update in real time.
+
Benchmarks
Spikes/sec vs Brian2, Auryn, NEST — matched network, single thread.
diff --git a/examples/connectome-fly/ui/src/main.js b/examples/connectome-fly/ui/src/main.js index 89c03a5e..39bcc8dd 100644 --- a/examples/connectome-fly/ui/src/main.js +++ b/examples/connectome-fly/ui/src/main.js @@ -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'; diff --git a/examples/connectome-fly/ui/src/modules/fly-sim.js b/examples/connectome-fly/ui/src/modules/fly-sim.js new file mode 100644 index 00000000..259aa108 --- /dev/null +++ b/examples/connectome-fly/ui/src/modules/fly-sim.js @@ -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 = ` +
+
Fly simulation · real-time embodiment
+ +
+
+
+ +
+ `; + 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() + ' sp/s'; + 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) + ' Hz'; + 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(); + } +})(); diff --git a/examples/connectome-fly/ui/src/modules/fly.js b/examples/connectome-fly/ui/src/modules/fly.js index 1573b4b6..160da0cb 100644 --- a/examples/connectome-fly/ui/src/modules/fly.js +++ b/examples/connectome-fly/ui/src/modules/fly.js @@ -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(); diff --git a/examples/connectome-fly/ui/src/styles/views.css b/examples/connectome-fly/ui/src/styles/views.css index 3a4014f3..cd2714ad 100644 --- a/examples/connectome-fly/ui/src/styles/views.css +++ b/examples/connectome-fly/ui/src/styles/views.css @@ -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)); } +}