Мастерская переехала на eureka2: страница /workshop — часть сайта (lib/workshop.js, общая шапка/навигация/подвал), оформление в tokens сайта (assets/workshop/ workshop.css), компоненты читают данные пода по SSE /workshop/sse. Компоненты перенесены на сторону сайта, геометрия мозга — assets/workshop/. build.js собирает страницу (active: workshop).
86 lines
3.3 KiB
JavaScript
86 lines
3.3 KiB
JavaScript
/* <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);
|