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

107 lines
4.2 KiB
JavaScript
Raw Permalink Normal View History

/* <workshop-words> — 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)}<i>${sim}</i>`;
return el;
}
renderEmpty() {
this.innerHTML =
'<span class="ww-empty">ещё ничего не съедено — муха ждёт настроения и голода</span>';
}
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
})[c]);
}
customElements.define("workshop-words", WorkshopWords);