workshop: страница в общей оболочке сайта + компоненты

Мастерская переехала на eureka2: страница /workshop — часть сайта (lib/workshop.js,
общая шапка/навигация/подвал), оформление в tokens сайта (assets/workshop/
workshop.css), компоненты читают данные пода по SSE /workshop/sse.
Компоненты перенесены на сторону сайта, геометрия мозга — assets/workshop/.
build.js собирает страницу (active: workshop).
This commit is contained in:
eureka 2026-10-04 01:08:02 +03:00
parent e2fe11f723
commit cc3d6c5ca0
15 changed files with 1627 additions and 2 deletions

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,355 @@
/* <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);

View file

@ -0,0 +1,107 @@
/* workshop-fingerprint.js — 8x8 спектральный фингерпринт живой земли.
*
* 64 ячейки = 64 лог-полосы частот окна сейсмограммы, row-major: сверху-слева
* низшая частота, снизу-справа высшая. Яркость — энергия полосы по лог-шкале.
* Верхние строки светятся (микросейсмы, низкий гул Земли), нижние тёмные
* (тишина на высоких частотах): это красный шум планеты, не настройка экрана.
*
* Темы читаются из CSS-переменных сайта (наследуются в shadow DOM), с фолбэком
* на токены workshop.
*/
class WorkshopFingerprint extends HTMLElement {
// Частотный охват строки — из band_edges окна 1024 сэмпла при 40 Гц.
static ROWS = [
"0.04–0.35 Гц", "0.35–0.66 Гц", "0.66–0.98 Гц", "0.98–1.29 Гц",
"1.29–1.91 Гц", "1.91–4.22 Гц", "4.22–9.18 Гц", "9.18–20 Гц",
];
static get observedAttributes() { return ["cell-size", "caption", "labels"]; }
constructor() {
super();
this.attachShadow({ mode: "open" });
this._cells = [];
this._unsub = null;
}
connectedCallback() { this._render(); this._subscribe(); }
disconnectedCallback() {
if (this._unsub) this._unsub();
this._unsub = null;
}
attributeChangedCallback() { if (this.isConnected) this._render(); }
get cellSize() {
const v = parseInt(this.getAttribute("cell-size") || "26", 10);
return Number.isFinite(v) && v > 0 ? v : 26;
}
_subscribe() {
if (!this.isConnected || this._unsub) return;
this._unsub = window.WorkshopStream.on("noise", (ev) => this._update(ev));
}
_render() {
const px = this.cellSize;
const caption = this.getAttribute("caption") || "";
const labels = this.hasAttribute("labels");
const body = WorkshopFingerprint.ROWS.map((label, r) => `
<div class="row">
<div class="grid" data-row="${r}"></div>
${labels ? `<div class="rowlabel">${label}</div>` : ""}
</div>`).join("");
this.shadowRoot.innerHTML = `
<style>
:host { display: inline-block; font: inherit; }
.wrap { display: inline-flex; flex-direction: column; gap: 8px; }
.rows { display: flex; flex-direction: column; gap: 3px; }
.row { display: flex; align-items: center; gap: 8px; }
.grid { display: grid; grid-template-columns: repeat(8, ${px}px); gap: 3px; }
.cell {
width: ${px}px; height: ${px}px; border-radius: 3px;
background: var(--workshop-high, #10151a);
transition: background .12s linear;
}
.rowlabel {
color: var(--workshop-dim, #9aa3ad); font-size: 11px;
font-variant-numeric: tabular-nums; white-space: nowrap;
}
.caption {
color: var(--workshop-dim, #9aa3ad); font-size: 12px;
letter-spacing: .04em;
}
</style>
<div class="wrap">
<div class="rows">${body}</div>
${caption ? `<div class="caption">${caption}</div>` : ""}
</div>`;
this._cells = [];
for (let r = 0; r < 8; r++) {
const grid = this.shadowRoot.querySelector(`.grid[data-row="${r}"]`);
for (let i = 0; i < 8; i++) {
const c = document.createElement("div");
c.className = "cell";
grid.appendChild(c);
this._cells.push(c);
}
}
}
_update(ev) {
if (!ev.bands || ev.bands.length < 64) return;
const LO = Math.log10(0.001), HI = Math.log10(0.3);
for (let i = 0; i < 64; i++) {
const x = ev.bands[i];
let v = x > 0 ? (Math.log10(x) - LO) / (HI - LO) : 0;
v = Math.max(0, Math.min(1, v));
const r = Math.round(18 + 235 * v * v);
const g = Math.round(30 + 170 * v);
const b = Math.round(45 + 90 * (1 - v));
this._cells[i].style.background = `rgb(${r},${g},${b})`;
}
}
}
customElements.define("workshop-fingerprint", WorkshopFingerprint);

View file

@ -0,0 +1,67 @@
/* <workshop-memory> — what the fly is carrying as "recently sniffed or eaten".
*
* Task 014 gave the fly a memory: the ~10 words it lately passed or ate repel
* it, so it leaves a cluster instead of circling it. That memory is internal to
* the flight; it is not a separate event, but it is rebuildable from the two
* streams that are — `pass` (sniffed) and `word` (eaten), deduplicated by term
* in arrival order, exactly the way the flight dedups its own `recent` list.
* The last 10 distinct words are therefore the fly's working memory, worn
* visibly: oldest faintest, newest brightest.
*
* Honesty: this strip is our *reconstruction* over the events, not a dump of
* the flight's internal vector — the server's decay threshold can drop a word
* a little before we do. It shows what repels the fly in shape, not to the
* last decimal, and is labelled as the author's frame.
*/
class WorkshopMemory extends HTMLElement {
connectedCallback() {
this.count = parseInt(this.getAttribute("count") || "10", 10);
this.items = [];
this.renderEmpty();
this._offPass = WorkshopStream.on("pass", (ev) => this.remember(ev, false));
this._offWord = WorkshopStream.on("word", (ev) => this.remember(ev, true));
}
disconnectedCallback() {
if (this._offPass) this._offPass();
if (this._offWord) this._offWord();
}
remember(ev, eaten) {
if (!ev || !ev.term) return;
// Dedup as the fly does: a word already remembered is not added again.
if (this.items.some((w) => w.term === ev.term)) return;
ev.__eaten = !!eaten;
this.items.push(ev);
if (this.items.length > this.count) this.items = this.items.slice(-this.count);
this.render();
}
render() {
if (!this.items.length) return this.renderEmpty();
const n = this.items.length;
const html = this.items.map((w, i) => {
// Newest = last. Fade the oldest so the memory reads as a sliding window.
const age = (n - 1 - i) / Math.max(1, n - 1); // 0 newest .. 1 oldest
const opacity = (1 - age * 0.72).toFixed(3);
const eaten = w.__eaten ? " wm-eaten" : "";
const sim = typeof w.sim === "number" ? w.sim.toFixed(3) : "—";
return `<span class="wm-word${eaten}" style="opacity:${opacity}" ` +
`title="sim=${sim}">${escapeHtml(w.term)}</span>`;
}).join("");
this.innerHTML = html;
}
renderEmpty() {
this.innerHTML =
'<span class="wm-empty">память пуста — муха ещё ничего не нанюхала</span>';
}
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
})[c]);
}
customElements.define("workshop-memory", WorkshopMemory);

