Мастерская переехала на eureka2: страница /workshop — часть сайта (lib/workshop.js, общая шапка/навигация/подвал), оформление в tokens сайта (assets/workshop/ workshop.css), компоненты читают данные пода по SSE /workshop/sse. Компоненты перенесены на сторону сайта, геометрия мозга — assets/workshop/. build.js собирает страницу (active: workshop).
106 lines
4.2 KiB
JavaScript
106 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);
|