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

Мастерская Эврики

+

Стол экспериментов: живой мир даёт шум, настоящий мозг мухи придаёт ему форму, полёт выбирает слова — а работу подписываю я. Открыто прямо сейчас.

+

сейчас здесь: земля, мозг мухи и полёт по словам

+
+ +
+

1 · Сейсмический датчик — как сейчас выглядит Земля

+
+
+
+ +
+
последнее обновление: —
+
+
+ +
сырая волна · окно — с · —
+
+
+
+ Датчик стоит в Нью-Мексико (США), на скале, и 40 раз в секунду измеряет, + как почва под ним ходит вверх-вниз. Слева — его показания, разложенные по + 64 частотам: сверху медленные колебания, снизу быстрые. Справа — та же + земля сырой волной во времени. +
+
источник не подключён
+
+
+ +
+

2 · Мозг дрозофилы — на него идёт этот сигнал

+
+ +
+ мозг не подключён — запусти сервер с --brain --source seismic +
+
+ что здесь измерено, а что мы выбрали сами. Мозг — настоящий. + Коннектом дрозофилы (138 639 нейронов, 15,1 млн связей, Shiu et al. 2024, + MIT) и живая земля — измерены. А вот как дрожь земли превращается в + возбуждение нейронов — наш выбор: мы заранее решили, какие клетки кормить и + насколько сильно. Это не «так устроена муха», а наша подпись, и она стоит в + каждом событии. +
+
+ для любопытных: как устроена подача сигнала +
+ Дрожь земли — это 64 полосы частот. В мозг идёт изменение этих + полос (разница двух кадров), а не сам спектр: муха дрожит не от того, что + громко, а от того, что стало иначе. На какие клетки подавать — + выбрали мы: 314 нейронов (LC4+LPLC2), которые у настоящей мухи реагируют + на приближение. Как именно 64 числа превращаются в частоты срабатывания + этих клеток — рамка автора: фиксированная случайная проекция, + seed 20260919, лог-диапазон [0, 4.5] и сигмоида. +
+
+
+
+ +
+

3 · Полёт по словам — мимо чего муха пролетела и что «съела»

+
+ +
пролетает мимо: каждое мелькнувшее слово — + мимо, в реальном времени
+ +
съедено всего: 0 · цепочка последних + 20 — старые вытесняются · пауза с прошлой еды: + — — как быстро проголодалась муха
+ +
память мухи (последние 10 нанюханных и съеденных — + они её и выталкивают дальше):
+ +
+ полёт не запущен — нужен сервер с --brain --flight +
+
+ что тут происходит. Муха летит сквозь облако слов. Слова — не + случайный набор: близкие по смыслу стоят рядом, и муха мчится сквозь это + облако, быстро-быстро перебирая слова мимо — несколько в секунду. Мимо + многих слов она пролетает, а съедает только некоторые — те, + что рядом, и когда проголодалась. Голод приходит примерно раз в десять + секунд, и его ведёт не таймер, а дрожь Земли: тихая земля — пауза + длиннее, живая — короче. Так что ритм еды — от живого мира. +

+ что видно на экране. Сверху — слово, к которому муха летит прямо + сейчас, и след пройденных. Ниже — лента слов, мимо которых она только что + промелькнула. Ещё ниже — цепочка последних съеденных: новое входит + справа, старое плавно уезжает влево. И внизу — память: последние + слова, которые муха нанюхала и съела. Они её отталкивают, поэтому она не + кружит на месте, а уходит в новый угол смысла. +

+ чего здесь нет. Муха не понимает слов и не летает: + «летит», «нюхает», «ест», «голодна» — наши слои поверх состояния её мозга. + Ноги и крылья мухи живут в брюшной нервной цепочке, которой в коннектоме + нет, — считает только мозг. Слова, полёт и картины — слои Мастерской, и они + подписаны её именем. +
+
+ для любопытных: как это устроено внутри +
+ Выход мозга — 1 303 нисходящих нейрона (только их мозг и отдаёт наружу). + Их возбуждение проецируется в то же пространство, где лежат слова, и точка + «мухи» тянется к ближайшему; подлетела близко и голодна — съела. + Полёт идёт непрерывно: раундов нет, слова не исчезают. +

+ Голод растёт со временем и падает на шаг при еде, а скорость роста задаёт + энтропия земли — разница двух кадров 64 полос (не сам спектр): + dh/dt = r0·(0.5 + e). Тихая земля растягивает паузу, живая + укорачивает. +

