eureka-web/assets/workshop/workshop-brain.js
eureka cc3d6c5ca0 workshop: страница в общей оболочке сайта + компоненты
Мастерская переехала на eureka2: страница /workshop — часть сайта (lib/workshop.js,
общая шапка/навигация/подвал), оформление в tokens сайта (assets/workshop/
workshop.css), компоненты читают данные пода по SSE /workshop/sse.
Компоненты перенесены на сторону сайта, геометрия мозга — assets/workshop/.
build.js собирает страницу (active: workshop).
2026-10-04 01:08:06 +03:00

355 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* <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);