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