eureka-web/assets/workshop/workshop-pass.js

87 lines
3.3 KiB
JavaScript
Raw Normal View History

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