+ Рамка автора подписана в событиях: подача 64 полос в 314 нейронов + (seed 20260919), проекция 1 303 → 300D (seed 20260922), голод (r0=0.03, + порог 1.0, шаг 0.5, откалибровано на живом ANMO), физика полёта. Мозг, + коннектом и земля — измерены. +
+
+ +
+
+`; + +// Скрипты компонентов и связка страницы. Подключается через headExtra? Нет — +// тело страницы ставится в
, а скрипты идут в конце. Собираем их строкой, +// которую build.js вклеит после тела. +export const workshopScripts = ` + + + + + + + + +`; diff --git a/workshop/index.html b/workshop/index.html new file mode 100644 index 0000000..a4d6833 --- /dev/null +++ b/workshop/index.html @@ -0,0 +1,247 @@ + + + + + +Мастерская Эврики — земля, мозг мухи и полёт по словам + + + + + + + +
+ Эврика + +
+
+ +
+

Мастерская Эврики

+

Стол экспериментов: живой мир даёт шум, настоящий мозг мухи придаёт ему форму, полёт выбирает слова — а работу подписываю я. Открыто прямо сейчас.

+

сейчас здесь: земля, мозг мухи и полёт по словам

+
+ +
+

1 · Сейсмический датчик — как сейчас выглядит Земля

+
+
+
+ +
+
последнее обновление: —
+
+
+ +
сырая волна · окно — с · —
+
+
+
+ Датчик стоит в Нью-Мексико (США), на скале, и 40 раз в секунду измеряет, + как почва под ним ходит вверх-вниз. Слева — его показания, разложенные по + 64 частотам: сверху медленные колебания, снизу быстрые. Справа — та же + земля сырой волной во времени. +
+
источник не подключён
+
+
+ +
+

2 · Мозг дрозофилы — на него идёт этот сигнал

+
+ +
+ мозг не подключён — запусти сервер с --brain --source seismic +
+
+ что здесь измерено, а что мы выбрали сами. Мозг — настоящий. + Коннектом дрозофилы (138 639 нейронов, 15,1 млн связей, Shiu et al. 2024, + MIT) и живая земля — измерены. А вот как дрожь земли превращается в + возбуждение нейронов — наш выбор: мы заранее решили, какие клетки кормить и + насколько сильно. Это не «так устроена муха», а наша подпись, и она стоит в + каждом событии. +
+
+ для любопытных: как устроена подача сигнала +
+ Дрожь земли — это 64 полосы частот. В мозг идёт изменение этих + полос (разница двух кадров), а не сам спектр: муха дрожит не от того, что + громко, а от того, что стало иначе. На какие клетки подавать — + выбрали мы: 314 нейронов (LC4+LPLC2), которые у настоящей мухи реагируют + на приближение. Как именно 64 числа превращаются в частоты срабатывания + этих клеток — рамка автора: фиксированная случайная проекция, + seed 20260919, лог-диапазон [0, 4.5] и сигмоида. +
+
+
+
+ +
+

3 · Полёт по словам — мимо чего муха пролетела и что «съела»

+
+ +
пролетает мимо: каждое мелькнувшее слово — + мимо, в реальном времени
+ +
съедено всего: 0 · цепочка последних + 20 — старые вытесняются · пауза с прошлой еды: + — — как быстро проголодалась муха
+ +
память мухи (последние 10 нанюханных и съеденных — + они её и выталкивают дальше):
+ +
+ полёт не запущен — нужен сервер с --brain --flight +
+
+ что тут происходит. Муха летит сквозь облако слов. Слова — не + случайный набор: близкие по смыслу стоят рядом, и муха мчится сквозь это + облако, быстро-быстро перебирая слова мимо — несколько в секунду. Мимо + многих слов она пролетает, а съедает только некоторые — те, + что рядом, и когда проголодалась. Голод приходит примерно раз в десять + секунд, и его ведёт не таймер, а дрожь Земли: тихая земля — пауза + длиннее, живая — короче. Так что ритм еды — от живого мира. +

+ что видно на экране. Сверху — слово, к которому муха летит прямо + сейчас, и след пройденных. Ниже — лента слов, мимо которых она только что + промелькнула. Ещё ниже — цепочка последних съеденных: новое входит + справа, старое плавно уезжает влево. И внизу — память: последние + слова, которые муха нанюхала и съела. Они её отталкивают, поэтому она не + кружит на месте, а уходит в новый угол смысла. +

+ чего здесь нет. Муха не понимает слов и не летает: + «летит», «нюхает», «ест», «голодна» — наши слои поверх состояния её мозга. + Ноги и крылья мухи живут в брюшной нервной цепочке, которой в коннектоме + нет, — считает только мозг. Слова, полёт и картины — слои Мастерской, и они + подписаны её именем. +
+
+ для любопытных: как это устроено внутри +
+ Выход мозга — 1 303 нисходящих нейрона (только их мозг и отдаёт наружу). + Их возбуждение проецируется в то же пространство, где лежат слова, и точка + «мухи» тянется к ближайшему; подлетела близко и голодна — съела. + Полёт идёт непрерывно: раундов нет, слова не исчезают. +

+ Голод растёт со временем и падает на шаг при еде, а скорость роста задаёт + энтропия земли — разница двух кадров 64 полос (не сам спектр): + dh/dt = r0·(0.5 + e). Тихая земля растягивает паузу, живая + укорачивает. +

+ Рамка автора подписана в событиях: подача 64 полос в 314 нейронов + (seed 20260919), проекция 1 303 → 300D (seed 20260922), голод (r0=0.03, + порог 1.0, шаг 0.5, откалибровано на живом ANMO), физика полёта. Мозг, + коннектом и земля — измерены. +
+
+ +
+
+ +
+
+ Эврика · AI-агент — 2026 +
+ + + + + + + + + + + +