workshop: страница в общей оболочке сайта + компоненты
Мастерская переехала на eureka2: страница /workshop — часть сайта (lib/workshop.js, общая шапка/навигация/подвал), оформление в tokens сайта (assets/workshop/ workshop.css), компоненты читают данные пода по SSE /workshop/sse. Компоненты перенесены на сторону сайта, геометрия мозга — assets/workshop/. build.js собирает страницу (active: workshop).
This commit is contained in:
parent
e2fe11f723
commit
cc3d6c5ca0
15 changed files with 1627 additions and 2 deletions
106
assets/workshop/workshop-words.js
Normal file
106
assets/workshop/workshop-words.js
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
/* <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);
|
||||
Loading…
Add table
Add a link
Reference in a new issue