/* — the fly brain, alive: 138k neurons that tremble with the Earth. * * The coordinates are loaded once from brain-coords.bin (Float32 x,z pairs, in * the same index order as the simulation's Brian ids), so every spike index in a * `brain` event lands on the right dot. Nothing about the connectome is drawn by * hand: the shape is the real FlyWire geometry, the glow is the real simulation. * * The brain is slower than the world (~4x), so events arrive at their own pace; * between them the component keeps a short decay so activity reads as a pulse, * not a strobe. This is honest: the dots that glow are the neurons that spiked. * * Usage: * * * * * CSS custom properties (inherited into the shadow DOM): * workshop-brain { --workshop-brain-bg: #0b0a14; --workshop-brain-hot: #ffd166; * --workshop-brain-desc: #4cc9f0; --workshop-brain-drive: #ff4d6d; } */ class WorkshopBrain extends HTMLElement { static get observedAttributes() { return ["height", "caption"]; } constructor() { super(); this.attachShadow({ mode: "open" }); this._coords = null; this._class = null; this._n = 0; this._heat = null; // Float32Array, per-neuron glow 0..1 this._target = null; // Float32Array, glow set by the last event this._drive = null; // Uint8Array, driven input mask this._driven = 0; this._unsub = null; this._raf = null; this._last = null; this._stats = { spikes: 0, active: 0, chunk: 0, simTime: 0, wall: 0 }; this._colors = null; } connectedCallback() { this._render(); this._load(); this._subscribe(); } disconnectedCallback() { if (this._unsub) this._unsub(); this._unsub = null; if (this._raf) cancelAnimationFrame(this._raf); this._raf = null; } attributeChangedCallback(name) { if (!this.isConnected) return; if (name === "caption") this._render(); if (name === "height") this._resize(); } get height() { const v = parseInt(this.getAttribute("height") || "360", 10); return Number.isFinite(v) && v > 80 ? v : 360; } _render() { const caption = this.getAttribute("caption") || ""; this.shadowRoot.innerHTML = `
${caption ? `
${caption}
` : ""}
нейрон сработал нисходящий (выход мозга) вход: шум земли
`; this._canvas = this.shadowRoot.querySelector("canvas"); this._ctx = this._canvas.getContext("2d"); this._resize(); if (this._coords) this._startLoop(); } _resize() { if (!this._canvas) return; const dpr = Math.min(window.devicePixelRatio || 1, 2); const w = this._canvas.clientWidth || 640; this._canvas.width = Math.round(w * dpr); this._canvas.height = Math.round(this.height * dpr); this._dpr = dpr; this._w = w; this._h = this.height; this._needsProject = true; // One persistent buffer, not a fresh one per frame: createImageData every // frame allocated ~550 KB at 60 Hz, and putImageData then copies it all. this._img = this._ctx.createImageData(this._canvas.width, this._canvas.height); const d = this._img.data; for (let o = 0; o < d.length; o += 4) { d[o] = 11; d[o + 1] = 10; d[o + 2] = 20; d[o + 3] = 255; } if (this._coords) this._draw(); } async _load() { try { const [cRes, kRes] = await Promise.all([ fetch("/assets/workshop/brain-coords.bin"), fetch("/assets/workshop/brain-class.bin"), ]); const cBuf = await cRes.arrayBuffer(); const kBuf = await kRes.arrayBuffer(); this._coords = new Float32Array(cBuf); this._class = new Uint8Array(kBuf); this._n = this._class.length; this._heat = new Float32Array(this._n); this._target = new Float32Array(this._n); this._drive = new Uint8Array(this._n); this._buildColors(); this._startLoop(); } catch (err) { console.error("[workshop-brain] failed to load cloud", err); this._statEl().textContent = "не удалось загрузить облако нейронов"; } } _buildColors() { // Three base colours by class: other (dim violet), central, optic, and the // descending output (cyan). Heat is drawn on top, so these are the resting // body of the brain. const n = this._n; this._base = new Uint8Array(n * 3); for (let i = 0; i < n; i++) { const k = this._class[i]; let r, g, b; if (k === 255) { r = 11; g = 10; b = 20; } else if (k === 3) { r = 26; g = 52; b = 64; } else if (k === 2) { r = 52; g = 40; b = 78; } else if (k === 1) { r = 38; g = 34; b = 58; } else { r = 26; g = 23; b = 40; } this._base[i * 3] = r; this._base[i * 3 + 1] = g; this._base[i * 3 + 2] = b; } } _subscribe() { if (!this.isConnected || this._unsub) return; if (window.WorkshopStream) { this._unsub = window.WorkshopStream.on("brain", (ev) => this._onBrain(ev)); window.WorkshopStream.on("brain_ready", (ev) => this._onReady(ev)); return; } if (!WorkshopBrain._loading) { WorkshopBrain._loading = new Promise((resolve, reject) => { const s = document.createElement("script"); s.src = "/assets/workshop/workshop-stream.js"; s.onload = resolve; s.onerror = reject; document.head.appendChild(s); }); } WorkshopBrain._loading.then(() => this._subscribe()).catch(() => {}); } _onReady(ev) { this._driven = ev.driven || 0; if (Array.isArray(ev.drive_idx)) this._driveIdx = ev.drive_idx; if (ev.mode) this._mode = ev.mode; if (ev.source) this._source = ev.source; } _onBrain(ev) { if (!this._target) return; const act = ev.active || []; // Set the glow for this event's neurons; the loop decays it toward zero. this._target.fill(0); for (let i = 0; i < act.length; i++) { const idx = act[i]; if (idx >= 0 && idx < this._n) this._target[idx] = 1; } this._stats = { spikes: ev.spikes, active: ev.active_n, chunk: ev.chunk, simTime: ev.sim_time, wall: ev.wall, sampled: ev.sampled, }; } _startLoop() { if (this._raf) return; this._lastT = performance.now(); const tick = (now) => { const dt = Math.min(0.1, (now - this._lastT) / 1000); this._lastT = now; this._decay(dt); this._draw(); this._raf = requestAnimationFrame(tick); }; this._raf = requestAnimationFrame(tick); } _decay(dt) { // Time-based bleed, not per-frame: chunks arrive ~1.3 s apart, so a // frame-rate decay (0.90/frame at 60 Hz) emptied the glow in ~1 s and the // brain sat dark between events. Half-life 0.9 s keeps each chunk's pattern // visible until the next one, so activity reads as a steady tremble. const h = this._heat, t = this._target; const k = Math.exp(-dt / 0.9); for (let i = 0; i < h.length; i++) { const want = t[i]; h[i] = want > h[i] ? want : h[i] * k; if (h[i] < 0.01) h[i] = 0; } } _project() { if (!this._coords) return; const n = this._n; const xs = new Float32Array(n), ys = new Float32Array(n); let minx = Infinity, maxx = -Infinity, miny = Infinity, maxy = -Infinity; for (let i = 0; i < n; i++) { const x = this._coords[i * 2], z = this._coords[i * 2 + 1]; if (Number.isNaN(x) || Number.isNaN(z)) { xs[i] = NaN; ys[i] = NaN; continue; } xs[i] = x; ys[i] = z; if (x < minx) minx = x; if (x > maxx) maxx = x; if (z < miny) miny = z; if (z > maxy) maxy = z; } const pad = 14; const sx = (this._w - pad * 2) / (maxx - minx); const sy = (this._h - pad * 2) / (maxy - miny); const s = Math.min(sx, sy); const ox = (this._w - (maxx - minx) * s) / 2 - minx * s; const oy = (this._h - (maxy - miny) * s) / 2 - miny * s; for (let i = 0; i < n; i++) { if (Number.isNaN(xs[i])) continue; // y grows downward on canvas; the brain's z grows up, so flip it. xs[i] = xs[i] * s + ox; ys[i] = this._h - (ys[i] * s + oy); } this._px = xs; this._py = ys; this._needsProject = false; } _draw() { if (!this._ctx || !this._coords) return; if (this._needsProject) this._project(); const ctx = this._ctx, dpr = this._dpr; const w = this._w, h = this._h; const dw = Math.round(w * dpr), dh = Math.round(h * dpr); // putImageData ignores setTransform, so the buffer must be built at device // resolution and every coordinate scaled by dpr — otherwise on a retina // screen only the top-left quarter would be painted. The buffer persists // between frames; the body is repainted over it each time. const img = this._img; const data = img.data; for (let o = 0; o < data.length; o += 4) { data[o] = 11; data[o + 1] = 10; data[o + 2] = 20; data[o + 3] = 255; } const n = this._n, px = this._px, py = this._py; const heat = this._heat, base = this._base, klass = this._class; // Resting body: paint the whole cloud at its base colour, very faint. for (let i = 0; i < n; i++) { const x = px[i], y = py[i]; if (Number.isNaN(x)) continue; const xi = (x * dpr) | 0, yi = (y * dpr) | 0; if (xi < 0 || xi >= dw || yi < 0 || yi >= dh) continue; const o = (yi * dw + xi) * 4; data[o] = base[i * 3]; data[o + 1] = base[i * 3 + 1]; data[o + 2] = base[i * 3 + 2]; data[o + 3] = 255; } // Spikes: additive splat of the heat on top of the body. A single pixel per // neuron would be invisible — a real chunk lights ~0.7% of 138k points — so // each lit neuron is painted as a small disc (radius scales with dpr). const rad = Math.max(1, Math.round(dpr * 1.5)); let lit = 0; for (let i = 0; i < n; i++) { const hv = heat[i]; if (hv <= 0.02) continue; const x = px[i], y = py[i]; if (Number.isNaN(x)) continue; const cx = (x * dpr) | 0, cy = (y * dpr) | 0; const desc = klass[i] === 3; const cr = desc ? 76 * hv : 235 * hv; const cg = desc ? 201 * hv : 180 * hv; const cb = desc ? 240 * hv : 60 * hv; for (let dy = -rad; dy <= rad; dy++) { const yy = cy + dy; if (yy < 0 || yy >= dh) continue; for (let dx = -rad; dx <= rad; dx++) { // Round disc, not a square block: drop the corners. if (rad >= 2 && dx * dx + dy * dy > rad * rad + 1) continue; const xx = cx + dx; if (xx < 0 || xx >= dw) continue; const o = (yy * dw + xx) * 4; data[o] = Math.min(255, data[o] + cr); data[o + 1] = Math.min(255, data[o + 1] + cg); data[o + 2] = Math.min(255, data[o + 2] + cb); } } lit++; } ctx.putImageData(img, 0, 0); // Mark the driven input neurons with a thin ring, so the eye can see where // the Earth enters the brain. Positions come from the same cloud. if (this._driveIdx) { ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.strokeStyle = "rgba(255,77,109,0.45)"; ctx.lineWidth = 0.8; for (const i of this._driveIdx) { const x = px[i], y = py[i]; if (Number.isNaN(x)) continue; ctx.beginPath(); ctx.arc(x, y, 2.4, 0, Math.PI * 2); ctx.stroke(); } } this._lit = lit; const s = this._stats; if (s && s.chunk) { this._statEl().innerHTML = `чанк ${s.chunk} · спайков ${s.spikes} · активно ${s.active} · горят ${lit}
` + `мозг ${s.simTime.toFixed(1)} с · шаг ${s.wall.toFixed(2)} с` + (s.sampled ? " · выборка" : ""); } } _statEl() { return this.shadowRoot.querySelector(".stat"); } } customElements.define("workshop-brain", WorkshopBrain);