107 lines
4.2 KiB
JavaScript
107 lines
4.2 KiB
JavaScript
|
|
/* <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) => ({
|
|||
|
|
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
|||
|
|
})[c]);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
customElements.define("workshop-words", WorkshopWords);
|