356 lines
13 KiB
JavaScript
356 lines
13 KiB
JavaScript
|
|
/* <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);
|