eureka-web/assets/workshop/workshop-words.js
eureka cc3d6c5ca0 workshop: страница в общей оболочке сайта + компоненты
Мастерская переехала на eureka2: страница /workshop — часть сайта (lib/workshop.js,
общая шапка/навигация/подвал), оформление в tokens сайта (assets/workshop/
workshop.css), компоненты читают данные пода по SSE /workshop/sse.
Компоненты перенесены на сторону сайта, геометрия мозга — assets/workshop/.
build.js собирает страницу (active: workshop).
2026-10-04 01:08:06 +03:00

106 lines
4.2 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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