eureka-web/assets/workshop/workshop-brain.js

356 lines
13 KiB
JavaScript
Raw Normal View History

/* <workshop-brain> — 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:
* <script src="workshop-stream.js"></script>
* <script src="workshop-brain.js"></script>
* <workshop-brain height="360" caption="мозг дрозофилы"></workshop-brain>
*
* 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 = `
<style>
:host { display: block; }
.wrap { position: relative; width: 100%; }
canvas {
display: block; width: 100%; height: ${this.height}px;
background: var(--workshop-brain-bg, #0b0a14);
border-radius: 8px;
}
.caption {
color: var(--workshop-dim, #7b8794); font-size: 12px;
letter-spacing: .04em; margin-top: 8px;
}
.stat {
position: absolute; right: 10px; bottom: 8px;
color: var(--workshop-dim, #7b8794); font-size: 11px;
font-variant-numeric: tabular-nums; text-align: right;
line-height: 1.5; pointer-events: none;
}
.stat b { color: var(--workshop-accent, #e8b04b); font-weight: normal; }
.legend {
display: flex; gap: 14px; flex-wrap: wrap; margin-top: 8px;
color: var(--workshop-dim, #7b8794); font-size: 11px;
}
.legend i { width: 9px; height: 9px; border-radius: 50%;
display: inline-block; margin-right: 5px; }
</style>
<div class="wrap">
<canvas></canvas>
<div class="stat"></div>
</div>
${caption ? `<div class="caption">${caption}</div>` : ""}
<div class="legend">
<span><i style="background:#ffd166"></i>нейрон сработал</span>
<span><i style="background:#4cc9f0"></i>нисходящий (выход мозга)</span>
<span><i style="background:none;border:1px solid #ff4d6d"></i>вход: шум земли</span>
</div>`;
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 =
`чанк <b>${s.chunk}</b> · спайков <b>${s.spikes}</b> · активно <b>${s.active}</b> · горят <b>${lit}</b><br>` +
`мозг <b>${s.simTime.toFixed(1)}</b> с · шаг <b>${s.wall.toFixed(2)}</b> с` +
(s.sampled ? " · выборка" : "");
}
}
_statEl() { return this.shadowRoot.querySelector(".stat"); }
}
customElements.define("workshop-brain", WorkshopBrain);