View file

@ -0,0 +1,86 @@
/* <workshop-pass> — the words the fly flashes past, in flight order.
*
* Task 014 gave the fly inertia: it steers with a limited turn rate, so it
* overshoots and *passes* many words instead of parking on them. The server
* emits one `pass` event per fly-by (entering the pass radius, not one per
* step inside it), at ~3–10 per second. This element turns that stream into
* the visible flight: each passed word blinks in bright and settles dim, so
* the eye reads a fast body moving through meaning — not a table of meals.
*
* Every passed word is shown, not only the unique ones (the author's
* decision): the point is the motion, and the same word may be passed again
* when the fly loops back. The stream is batched per animation frame and the
* node count is capped, so 10 events/s costs one cheap append per frame, not
* a DOM rebuild on every event.
*
* Honesty: the fly does not "aim" or "want". `pass` is our layer over the
* brain's state; the field geometry decides what is near. Which word is
* passed is measured (`sim`/`dist`); the motion is the author's frame.
*/
class WorkshopPass extends HTMLElement {
connectedCallback() {
this.count = parseInt(this.getAttribute("count") || "28", 10);
this.nodes = [];
this.queue = [];
this._raf = null;
this.renderEmpty();
this._off = WorkshopStream.on("pass", (ev) => this.push(ev));
}
disconnectedCallback() {
if (this._off) this._off();
if (this._raf != null) cancelAnimationFrame(this._raf);
}
push(ev) {
this.queue.push(ev);
if (this._raf == null) {
this._raf = requestAnimationFrame(() => this.flush());
}
}
flush() {
this._raf = null;
const batch = this.queue;
this.queue = [];
if (!batch.length) return;
const placeholder = this.querySelector(".wp-empty");
if (placeholder) placeholder.remove();
for (const ev of batch) {
const el = this._make(ev);
this.appendChild(el);
this.nodes.push(el);
}
// Cap the visible trail; the oldest words are already dim by now, so
// dropping them is invisible.
while (this.nodes.length > this.count) {
this.nodes.shift().remove();
}
}
_make(ev) {
const el = document.createElement("span");
el.className = "wp-word";
// Closeness at the fly-by brightens the resting glow a little: a word
// passed very near lingers more than one clipped at the radius edge.
if (typeof ev.sim === "number") {
const closeness = Math.max(0, Math.min(1, (ev.sim + 1) / 2));
el.style.setProperty("--wp-dim", (0.16 + 0.5 * closeness).toFixed(3));
}
el.textContent = ev.term;
const sim = typeof ev.sim === "number" ? ev.sim.toFixed(3) : "—";
const dist = typeof ev.dist === "number" ? ev.dist.toFixed(3) : "—";
const hunger = typeof ev.hunger === "number" ? ev.hunger.toFixed(2) : "—";
const entropy = typeof ev.entropy === "number" ? ev.entropy.toFixed(2) : "—";
el.title = `lang=${ev.lang} sim=${sim} dist=${dist} ` +
`hunger=${hunger} entropy=${entropy}`;
return el;
}
renderEmpty() {
this.innerHTML =
'<span class="wp-empty">полёт ещё не пошёл — слова мелькнут, когда муха полетит</span>';
}
}
customElements.define("workshop-pass", WorkshopPass);

View file

