87 lines
3.3 KiB
JavaScript
87 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);
|