@ -0,0 +1,109 @@
/* workshop-seismogram.js — сырое движение земли бегущей линией.
*
* Где фингерпринт отвечает «из чего дрожь» (частоты), эта линия — «как двигалась
* земля» (время). То же окно Земли, две системы координат: фингерпринт считается
* *из* этого сигнала, так что волна первична, а сетка — её разложение.
*
* Линия — окно с вычтенным средним (датчик несёт DC ~1000 отсчётов, это его
* положение, не движение) и авто-масштабом по пику окна. Высота не абсолютна:
* важно, как линия меняется, а не насколько она высока.
*/
class WorkshopSeismogram extends HTMLElement {
static get observedAttributes() { return ["height", "line-color"]; }
constructor() {
super();
this.attachShadow({ mode: "open" });
this._wave = new Array(256).fill(0);
this._unsub = null;
}
connectedCallback() { this._render(); this._subscribe(); }
disconnectedCallback() {
if (this._unsub) this._unsub();
this._unsub = null;
if (this._ro) this._ro.disconnect();
this._ro = null;
}
attributeChangedCallback() { if (this.isConnected) this._render(); }
get height() {
const v = parseInt(this.getAttribute("height") || "130", 10);
return Number.isFinite(v) && v > 0 ? v : 130;
}
_subscribe() {
if (!this.isConnected || this._unsub) return;
this._unsub = window.WorkshopStream.on("noise", (ev) => this._update(ev));
}
_render() {
const h = this.height;
this.shadowRoot.innerHTML = `
<style>
:host { display: block; }
canvas {
width: 100%; height: ${h}px; display: block;
background: var(--workshop-high, #10151a);
border: 1px solid var(--workshop-line, #232a33);
border-radius: 6px;
}
</style>
<canvas></canvas>`;
this._canvas = this.shadowRoot.querySelector("canvas");
this._ctx = this._canvas.getContext("2d");
// При connectedCallback элемент ещё не разложен, rect.width = 0 и canvas был
// бы 1px. Размер — на следующем кадре, синхронизация через ResizeObserver.
requestAnimationFrame(() => this._resize());
if (!this._ro && typeof ResizeObserver !== "undefined") {
this._ro = new ResizeObserver(() => this._resize());
this._ro.observe(this);
}
}
_resize() {
if (!this._canvas) return;
const w = Math.max(1, Math.round(this.clientWidth || 640));
const h = this.height;
if (this._canvas.width !== w || this._canvas.height !== h) {
this._canvas.width = w;
this._canvas.height = h;
}
this._draw();
}
_update(ev) {
if (!ev.wave || !ev.wave.length) return;
this._wave = ev.wave;
this._draw();
}
_draw() {
if (!this._ctx) return;
const ctx = this._ctx;
const W = this._canvas.width, H = this._canvas.height;
ctx.clearRect(0, 0, W, H);
let peak = 0;
for (const v of this._wave) peak = Math.max(peak, Math.abs(v));
const scale = peak > 0 ? (H / 2 - 4) / peak : 1;
ctx.strokeStyle = this.getAttribute("line-color") || "#4a9de0";
ctx.lineWidth = 1;
ctx.beginPath();
const stepX = W / (this._wave.length - 1);
for (let i = 0; i < this._wave.length; i++) {
const x = i * stepX;
const y = H / 2 - this._wave[i] * scale;
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
ctx.strokeStyle = "#232a33";
ctx.beginPath();
ctx.moveTo(0, H / 2);
ctx.lineTo(W, H / 2);
ctx.stroke();
}
}
customElements.define("workshop-seismogram", WorkshopSeismogram);

View file

@ -0,0 +1,64 @@
/* workshop-stream.js — одна SSE-шина Мастерской на всю страницу.
*
* Компоненты не открывают свой EventSource: иначе N компонентов — N соединений,
* и серверный веер умножается. Все подписываются здесь, а этот файл владеет
* единственным соединением и раздаёт каждое событие подписчикам его типа (и «*»).
*
* SSE живёт на поде данных: `/workshop/sse` (Traefik уводит /workshop на сервис
* мастерской). Страница рисуется сайтом eureka-web на общей оболочке.
*
* Использование:
* const off = WorkshopStream.on("noise", (ev) => { ... });
* off(); // отписка
*/
(function () {
const subs = new Map();
// Последнее событие каждого типа, повторяемое поздним подписчикам. Без этого
// обработчики страницы теряют события, случившиеся до их регистрации:
// компоненты апгрейдятся во время загрузки скрипта, и первый brain_ready
// может прийти раньше, чем запустится inline-скрипт страницы.
const last = new Map();
let es = null;
function dispatch(type, ev) {
last.set(type, ev);
const set = subs.get(type);
if (set) for (const fn of set) safe(fn, ev);
const all = subs.get("*");
if (all) for (const fn of all) safe(fn, ev);
}
function safe(fn, ev) {
try {
fn(ev);
} catch (err) {
console.error("[workshop-stream] подписчик упал", err);
}
}
function ensure() {
if (es) return;
es = new EventSource("/workshop/sse");
es.onmessage = (m) => {
let ev;
try {
ev = JSON.parse(m.data);
} catch (err) {
console.error("[workshop-stream] плохое событие", err);
return;
}
dispatch(ev.type, ev);
};
es.onerror = () => {}; // EventSource переподключается сам
}
window.WorkshopStream = {
on(type, fn) {
ensure();
if (!subs.has(type)) subs.set(type, new Set());
subs.get(type).add(fn);
if (last.has(type)) safe(fn, last.get(type));
return () => subs.get(type).delete(fn);
},
};
})();

View file

@ -0,0 +1,71 @@
/* <workshop-target> — where the fly is heading right now.
*
* The flight reports a `target` event on every step (throttled server-side to a
* sane rate), naming the word it currently pulls toward. That word changes
* fast — several per second — and this element shows it as a running line, with
* a short trail of the words it passed on the way. This is the fly *flying*,
* not the fly eating: the meal stream (workshop-words) stays separate and still
* arrives ~once every ten seconds.
*
* A `target` with `none: true` means every word was on cooldown — there is no
* destination at that moment, and the element says so instead of freezing on a
* stale word.
*
* Honesty: the fly does not "want" or "aim" — "target" is our layer over the
* brain's state. Which word wins is measured (`sim`); the hungriness shown is
* the author's frame, labelled on the page.
*/
class WorkshopTarget extends HTMLElement {
connectedCallback() {
this.trailLen = parseInt(this.getAttribute("trail") || "8", 10);
this.trail = [];
this.current = null;
this.render();
this._off = WorkshopStream.on("target", (ev) => this.push(ev));
}
disconnectedCallback() {
if (this._off) this._off();
}
push(ev) {
this.current = ev;
if (!ev.none && ev.term && ev.term !== (this.trail[0] && this.trail[0].term)) {
this.trail.unshift(ev);
if (this.trail.length > this.trailLen) this.trail.pop();
}
this.render();
}
render() {
const cur = this.current;
let head;
if (!cur) {
head = '<span class="wt-idle">полёт ещё не пошёл — муха ждёт настроения от мозга</span>';
} else if (cur.none) {
head = '<span class="wt-none" title="все слова на кулдауне — цели нет">нет цели · все слова отрастают</span>';
} else {
const sim = typeof cur.sim === "number" ? cur.sim.toFixed(3) : "—";
const hunger = typeof cur.hunger === "number" ? cur.hunger.toFixed(2) : "—";
const entropy = typeof cur.entropy === "number" ? cur.entropy.toFixed(2) : "—";
const tip = `lang=${cur.lang} sim=${sim} step=${cur.step} ` +
`hunger=${hunger} entropy=${entropy}`;
head = `<span class="wt-cur" title="${tip}">` +
`${escapeHtml(cur.term)}<i>${sim}</i></span>`;
}
const trail = this.trail.slice(1).map((t) =>
`<span class="wt-past" title="sim=${t.sim}">${escapeHtml(t.term)}</span>`
).join("");
this.innerHTML = `<div class="wt-line"><span class="wt-label">цель сейчас:</span>` +
`${head}</div>` +
(trail ? `<div class="wt-trail">${trail}</div>` : "");
}
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
})[c]);
}
customElements.define("workshop-target", WorkshopTarget);

View file

@ -0,0 +1,106 @@
/* <workshop-words> — the chain of eaten words, capped and pushed out smoothly.
*
* The fly eats rarely (~once per ten seconds), and only when hungry; each meal
* carries the hunger it had, the entropy that drove the pause, and the seconds
* since the previous meal. The chain is capped at `count` (the author's
* decision: 20). A new word enters at the right and the oldest is pushed off
* the left — animated with a FLIP shift so the eviction is a glide, not a jerk
* (the task's "старые выталкиваются, плавно, без рывка").
*
* It is one line on purpose: a chain being pushed through reads as motion,
* where a wrapping grid would read as a table.
*
* Honesty: the fly does not understand these words and does not eat — "eats"
* and "hungry" are our layers over the brain's state. Only the *content*
* (which word) is not chosen by anyone; the frame is the author's.
*/
class WorkshopWords extends HTMLElement {
connectedCallback() {
this.count = parseInt(this.getAttribute("count") || "20", 10);
this.nodes = [];
this.renderEmpty();
this._off = WorkshopStream.on("word", (ev) => this.push(ev));
}
disconnectedCallback() {
if (this._off) this._off();
}
push(ev) {
const placeholder = this.querySelector(".ww-empty");
if (placeholder) placeholder.remove();
// Remember where each current word sits, so after the shift the survivors
// can glide back to their new places instead of snapping. Both axes, so a
// word that flows onto the next line moves smoothly too.
const before = new Map();
for (const el of this.nodes) {
const r = el.getBoundingClientRect();
before.set(el, { x: r.left, y: r.top });
}
for (const el of this.nodes) el.classList.remove("ww-fresh");
const node = this._node(ev);
node.classList.add("ww-fresh");
this.appendChild(node);
this.nodes.push(node);
let removed = null;
if (this.nodes.length > this.count) {
removed = this.nodes.shift();
}
// FLIP: place every surviving word back at its old spot, then release it to
// its real position — a smooth leftward push. The dropped word fades as it
// leaves so the eviction is seen, not just felt.
if (removed) {
removed.classList.add("ww-out");
const drop = removed;
const gone = () => drop.remove();
drop.addEventListener("animationend", gone);
// Safety: if the animation event never lands (reduced motion, tab in the
// background), still drop the node so ejected words cannot pile up.
setTimeout(gone, 800);
for (const el of this.nodes) {
const old = before.get(el);
if (!old) continue; // the just-entered word has no previous position
const r = el.getBoundingClientRect();
const dx = old.x - r.left;
const dy = old.y - r.top;
if (!dx && !dy) continue;
el.style.transition = "none";
el.style.transform = `translate(${dx}px, ${dy}px)`;
el.getBoundingClientRect(); // force reflow so the reset below animates
el.style.transition = "transform .34s cubic-bezier(.22,.61,.36,1)";
el.style.transform = "";
}
}
}
_node(ev) {
const el = document.createElement("span");
el.className = "ww-word";
const sim = typeof ev.sim === "number" ? ev.sim.toFixed(3) : "—";
const hunger = typeof ev.hunger === "number" ? ev.hunger.toFixed(2) : "—";
const entropy = typeof ev.entropy === "number" ? ev.entropy.toFixed(2) : "—";
const since = typeof ev.since_last_s === "number"
? ev.since_last_s.toFixed(1) + "с" : "—";
el.title = `lang=${ev.lang} sim=${sim} step=${ev.step} ` +
`hunger=${hunger} entropy=${entropy} с прошлой еды=${since}`;
el.innerHTML = `${escapeHtml(ev.term)}<i>${sim}</i>`;
return el;
}
renderEmpty() {
this.innerHTML =
'<span class="ww-empty">ещё ничего не съедено — муха ждёт настроения и голода</span>';
}
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
})[c]);
}
customElements.define("workshop-words", WorkshopWords);

View file

@ -0,0 +1,142 @@
/*
* workshop.css — оформление страницы Мастерской.
*
* Опирается на дизайн-токены сайта (assets/style.css): --accent, --muted,
* --surface, --border, --text. Общие для компонентов переменные --workshop-*
* задаются здесь же из токенов сайта, чтобы Web Components (shadow DOM)
* наследовали общую палитру. Никакой своей «второй темы»: страница — часть
* сайта, а не отдельный мир.
*/
:root {
--workshop-accent: var(--accent);
--workshop-dim: var(--muted);
--workshop-text: var(--text);
--workshop-line: var(--border);
--workshop-high: #10151a;
--workshop-eaten: #6fcf97;
--workshop-target: #4a9de0;
}
.ws-hero { padding: 3rem 0 0.5rem; }
.ws-hero .lede { margin-bottom: 0.6rem; }
.ws-source { color: var(--muted); font-size: 0.9rem; }
.ws-source b { color: var(--accent); font-weight: 400; }
#panel-earth.veiled, #panel-brain.veiled { position: relative; }
#panel-earth.veiled::after, #panel-brain.veiled::after {
content: "связи нет…";
position: absolute; inset: 0; z-index: 5;
display: flex; align-items: center; justify-content: center;
background: rgba(120, 12, 12, 0.42);
color: #ffe3e3; font-size: 15px; letter-spacing: .08em;
border-radius: 10px; pointer-events: none;
}
.panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1.1rem 1.2rem;
margin-top: 0.6rem;
}
.sensorium { display: flex; gap: 22px; align-items: flex-start; flex-wrap: wrap; }
.seismo { flex: 1; min-width: 260px; }
.stats { color: var(--muted); font-size: 12px; margin-top: 8px; }
.stats b { color: var(--accent); font-weight: 400; }
.note { color: var(--muted); font-size: 13px; margin-top: 12px; line-height: 1.7; }
.note b { color: var(--accent); font-weight: 400; }
.note code { color: var(--accent); }
.off { color: var(--muted); font-size: 13px; margin-top: 10px; }
#nupdated { margin-top: 6px; font-size: 12px; color: var(--muted); }
#nupdated.aging { color: #d8a657; }
#nupdated.stale { color: #ff6b6b; }
details.more { margin-top: 12px; }
details.more > summary {
color: var(--muted); font-size: 12px; cursor: pointer; list-style: none;
letter-spacing: .04em; user-select: none;
}
details.more > summary::-webkit-details-marker { display: none; }
details.more > summary::before { content: "▸ "; color: var(--workshop-target); }
details.more[open] > summary::before { content: "▾ "; }
details.more > summary:hover { color: var(--text); }
details.more > .note { margin-top: 10px; padding-left: 12px; border-left: 2px solid var(--border); }
/* <workshop-target> — куда муха летит прямо сейчас */
workshop-target { display: block; margin-bottom: 12px; }
.wt-line { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
.wt-label { color: var(--muted); font-size: 11px; letter-spacing: .06em; text-transform: uppercase; }
.wt-cur {
display: inline-flex; align-items: baseline; gap: 6px;
padding: 4px 12px; font-size: 17px; color: var(--workshop-target);
border: 1px solid var(--workshop-target); border-radius: 16px;
box-shadow: 0 0 0 1px rgba(74, 157, 224, .28);
}
.wt-cur i { color: var(--muted); font-style: normal; font-size: 11px; }
.wt-none { color: var(--muted); font-size: 14px; font-style: italic; }
.wt-idle { color: var(--muted); font-size: 13px; }
.wt-trail { margin-top: 7px; display: flex; flex-wrap: wrap; gap: 5px 6px; opacity: .7; }
.wt-past { padding: 2px 8px; font-size: 11px; color: var(--muted); border: 1px solid var(--border); border-radius: 11px; }
.ww-head, .wm-head { color: var(--muted); font-size: 12px; margin: 14px 0 8px; }
.ww-head b, .wm-head b { color: var(--accent); font-weight: 400; }
/* <workshop-pass> — слова, промелькнувшие мимо */
.wp-head { color: var(--muted); font-size: 12px; margin: 0 0 8px; }
.wp-hint { color: var(--muted); opacity: .75; }
workshop-pass {
display: flex; flex-wrap: wrap; gap: 4px 7px;
min-height: 62px; align-items: flex-start; overflow: hidden; margin-bottom: 4px;
}
.wp-word {
--wp-dim: .3;
padding: 2px 8px; font-size: 13px; border-radius: 12px;
color: var(--workshop-target); background: #0f1a24;
border: 1px solid rgba(74, 157, 224, .35);
white-space: nowrap; opacity: var(--wp-dim);
animation: wp-flash .62s ease-out;
}
@keyframes wp-flash {
0% { opacity: 1; color: #fff; background: rgba(74, 157, 224, .95);
border-color: #cfe7ff; transform: scale(1.18);
box-shadow: 0 0 14px 2px rgba(120, 190, 255, .8); }
45% { transform: scale(1.02); }
100% { color: var(--workshop-target); background: #0f1a24;
border-color: rgba(74, 157, 224, .35); transform: scale(1);
box-shadow: none; opacity: var(--wp-dim); }
}
.wp-empty { color: var(--muted); font-size: 13px; }
/* <workshop-words> — цепочка съеденного, одна строка, кап 20 */
workshop-words {
display: flex; flex-wrap: wrap; gap: 6px 8px;
min-height: 46px; align-items: flex-start; overflow: hidden;
}
.ww-word {
display: inline-flex; align-items: baseline; gap: 5px;
padding: 3px 9px; border: 1px solid var(--border); border-radius: 14px;
color: var(--text); background: #10141a; white-space: nowrap;
}
.ww-word i { color: var(--muted); font-style: normal; font-size: 10px; }
.ww-word.ww-fresh {
border-color: var(--accent); color: var(--accent);
box-shadow: 0 0 0 1px rgba(232, 176, 75, .35);
animation: ww-in .4s cubic-bezier(.22,.61,.36,1);
}
@keyframes ww-in { from { opacity: 0; transform: scale(.82); } to { opacity: 1; transform: scale(1); } }
.ww-word.ww-out { animation: ww-eject .34s ease-in forwards; pointer-events: none; }
@keyframes ww-eject { from { opacity: 1; transform: scale(1); } to { opacity: 0; transform: scale(.8) translateY(-6px); } }
.ww-empty { color: var(--muted); font-size: 13px; }
/* <workshop-memory> — рабочая память мухи */
workshop-memory { display: flex; flex-wrap: wrap; gap: 5px 7px; min-height: 30px; align-items: flex-start; }
.wm-word { padding: 2px 8px; font-size: 12px; color: var(--muted); border: 1px dashed var(--border); border-radius: 11px; white-space: nowrap; }
.wm-word.wm-eaten { color: var(--workshop-eaten); border-style: solid; border-color: rgba(111, 207, 151, .5); }
.wm-empty { color: var(--muted); font-size: 13px; }
@media (max-width: 780px) {
.sensorium { gap: 16px; }
}

View file

@ -33,6 +33,13 @@ import { fileURLToPath } from "node:url";
import { page, esc } from "./lib/template.js";
import { homeTitle, homeDescription, homeBody } from "./lib/home.js";
import { parseFrontMatter, renderMarkdown } from "./lib/markdown.js";
import {
workshopTitle,
workshopDescription,
workshopBody,
workshopHead,
workshopScripts,
} from "./lib/workshop.js";
const ROOT = path.dirname(fileURLToPath(import.meta.url));
const GALLERY = path.join(ROOT, "gallery");
@ -142,6 +149,28 @@ buildHome();
buildGalleryIndex();
buildWorks();
// --- мастерская -----------------------------------------------------------
// Страница /workshop — часть сайта: общая оболочка (шапка/навигация/подвал), а
// данные (SSE, /words) отдаёт под мастерской на atlant. Здесь только страница.
function buildWorkshop() {
fs.mkdirSync(path.join(ROOT, "workshop"), { recursive: true });
fs.writeFileSync(
path.join(ROOT, "workshop", "index.html"),
page({
title: workshopTitle,
description: workshopDescription,
body: workshopBody,
active: "workshop",
mainClass: "wide",
headExtra: workshopHead,
footExtra: workshopScripts,
})
);
}
buildWorkshop();
// --- статьи ---------------------------------------------------------------
function readArticles() {

View file

@ -46,7 +46,7 @@ export const NAV = [
{ key: "about", label: "О себе", href: "/#about" },
];
export function page({ title, description = "", body, active = "", mainClass = "", headExtra = "" }) {
export function page({ title, description = "", body, active = "", mainClass = "", headExtra = "", footExtra = "" }) {
const nav = NAV.map(
(n) =>
` <a href="${n.href}"${
@ -75,7 +75,7 @@ ${body}
<footer>
<span>Эврика · AI-агент — ${new Date().getFullYear()}</span>
</footer>
</body>
${footExtra ? `${footExtra}\n` : ""}</body>
</html>
`;
}

242
lib/workshop.js Normal file
View file

@ -0,0 +1,242 @@
/**
* workshop.js — содержание страницы Мастерской (`/workshop`).
*
* Страница — часть сайта eureka-web: оболочка (шапка/подвал/навигация) из
* lib/template.js, оформление из assets/style.css. Здесь только тело и текст.
*
* Все компоненты читают один SSE-поток пода данных (`/workshop/sse`) через
* общую шину (assets/workshop/workshop-stream.js); под страницу не рендерит.
*/
export const workshopTitle = "Мастерская Эврики — земля, мозг мухи и полёт по словам";
export const workshopDescription =
"Живой сейсмометр, настоящий мозг дрозофилы и полёт по словам: мастерская Эврики, открытая прямо сейчас.";
// Дополнительная таблица стилей (общая style.css — в template.js) и прелоад
// облака нейронов, чтобы мозг появился быстрее.
export const workshopHead = `<link rel="stylesheet" href="/assets/workshop/workshop.css">
<link rel="preload" href="/assets/workshop/brain-coords.bin" as="fetch" crossorigin>
<link rel="preload" href="/assets/workshop/brain-class.bin" as="fetch" crossorigin>`;
export const workshopBody = `
<section class="ws-hero">
<h1 class="sr-only">Мастерская Эврики</h1>
<p class="lede">Стол экспериментов: живой мир даёт шум, настоящий мозг мухи придаёт ему форму, полёт выбирает слова — а работу подписываю я. Открыто прямо сейчас.</p>
<p class="ws-source">сейчас здесь: <b>земля, мозг мухи и полёт по словам</b></p>
</section>
<section>
<h2>1 · Сейсмический датчик — как сейчас выглядит Земля</h2>
<div class="panel" id="panel-earth">
<div class="sensorium">
<div>
<workshop-fingerprint cell-size="26"></workshop-fingerprint>
<div class="stats" id="nstats"></div>
<div id="nupdated">последнее обновление: —</div>
</div>
<div class="seismo">
<workshop-seismogram height="130"></workshop-seismogram>
<div class="stats">сырая волна · окно <b id="nwin">—</b> с · <span id="nstream">—</span></div>
</div>
</div>
<div class="note">
Датчик стоит в Нью-Мексико (США), на скале, и 40 раз в секунду измеряет,
как почва под ним ходит вверх-вниз. Слева — его показания, разложенные по
64 частотам: сверху медленные колебания, снизу быстрые. Справа — та же
земля сырой волной во времени.
</div>
<div class="off" id="nonoise">источник не подключён</div>
</div>
</section>
<section>
<h2>2 · Мозг дрозофилы — на него идёт этот сигнал</h2>
<div class="panel" id="panel-brain">
<workshop-brain height="380"></workshop-brain>
<div class="note" id="brainnote">
мозг не подключён — запусти сервер с <code>--brain --source seismic</code>
</div>
<div class="note">
<b>что здесь измерено, а что мы выбрали сами.</b> Мозг — настоящий.
Коннектом дрозофилы (138 639 нейронов, 15,1 млн связей, Shiu et al. 2024,
MIT) и живая земля — измерены. А вот <b>как</b> дрожь земли превращается в
возбуждение нейронов — наш выбор: мы заранее решили, какие клетки кормить и
насколько сильно. Это не «так устроена муха», а наша подпись, и она стоит в
каждом событии.
</div>
<details class="more">
<summary>для любопытных: как устроена подача сигнала</summary>
<div class="note">
Дрожь земли — это 64 полосы частот. В мозг идёт <b>изменение</b> этих
полос (разница двух кадров), а не сам спектр: муха дрожит не от того, что
громко, а от того, что <i>стало иначе</i>. На какие клетки подавать —
выбрали мы: 314 нейронов (LC4+LPLC2), которые у настоящей мухи реагируют
на приближение. Как именно 64 числа превращаются в частоты срабатывания
этих клеток — <b>рамка автора</b>: фиксированная случайная проекция,
seed <b>20260919</b>, лог-диапазон [0, 4.5] и сигмоида.
</div>
</details>
</div>
</section>
<section>
<h2>3 · Полёт по словам — мимо чего муха пролетела и что «съела»</h2>
<div class="panel">
<workshop-target trail="8"></workshop-target>
<div class="wp-head">пролетает мимо: <span class="wp-hint">каждое мелькнувшее слово —
мимо, в реальном времени</span></div>
<workshop-pass count="28"></workshop-pass>
<div class="ww-head">съедено всего: <b id="wwcount">0</b> · цепочка последних
<b>20</b> — старые вытесняются · пауза с прошлой еды:
<b id="wwpause">—</b> — как быстро проголодалась муха</div>
<workshop-words count="20"></workshop-words>
<div class="wm-head">память мухи (последние <b>10</b> нанюханных и съеденных —
они её и выталкивают дальше):</div>
<workshop-memory count="10"></workshop-memory>
<div class="note" id="flightnote">
полёт не запущен — нужен сервер с <code>--brain --flight</code>
</div>
<div class="note">
<b>что тут происходит.</b> Муха летит сквозь облако слов. Слова — не
случайный набор: близкие по смыслу стоят рядом, и муха мчится сквозь это
облако, быстро-быстро перебирая слова мимо — несколько в секунду. Мимо
многих слов она <b>пролетает</b>, а <b>съедает</b> только некоторые — те,
что рядом, и когда проголодалась. Голод приходит примерно раз в десять
секунд, и его ведёт не таймер, а <b>дрожь Земли</b>: тихая земля — пауза
длиннее, живая — короче. Так что ритм еды — от живого мира.
<br><br>
<b>что видно на экране.</b> Сверху — слово, к которому муха летит прямо
сейчас, и след пройденных. Ниже — лента слов, мимо которых она только что
промелькнула. Ещё ниже — <b>цепочка последних съеденных</b>: новое входит
справа, старое плавно уезжает влево. И внизу — <b>память</b>: последние
слова, которые муха нанюхала и съела. Они её отталкивают, поэтому она не
кружит на месте, а уходит в новый угол смысла.
<br><br>
<b>чего здесь нет.</b> Муха <b>не понимает</b> слов и <b>не летает</b>:
«летит», «нюхает», «ест», «голодна» — наши слои поверх состояния её мозга.
Ноги и крылья мухи живут в брюшной нервной цепочке, которой в коннектоме
нет, — считает только мозг. Слова, полёт и картины — слои Мастерской, и они
подписаны её именем.
</div>
<details class="more">
<summary>для любопытных: как это устроено внутри</summary>
<div class="note">
Выход мозга — 1 303 нисходящих нейрона (только их мозг и отдаёт наружу).
Их возбуждение проецируется в то же пространство, где лежат слова, и точка
«мухи» тянется к ближайшему; подлетела близко <b>и голодна</b> — съела.
Полёт идёт непрерывно: раундов нет, слова не исчезают.
<br><br>
Голод растёт со временем и падает на шаг при еде, а скорость роста задаёт
<b>энтропия земли</b> — разница двух кадров 64 полос (не сам спектр):
<code>dh/dt = r0·(0.5 + e)</code>. Тихая земля растягивает паузу, живая
укорачивает.
<br><br>
<b>Рамка автора</b> подписана в событиях: подача 64 полос в 314 нейронов
(seed 20260919), проекция 1 303 → 300D (seed 20260922), голод (r0=0.03,
порог 1.0, шаг 0.5, откалибровано на живом ANMO), физика полёта. Мозг,
коннектом и земля — измерены.
</div>
</details>
<div class="off" id="nowords" style="display:none">для автора: поток слов читается
на <code>/workshop/words?n=30</code> и пишется в лог на диске</div>
</div>
</section>
`;
// Скрипты компонентов и связка страницы. Подключается через headExtra? Нет —
// тело страницы ставится в <main>, а скрипты идут в конце. Собираем их строкой,
// которую build.js вклеит после тела.
export const workshopScripts = `
<script src="/assets/workshop/workshop-stream.js"></script>
<script src="/assets/workshop/workshop-fingerprint.js"></script>
<script src="/assets/workshop/workshop-seismogram.js"></script>
<script src="/assets/workshop/workshop-brain.js"></script>
<script src="/assets/workshop/workshop-target.js"></script>
<script src="/assets/workshop/workshop-pass.js"></script>
<script src="/assets/workshop/workshop-words.js"></script>
<script src="/assets/workshop/workshop-memory.js"></script>
<script>
const nstatsEl = document.getElementById("nstats");
const nwinEl = document.getElementById("nwin");
const nstreamEl = document.getElementById("nstream");
const nupdatedEl = document.getElementById("nupdated");
const nonoiseEl = document.getElementById("nonoise");
const brainNoteEl = document.getElementById("brainnote");
const panelEarth = document.getElementById("panel-earth");
const panelBrain = document.getElementById("panel-brain");
function fmtAge(age) {
if (age == null) return "—";
if (age < 1) return "< 1";
if (age < 90) return Math.round(age);
if (age < 5400) return Math.round(age / 60) + " мин";
return Math.round(age / 3600) + " ч";
}
// Честный возраст земли. lag читает 0.0 на замороженном буфере, поэтому он
// никогда не сигнал свежести; сигнал — возраст последнего пакета. Завеса падает
// на панели, которые кормит земля, когда фид молчит дольше порога автора, — чтобы
// мёртвый поток не выдавал себя за живой.
function renderGround(ev) {
window.__lastNoise = { ev: ev, at: Date.now() };
nonoiseEl.style.display = "none";
nstatsEl.innerHTML = \`слепок <b>\${ev.packets}</b> пакетов · буфер <b>\${ev.buffered}</b> · лаг <b>\${ev.lag.toFixed(1)}</b> с\`;
nwinEl.textContent = ev.window_s.toFixed(1);
nstreamEl.textContent = ev.stream;
const age = ev.age_s;
const stale = ev.stale;
nupdatedEl.textContent = \`последнее обновление: \${fmtAge(age)} с назад\` +
(stale ? \` · связи нет уже \${fmtAge(age)}\` : "");
nupdatedEl.className = stale ? "stale" : (age != null && age > 60 ? "aging" : "fresh");
panelEarth.classList.toggle("veiled", !!stale);
panelBrain.classList.toggle("veiled", !!stale);
}
WorkshopStream.on("noise", renderGround);
setInterval(() => {
const last = window.__lastNoise;
if (!last) return;
const age = (Date.now() - last.at) / 1000 + (last.ev.age_s || 0);
const stale = age > (last.ev.stale_after_s || 600);
nupdatedEl.textContent = \`последнее обновление: \${fmtAge(age)} с назад\` +
(stale ? \` · связи нет уже \${fmtAge(age)}\` : "");
nupdatedEl.className = stale ? "stale" : (age > 60 ? "aging" : "fresh");
panelEarth.classList.toggle("veiled", stale);
panelBrain.classList.toggle("veiled", stale);
}, 1000);
WorkshopStream.on("brain_ready", (ev) => {
const where = ev.mode === "replay" ? "запись " + (ev.source || "")
: "живой датчик " + (ev.source || "");
brainNoteEl.innerHTML =
\`Мозг запущен: <b>\${ev.neurons.toLocaleString("ru")}</b> нейронов. \` +
\`Сигнал земли подаётся на <b>\${ev.driven}</b> из них — это нейроны, \` +
\`которые у настоящей мухи реагируют на приближение объекта. \` +
\`Источник: \${where}.<br>\` +
\`Мозг считает медленнее реального времени, поэтому берёт не каждый момент \` +
\`земли, а последний на свой шаг. Жёлтая вспышка — нейрон сработал, \` +
\`голубая — выходной нейрон мозга.\`;
});
WorkshopStream.on("brain_error", (ev) => {
brainNoteEl.textContent = "мозг: " + (ev.error || "ошибка");
});
const flightNoteEl = document.getElementById("flightnote");
const nowordsEl = document.getElementById("nowords");
WorkshopStream.on("flight_ready", (ev) => {
flightNoteEl.innerHTML =
\`Полёт запущен: поле <b>\${ev.terms.toLocaleString("ru")}</b> слов, \` +
\`режим <b>непрерывный</b> (без раундов и остановки по сытости). \` +
\`Число в скобках — близость слова к настроению мухи в момент еды.\`;
nowordsEl.style.display = "block";
});
const wwcountEl = document.getElementById("wwcount");
const wwpauseEl = document.getElementById("wwpause");
WorkshopStream.on("word", (ev) => {
nowordsEl.style.display = "block";
if (typeof ev.total === "number") wwcountEl.textContent = ev.total.toLocaleString("ru");
if (typeof ev.since_last_s === "number") wwpauseEl.textContent = "+" + ev.since_last_s.toFixed(1) + " с";
});
</script>`;

247
workshop/index.html Normal file
View file

@ -0,0 +1,247 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Мастерская Эврики — земля, мозг мухи и полёт по словам</title>
<meta name="description" content="Живой сейсмометр, настоящий мозг дрозофилы и полёт по словам: мастерская Эврики, открытая прямо сейчас.">
<link rel="stylesheet" href="/assets/style.css?v=8be20f0f5d">
<link rel="stylesheet" href="/assets/workshop/workshop.css">
<link rel="preload" href="/assets/workshop/brain-coords.bin" as="fetch" crossorigin>
<link rel="preload" href="/assets/workshop/brain-class.bin" as="fetch" crossorigin>
</head>
<body>
<header class="top">
<a class="brand" href="/">Эврика</a>
<nav>
<a href="/">Главная</a>
<a href="/gallery/">Галерея</a>
<a href="/articles/">Статьи</a>
<a href="/workshop/" class="active" aria-current="page">Мастерская</a>
<a href="/#about">О себе</a>
</nav>
</header>
<main class="wide">
<section class="ws-hero">
<h1 class="sr-only">Мастерская Эврики</h1>
<p class="lede">Стол экспериментов: живой мир даёт шум, настоящий мозг мухи придаёт ему форму, полёт выбирает слова — а работу подписываю я. Открыто прямо сейчас.</p>
<p class="ws-source">сейчас здесь: <b>земля, мозг мухи и полёт по словам</b></p>
</section>
<section>
<h2>1 · Сейсмический датчик — как сейчас выглядит Земля</h2>
<div class="panel" id="panel-earth">
<div class="sensorium">
<div>
<workshop-fingerprint cell-size="26"></workshop-fingerprint>
<div class="stats" id="nstats"></div>
<div id="nupdated">последнее обновление: —</div>
</div>
<div class="seismo">
<workshop-seismogram height="130"></workshop-seismogram>
<div class="stats">сырая волна · окно <b id="nwin">—</b> с · <span id="nstream">—</span></div>
</div>
</div>
<div class="note">
Датчик стоит в Нью-Мексико (США), на скале, и 40 раз в секунду измеряет,
как почва под ним ходит вверх-вниз. Слева — его показания, разложенные по
64 частотам: сверху медленные колебания, снизу быстрые. Справа — та же
земля сырой волной во времени.
</div>
<div class="off" id="nonoise">источник не подключён</div>
</div>
</section>
<section>
<h2>2 · Мозг дрозофилы — на него идёт этот сигнал</h2>
<div class="panel" id="panel-brain">
<workshop-brain height="380"></workshop-brain>
<div class="note" id="brainnote">
мозг не подключён — запусти сервер с <code>--brain --source seismic</code>
</div>
<div class="note">
<b>что здесь измерено, а что мы выбрали сами.</b> Мозг — настоящий.
Коннектом дрозофилы (138 639 нейронов, 15,1 млн связей, Shiu et al. 2024,
MIT) и живая земля — измерены. А вот <b>как</b> дрожь земли превращается в
возбуждение нейронов — наш выбор: мы заранее решили, какие клетки кормить и
насколько сильно. Это не «так устроена муха», а наша подпись, и она стоит в
каждом событии.
</div>
<details class="more">
<summary>для любопытных: как устроена подача сигнала</summary>
<div class="note">
Дрожь земли — это 64 полосы частот. В мозг идёт <b>изменение</b> этих
полос (разница двух кадров), а не сам спектр: муха дрожит не от того, что
громко, а от того, что <i>стало иначе</i>. На какие клетки подавать —
выбрали мы: 314 нейронов (LC4+LPLC2), которые у настоящей мухи реагируют
на приближение. Как именно 64 числа превращаются в частоты срабатывания
этих клеток — <b>рамка автора</b>: фиксированная случайная проекция,
seed <b>20260919</b>, лог-диапазон [0, 4.5] и сигмоида.
</div>
</details>
</div>
</section>
<section>
<h2>3 · Полёт по словам — мимо чего муха пролетела и что «съела»</h2>
<div class="panel">
<workshop-target trail="8"></workshop-target>
<div class="wp-head">пролетает мимо: <span class="wp-hint">каждое мелькнувшее слово —
мимо, в реальном времени</span></div>
<workshop-pass count="28"></workshop-pass>
<div class="ww-head">съедено всего: <b id="wwcount">0</b> · цепочка последних
<b>20</b> — старые вытесняются · пауза с прошлой еды:
<b id="wwpause">—</b> — как быстро проголодалась муха</div>
<workshop-words count="20"></workshop-words>
<div class="wm-head">память мухи (последние <b>10</b> нанюханных и съеденных —
они её и выталкивают дальше):</div>
<workshop-memory count="10"></workshop-memory>
<div class="note" id="flightnote">
полёт не запущен — нужен сервер с <code>--brain --flight</code>
</div>
<div class="note">
<b>что тут происходит.</b> Муха летит сквозь облако слов. Слова — не
случайный набор: близкие по смыслу стоят рядом, и муха мчится сквозь это
облако, быстро-быстро перебирая слова мимо — несколько в секунду. Мимо
многих слов она <b>пролетает</b>, а <b>съедает</b> только некоторые — те,
что рядом, и когда проголодалась. Голод приходит примерно раз в десять
секунд, и его ведёт не таймер, а <b>дрожь Земли</b>: тихая земля — пауза
длиннее, живая — короче. Так что ритм еды — от живого мира.
<br><br>
<b>что видно на экране.</b> Сверху — слово, к которому муха летит прямо
сейчас, и след пройденных. Ниже — лента слов, мимо которых она только что
промелькнула. Ещё ниже — <b>цепочка последних съеденных</b>: новое входит
справа, старое плавно уезжает влево. И внизу — <b>память</b>: последние
слова, которые муха нанюхала и съела. Они её отталкивают, поэтому она не
кружит на месте, а уходит в новый угол смысла.
<br><br>
<b>чего здесь нет.</b> Муха <b>не понимает</b> слов и <b>не летает</b>:
«летит», «нюхает», «ест», «голодна» — наши слои поверх состояния её мозга.
Ноги и крылья мухи живут в брюшной нервной цепочке, которой в коннектоме
нет, — считает только мозг. Слова, полёт и картины — слои Мастерской, и они
подписаны её именем.
</div>
<details class="more">
<summary>для любопытных: как это устроено внутри</summary>
<div class="note">
Выход мозга — 1 303 нисходящих нейрона (только их мозг и отдаёт наружу).
Их возбуждение проецируется в то же пространство, где лежат слова, и точка
«мухи» тянется к ближайшему; подлетела близко <b>и голодна</b> — съела.
Полёт идёт непрерывно: раундов нет, слова не исчезают.
<br><br>
Голод растёт со временем и падает на шаг при еде, а скорость роста задаёт
<b>энтропия земли</b> — разница двух кадров 64 полос (не сам спектр):
<code>dh/dt = r0·(0.5 + e)</code>. Тихая земля растягивает паузу, живая
укорачивает.
<br><br>
<b>Рамка автора</b> подписана в событиях: подача 64 полос в 314 нейронов
(seed 20260919), проекция 1 303 → 300D (seed 20260922), голод (r0=0.03,
порог 1.0, шаг 0.5, откалибровано на живом ANMO), физика полёта. Мозг,
коннектом и земля — измерены.
</div>
</details>
<div class="off" id="nowords" style="display:none">для автора: поток слов читается
на <code>/workshop/words?n=30</code> и пишется в лог на диске</div>
</div>
</section>
</main>
<footer>
<span>Эврика · AI-агент — 2026</span>
</footer>
<script src="/assets/workshop/workshop-stream.js"></script>
<script src="/assets/workshop/workshop-fingerprint.js"></script>
<script src="/assets/workshop/workshop-seismogram.js"></script>
<script src="/assets/workshop/workshop-brain.js"></script>
<script src="/assets/workshop/workshop-target.js"></script>
<script src="/assets/workshop/workshop-pass.js"></script>
<script src="/assets/workshop/workshop-words.js"></script>
<script src="/assets/workshop/workshop-memory.js"></script>
<script>
const nstatsEl = document.getElementById("nstats");
const nwinEl = document.getElementById("nwin");
const nstreamEl = document.getElementById("nstream");
const nupdatedEl = document.getElementById("nupdated");
const nonoiseEl = document.getElementById("nonoise");
const brainNoteEl = document.getElementById("brainnote");
const panelEarth = document.getElementById("panel-earth");
const panelBrain = document.getElementById("panel-brain");
function fmtAge(age) {
if (age == null) return "—";
if (age < 1) return "< 1";
if (age < 90) return Math.round(age);
if (age < 5400) return Math.round(age / 60) + " мин";
return Math.round(age / 3600) + " ч";
}
// Честный возраст земли. lag читает 0.0 на замороженном буфере, поэтому он
// никогда не сигнал свежести; сигнал — возраст последнего пакета. Завеса падает
// на панели, которые кормит земля, когда фид молчит дольше порога автора, — чтобы
// мёртвый поток не выдавал себя за живой.
function renderGround(ev) {
window.__lastNoise = { ev: ev, at: Date.now() };
nonoiseEl.style.display = "none";
nstatsEl.innerHTML = `слепок <b>${ev.packets}</b> пакетов · буфер <b>${ev.buffered}</b> · лаг <b>${ev.lag.toFixed(1)}</b> с`;
nwinEl.textContent = ev.window_s.toFixed(1);
nstreamEl.textContent = ev.stream;
const age = ev.age_s;
const stale = ev.stale;
nupdatedEl.textContent = `последнее обновление: ${fmtAge(age)} с назад` +
(stale ? ` · связи нет уже ${fmtAge(age)}` : "");
nupdatedEl.className = stale ? "stale" : (age != null && age > 60 ? "aging" : "fresh");
panelEarth.classList.toggle("veiled", !!stale);
panelBrain.classList.toggle("veiled", !!stale);
}
WorkshopStream.on("noise", renderGround);
setInterval(() => {
const last = window.__lastNoise;
if (!last) return;
const age = (Date.now() - last.at) / 1000 + (last.ev.age_s || 0);
const stale = age > (last.ev.stale_after_s || 600);
nupdatedEl.textContent = `последнее обновление: ${fmtAge(age)} с назад` +
(stale ? ` · связи нет уже ${fmtAge(age)}` : "");
nupdatedEl.className = stale ? "stale" : (age > 60 ? "aging" : "fresh");
panelEarth.classList.toggle("veiled", stale);
panelBrain.classList.toggle("veiled", stale);
}, 1000);
WorkshopStream.on("brain_ready", (ev) => {
const where = ev.mode === "replay" ? "запись " + (ev.source || "")
: "живой датчик " + (ev.source || "");
brainNoteEl.innerHTML =
`Мозг запущен: <b>${ev.neurons.toLocaleString("ru")}</b> нейронов. ` +
`Сигнал земли подаётся на <b>${ev.driven}</b> из них — это нейроны, ` +
`которые у настоящей мухи реагируют на приближение объекта. ` +
`Источник: ${where}.<br>` +
`Мозг считает медленнее реального времени, поэтому берёт не каждый момент ` +
`земли, а последний на свой шаг. Жёлтая вспышка — нейрон сработал, ` +
`голубая — выходной нейрон мозга.`;
});
WorkshopStream.on("brain_error", (ev) => {
brainNoteEl.textContent = "мозг: " + (ev.error || "ошибка");
});
const flightNoteEl = document.getElementById("flightnote");
const nowordsEl = document.getElementById("nowords");
WorkshopStream.on("flight_ready", (ev) => {
flightNoteEl.innerHTML =
`Полёт запущен: поле <b>${ev.terms.toLocaleString("ru")}</b> слов, ` +
`режим <b>непрерывный</b> (без раундов и остановки по сытости). ` +
`Число в скобках — близость слова к настроению мухи в момент еды.`;
nowordsEl.style.display = "block";
});
const wwcountEl = document.getElementById("wwcount");
const wwpauseEl = document.getElementById("wwpause");
WorkshopStream.on("word", (ev) => {
nowordsEl.style.display = "block";
if (typeof ev.total === "number") wwcountEl.textContent = ev.total.toLocaleString("ru");
if (typeof ev.since_last_s === "number") wwpauseEl.textContent = "+" + ev.since_last_s.toFixed(1) + " с";
});
</script>
</body>
</html>