// The map of Stable Diffusion 1.5 and the tool slots. Runs as js_on_load of a gr.HTML component:
// `element`, `props` and `watch` come from Gradio. app.py replaces __CATALOG__ with the tools.
//
// props.value is JSON. This script owns {"state": {slots, rev}}; Python sends {"server": {...}} with
// the prompt's tokens and, after a run, the measured effect. Python never sends state back, so a
// change made while a run is going is kept.
const CAT = __CATALOG__;
const TOOLS = Object.fromEntries(CAT.tools.map((t) => [t.key, t]));
const COLORS = CAT.colors;
// Top to bottom: text encoding, the UNet inside the sampler loop, the VAE decoder, the timeline.
const W = 960, H = 1310, BW = 150, BH = 96;
const X0 = 260, X1 = 860; // the timeline's 0 .. 100 %
const LAT_Y = 1180, TL_AXIS = 1200, TL_LANE = 1214, LANE_H = 18, LANE_GAP = 28;
const LOOP_Y = 976; // the way back from the sampler to x_t, under the UNet
const FRAME = { x: 140, y: 350, w: 654, h: 606 };
const BLK = {};
["d0", "d1", "d2", "d3"].forEach((b, i) => { BLK[b] = { x: 150 + 44 * i, y: 384 + 116 * i }; });
BLK.mid = { x: 392, y: 848 };
["u0", "u1", "u2", "u3"].forEach((b, j) => { BLK[b] = { x: 634 - 44 * (3 - j), y: 384 + 116 * (3 - j) }; });
const META = {
d0: { label: "Abwärts 1", res: "64 × 64", ch: 320, R: 2, A: 2 },
d1: { label: "Abwärts 2", res: "32 × 32", ch: 640, R: 2, A: 2 },
d2: { label: "Abwärts 3", res: "16 × 16", ch: 1280, R: 2, A: 2 },
d3: { label: "Abwärts 4", res: "8 × 8", ch: 1280, R: 2, A: 0 },
mid: { label: "Mitte", res: "8 × 8", ch: 1280, R: 2, A: 1 },
u0: { label: "Aufwärts 1", res: "8 × 8", ch: 1280, R: 3, A: 0 },
u1: { label: "Aufwärts 2", res: "16 × 16", ch: 1280, R: 3, A: 3 },
u2: { label: "Aufwärts 3", res: "32 × 32", ch: 640, R: 3, A: 3 },
u3: { label: "Aufwärts 4", res: "64 × 64", ch: 320, R: 3, A: 3 },
};
const BLOCKS = Object.keys(META);
const SKIP_FROM = { u0: "d3", u1: "d2", u2: "d1", u3: "d0" };
const GLYPH = {
R: { dx: 6, w: 34, text: "Res" },
S: { dx: 44, w: 48, text: "Selbst" },
X: { dx: 96, w: 46, text: "Quer" },
};
const BOX = { // fixed boxes outside the UNet
prompt: { x: 16, y: 32, w: 600, h: 42 },
tok: { x: 16, y: 98, w: 292, h: 36 },
pos: { x: 324, y: 98, w: 292, h: 36 },
clip: { x: 16, y: 156, w: 600, h: 42 },
ctx: { x: 16, y: 218, w: 600, h: 96 },
promptB: { x: 640, y: 32, w: 304, h: 42 },
clipB: { x: 640, y: 156, w: 304, h: 42 },
ctxB: { x: 640, y: 218, w: 304, h: 96 },
latent: { x: 8, y: 396, w: 118, h: 60 },
eps: { x: 806, y: 396, w: 146, h: 56 },
cfg: { x: 806, y: 474, w: 146, h: 50 },
sampler: { x: 806, y: 546, w: 146, h: 50 },
time: { x: 818, y: 640, w: 124, h: 56 },
vae: { x: 300, y: 1000, w: 260, h: 52 },
image: { x: 590, y: 986, w: 140, h: 140 },
};
const TIPS = {
R: "Res: Faltungsschichten, die das Bild an jeder Stelle umrechnen. Hier hört das Netz auch die Zeit, also wie verrauscht das Bild noch ist.",
S: "Selbst-Aufmerksamkeit: jede Bildstelle schaut auf alle anderen Bildstellen und holt sich, was zu ihr passt.",
X: "Quer-Aufmerksamkeit: jede Bildstelle schaut auf die 77 Vektoren des Kontexts. Nur hier kommt der Text ins Bild.",
out: "Der Ausgang des Blocks, der an den nächsten Block weitergeht.",
prompt: "Prompt A, so wie ihr ihn oben eingebt.",
tok: "Wort-Tabelle: jedes Token wird zu einem Vektor mit 768 Zahlen.",
pos: "Positions-Tabelle: ein Vektor pro Platz 0 bis 76. Er sagt CLIP, an welcher Stelle ein Token steht.",
clip: "CLIP-Textencoder: 12 Transformer-Schichten mischen die Token-Vektoren miteinander.",
ctx: "Kontext: 77 Vektoren, einer pro Platz. Nach dem Prompt folgen das Ende-Token und Polster, die CLIP mit dem Sinn des ganzen Prompts füllt.",
promptB: "Prompt B wird genauso zu 77 Vektoren und ersetzt in einzelnen Blöcken den Kontext von Prompt A.",
latent: "Das Latent x_t: 4 Kanäle auf 64 × 64 Stellen. Es beginnt als Rauschen aus dem Seed, jeder Sampler-Schritt nimmt Rauschen weg.",
sampler: "Sampler-Schritt (Euler): zieht das vorhergesagte Rauschen ein Stück weit vom Latent ab. Das wiederholt sich für jeden Schritt.",
eps: "Das UNet sagt das Rauschen im Latent voraus, zweimal: einmal mit Prompt und einmal ohne.",
cfg: "Guidance: nimmt die Vorhersage ohne Prompt und geht den Unterschied zur Vorhersage mit Prompt g-mal weiter (g ist der Guidance-Regler, üblich 7,5). So folgt das Bild dem Prompt stärker.",
time: "Zeitpunkt t: Der Sampler sagt dem UNet bei jedem Schritt, wie verrauscht das Latent noch ist (999 = nur Rauschen, 0 = fertig). Jeder Res-Teil im UNet hört diese Zahl.",
vae: "VAE-Decoder: übersetzt das fertige Latent (64 × 64) in Pixel (512 × 512). Er läuft einmal, nach dem letzten Schritt.",
image: "Das erste Bild des letzten Laufs.",
};
let st = { slots: [], sel: 0, rev: 0 };
let srv = { prompt: "", prompt_b: "", tokens: [], tokens_b: [], max_len: 77, effect: null };
let hoverTool = null, hoverSlot = null, drag = null, chipBoxes = {}, root = null;
// ---------------------------------------------------------------- state
function parse(v) {
if (!v) return {};
if (typeof v !== "string") return v;
try { return JSON.parse(v); } catch (e) { return {}; }
}
function cleanSlots(slots) {
return (Array.isArray(slots) ? slots : []).filter((s) => s && TOOLS[s.tool]).slice(0, CAT.max_slots);
}
function absorb(v, initial) {
const d = parse(v);
if (d.server) Object.assign(srv, d.server);
if (d.state && initial) {
st.slots = cleanSlots(d.state.slots);
st.rev = Number.isInteger(d.state.rev) ? d.state.rev : 0;
}
}
function publish(changed) {
if (changed) st.rev += 1;
props.value = JSON.stringify({ state: { slots: st.slots, rev: st.rev } });
if (root) root.dataset.rev = st.rev;
}
function placeOf(slot) {
const t = TOOLS[slot.tool];
return t.places.find((p) => p.value === slot.place) || t.places[0];
}
function guessWord(prompt) {
const words = (prompt || "").toLowerCase().match(/[a-zäöüß]+/g) || [];
const of = words.findIndex((w, i) => w === "of" && ["a", "an", "the"].includes(words[i + 1]));
if (of >= 0 && words[of + 2]) return words[of + 2];
const at = words.findIndex((w) => ["in", "on", "with", "at", "under"].includes(w));
if (at > 0) return words[at - 1];
return words[words.length - 1] || "";
}
function newSlot(key) {
const t = TOOLS[key], p = t.places[0];
return { tool: key, place: p.value, value: p.default, start: p.start, end: p.end,
word: key === "excise" ? guessWord(srv.prompt) : "" };
}
function addTool(key) {
if (st.slots.length < CAT.max_slots) {
st.slots.push(newSlot(key));
st.sel = st.slots.length - 1;
} else {
const i = Math.min(st.sel, st.slots.length - 1);
st.slots[i] = newSlot(key);
st.sel = i;
}
publish(true);
renderAll();
}
function setPlace(i, value) {
const s = st.slots[i], t = TOOLS[s.tool];
const p = t.places.find((q) => q.value === value);
if (!p || s.place === value) return;
Object.assign(s, { place: p.value, value: p.default, start: p.start, end: p.end });
publish(true);
renderAll();
}
function removeSlot(i) {
st.slots.splice(i, 1);
st.sel = Math.max(0, Math.min(st.sel, st.slots.length - 1));
hoverSlot = null;
publish(true);
renderAll();
}
function siteClick(site) {
const s = st.slots[st.sel];
if (!s) return;
const t = TOOLS[s.tool];
const hits = t.places.filter((p) => p.sites.includes(site));
if (!hits.length) {
const owner = st.slots.findIndex((q) => placeOf(q).sites.includes(site));
if (owner >= 0 && owner !== st.sel) { st.sel = owner; renderAll(); }
return;
}
const k = hits.findIndex((p) => p.value === s.place);
setPlace(st.sel, hits[(k + 1) % hits.length].value);
}
// ---------------------------------------------------------------- helpers
const esc = (s) => String(s).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """);
const tx = (pct) => X0 + (X1 - X0) * Math.min(Math.max(pct, 0), 100) / 100;
const num = (x) => (Math.round(x * 1000) / 1000).toString().replace(".", ",");
function pct(v) {
if (!(v > 0)) return "0 %";
if (v < 0.01) return "<1 %";
if (v > 9.99) return ">999 %";
return Math.round(v * 100) + " %";
}
function glyphBox(b, k) {
const g = GLYPH[k], p = BLK[b];
return { x: p.x + g.dx, y: p.y + 23, w: g.w, h: 20 };
}
function wordChips(word) {
const target = (word || "").toLowerCase().replace(/\s+/g, "");
const toks = srv.tokens || [];
const hit = [];
if (!target) return hit;
for (let k = 0; k < toks.length; k++) {
let acc = "";
for (let j = k; j < toks.length && acc.length < target.length; j++) {
acc += toks[j].toLowerCase();
if (acc === target) { for (let m = k; m <= j; m++) hit.push(m); break; }
}
}
return hit;
}
// Box (or line) to draw a site's highlight around. Word and padding chips depend on the slot.
function siteShapes(site, slot) {
if (BOX[site]) return [BOX[site]];
if (site === "ctx_word") return wordChips(slot && slot.word).map((k) => chipBoxes["t" + k]).filter(Boolean);
if (site === "ctx_pad") return [chipBoxes.end, chipBoxes.pad].filter(Boolean);
if (site.startsWith("skip.")) {
const u = site.slice(5), d = SKIP_FROM[u];
const y = BLK[d].y + 46;
return [{ line: true, x1: BLK[d].x + BW, x2: BLK[u].x, y }];
}
const [b, k] = site.split(".");
if (!BLK[b]) return [];
if (k === "out") return [{ x: BLK[b].x, y: BLK[b].y, w: BW, h: BH }];
if ((k === "S" || k === "X") && META[b].A === 0) return [];
return [glyphBox(b, k)];
}
function ring(shape, color, layer, style) {
const o = 3 + 4 * layer;
if (shape.line) {
const y = shape.y + (layer % 2 ? 1 : -1) * Math.ceil(layer / 2) * 5;
return ` `;
}
const fill = style === "solid" && layer === 0 ? `fill="${color}" fill-opacity="0.16"` : 'fill="none"';
return ` `;
}
function slotsAt(site) {
return st.slots.map((s, i) => [s, i]).filter(([s]) => placeOf(s).sites.includes(site));
}
function tipFor(base, site) {
const who = slotsAt(site).map(([s, i]) => `${i + 1} ${TOOLS[s.tool].label}`);
return esc(base + (who.length ? "\nHier greift ein: " + who.join(", ") : ""));
}
// ---------------------------------------------------------------- the map
function box(b, cls, label, sub, site, tip) {
return `
${esc(label)}
${sub ? `${esc(sub)} ` : ""} `;
}
function arrow(x1, y1, x2, y2, cls = "") {
return ` `;
}
function chips(tokens, b, maxRows, withPad, fixedSite) {
const out = [], toks = tokens || [];
const items = [["start", "‹Start›"]].concat(toks.map((t, k) => ["t" + k, t.length > 12 ? t.slice(0, 11) + "…" : t]));
if (withPad) {
items.push(["end", "‹Ende›"]);
const pad = Math.max(0, (srv.max_len || 77) - 2 - toks.length);
if (pad) items.push(["pad", `‹Polster × ${pad}›`]);
}
let x = b.x + 8, y = b.y + 26, row = 0;
const boxes = {};
for (let n = 0; n < items.length; n++) {
const [id, text] = items[n];
const w = 12 + 6.6 * text.length;
if (x + w > b.x + b.w - 8) {
row += 1; x = b.x + 8; y += 23;
if (row >= maxRows) { out.push(`… und ${items.length - n} weitere `); break; }
}
const special = id === "start" || id === "end" || id === "pad";
const site = fixedSite || (id === "end" || id === "pad" ? "ctx_pad" : id === "start" ? "ctx" : "ctx_word");
out.push(`
${esc(text)} `);
boxes[id] = { x, y, w, h: 20 };
x += w + 4;
}
return [out.join(""), boxes];
}
function clip(text, n) {
return text.length > n ? text.slice(0, n - 1) + "…" : text;
}
function textLane() {
const usesB = st.slots.some((s) => s.tool === "layered");
const [ctxChips, boxes] = chips(srv.tokens, BOX.ctx, 3, true);
chipBoxes = boxes;
const stripes = (b) => Array.from({ length: 12 }, (_, k) =>
` `).join("");
const [bChips] = chips(srv.tokens_b, BOX.ctxB, 3, false, "promptB");
const cx = BOX.ctx.x + BOX.ctx.w / 2, bx = BOX.ctxB.x + 120;
return `
Text
${box(BOX.prompt, "", "Prompt A", clip(srv.prompt || "", 80), "prompt", TIPS.prompt)}
${arrow(162, 74, 162, 96)} ${arrow(470, 74, 470, 96)}
${box(BOX.tok, "", "Wort-Tabelle", "", "tok", TIPS.tok)}
${box(BOX.pos, "", "Positionen", "", "pos", TIPS.pos)}
+
${arrow(316, 134, 316, 154)}
CLIP-Textencoder
12 Schichten ${stripes(BOX.clip)}
${arrow(316, 198, 316, 216)}
Kontext · 77 Vektoren
${ctxChips}
${arrow(cx, BOX.ctx.y + BOX.ctx.h, cx, FRAME.y - 2)}
in jede Quer -Aufmerksamkeit
nur für „Zweiter Prompt“
${box(BOX.promptB, "", "Prompt B", clip(srv.prompt_b || "", 44), "promptB", TIPS.promptB)}
${arrow(bx, BOX.promptB.y + BOX.promptB.h, bx, BOX.clipB.y - 2)}
CLIP
dieselben Gewichte
${arrow(bx, BOX.clipB.y + BOX.clipB.h, bx, BOX.ctxB.y - 2)}
Kontext B
${bChips}
ersetzt Kontext A
`;
}
function heat(b) {
const e = srv.effect;
if (!e || e.none || !e.steps || !e.impact) return "";
const top = Math.max(1e-6, ...BLOCKS.map((k) => e.impact[k] || 0));
const a = 0.55 * (e.impact[b] || 0) / top * (e.rev !== st.rev ? 0.4 : 1);
return ` `;
}
function unetBlock(b) {
const p = BLK[b], m = META[b];
const glyphs = (m.A ? ["R", "S", "X"] : ["R"]).map((k) => {
const g = glyphBox(b, k), site = `${b}.${k}`;
const count = k === "R" ? m.R : m.A;
return `
${GLYPH[k].text} `;
}).join("");
const none = m.A ? "" : `nur Faltungen `;
return `
${heat(b)}
${m.label}
${m.res}
${glyphs}${none}`;
}
function unet() {
const parts = [`
UNet
läuft einmal pro Schritt `];
// skips first, so blocks sit on top
for (const [u, d] of Object.entries(SKIP_FROM)) {
const y = BLK[d].y + 46, x1 = BLK[d].x + BW, x2 = BLK[u].x;
parts.push(`
Skip `);
}
["d0", "d1", "d2"].forEach((b, i) => {
const p = BLK[b], q = BLK[["d1", "d2", "d3"][i]];
parts.push(arrow(p.x + 75, p.y + BH, q.x + 75, q.y - 2));
});
parts.push(arrow(BLK.d3.x + 75, BLK.d3.y + BH, BLK.mid.x + 30, BLK.mid.y - 2));
parts.push(arrow(BLK.mid.x + BW - 30, BLK.mid.y, BLK.u0.x + 75, BLK.u0.y + BH + 2));
["u0", "u1", "u2"].forEach((b, i) => {
const p = BLK[b], q = BLK[["u1", "u2", "u3"][i]];
parts.push(arrow(p.x + 75, p.y, q.x + 75, q.y + BH + 2));
});
BLOCKS.forEach((b) => parts.push(unetBlock(b)));
// the loop around the UNet
parts.push(box(BOX.latent, "", "Latent x_t", "Start: Rauschen", "latent", TIPS.latent));
parts.push(box(BOX.eps, "", "Vorhersage ε", "mit und ohne Prompt", "eps", TIPS.eps));
parts.push(box(BOX.cfg, "", "Guidance", "g × (mit − ohne)", "cfg", TIPS.cfg));
parts.push(box(BOX.sampler, "", "Sampler-Schritt", "Euler", "sampler", TIPS.sampler));
parts.push(box(BOX.time, "", "Zeitpunkt t", "an alle Res", "time", TIPS.time));
const ly = BLK.d0.y + 42, lx = BOX.latent.x + BOX.latent.w / 2, cx = BOX.eps.x + 60;
const rx = BOX.time.x + BOX.time.w + 6, ty = BOX.time.y + BOX.time.h / 2; // the way back runs right of t
parts.push(arrow(BOX.latent.x + BOX.latent.w, ly, BLK.d0.x - 2, ly));
parts.push(arrow(BLK.u3.x + BW, ly, BOX.eps.x - 2, ly));
parts.push(arrow(cx, BOX.eps.y + BOX.eps.h, cx, BOX.cfg.y - 2));
parts.push(arrow(cx, BOX.cfg.y + BOX.cfg.h, cx, BOX.sampler.y - 2));
parts.push(arrow(cx, BOX.sampler.y + BOX.sampler.h, cx, BOX.time.y - 2));
parts.push(arrow(BOX.time.x, ty, FRAME.x + FRAME.w + 2, ty));
parts.push(` `);
parts.push(`nächster Schritt `);
parts.push(`× Schritte `);
// after the last step
const vx = BOX.vae.x + BOX.vae.w / 2;
parts.push(` `);
parts.push(` `);
parts.push(`nach dem letzten Schritt `);
parts.push(box(BOX.vae, "", "VAE-Decoder", "Latent 64 × 64 → Pixel 512 × 512", "vae", TIPS.vae));
parts.push(arrow(BOX.vae.x + BOX.vae.w, BOX.vae.y + BOX.vae.h / 2, BOX.image.x - 2, BOX.vae.y + BOX.vae.h / 2));
return parts.join("");
}
function imageNode() {
const e = srv.effect, b = BOX.image;
const inner = e && e.thumb
? ` `
: `Bild `;
return `
${inner} `;
}
function effectLayer() {
const e = srv.effect;
if (!e || e.none || !e.steps) {
const lines = e && e.none ? ["Letzter Lauf ohne Eingriff:", "nichts zu messen."]
: ["Nach dem ersten Lauf steht", "hier und in den Blöcken,", "wie stark sich jeder Block", "verändert hat."];
return lines.map((l, k) => `${esc(l)} `).join("");
}
const stale = e.rev !== st.rev;
const N = e.steps, parts = [];
const all = BLOCKS.flatMap((b) => e.blocks[b] || []);
const top = Math.max(1e-6, ...all);
for (const b of BLOCKS) {
const v = e.blocks[b] || [], p = BLK[b];
if (!v.length) continue;
const pts = v.map((y, i) => `${(p.x + 8 + 134 * (i + 0.5) / N).toFixed(1)},${(p.y + 89 - 20 * Math.min(1, y / top)).toFixed(1)}`).join(" ");
parts.push(` `);
parts.push(` `);
parts.push(`Änderung `);
parts.push(`bis ${pct(Math.max(...v))} `);
}
// latent curve over the timeline
const lat = e.latent || [];
if (lat.length) {
const ltop = Math.max(1e-6, ...lat);
const pts = lat.map((y, i) => `${tx(100 * (i + 1) / N).toFixed(1)},${(LAT_Y - 26 * Math.min(1, y / ltop)).toFixed(1)}`).join(" ");
parts.push(` `);
parts.push(`${pct(lat[lat.length - 1])} `);
}
const img = e.image || [];
const meanImg = img.length ? img.reduce((a, b) => a + b, 0) / img.length : 0;
const first = e.first === null || e.first === undefined ? "–" : `Schritt ${e.first + 1} von ${N}`;
parts.push(`
Gemessen
${stale ? '(zum vorigen Eingriff) ' : ""}
Bild: ${pct(meanImg)} anders
Füllung der Blöcke:
erster veränderter Schritt
(${first})
Linie: alle Schritte `);
return `${parts.join("")} `;
}
function timeline() {
const parts = [];
parts.push(`Zeit `);
parts.push(`Latent: Abstand zum Lauf ohne Eingriff `);
parts.push(` `);
parts.push(` `);
for (const p of [0, 25, 50, 75, 100]) {
parts.push(` `);
parts.push(`${p} % `);
}
parts.push(`Rauschen `);
parts.push(`Bild `);
st.slots.forEach((s, i) => {
const t = TOOLS[s.tool], c = COLORS[i], y = TL_LANE + 8 + LANE_GAP * i;
const sel = i === st.sel ? "bm-lane-sel" : "";
parts.push(`${i + 1} ${esc(clip(t.label, 27))} `);
if (t.after_loop) {
parts.push(` `);
parts.push(`danach `);
return;
}
if (t.time_knob) {
const a = tx(s.value);
parts.push(` `);
parts.push(` `);
parts.push(`ab ${num(s.value)} % `);
return;
}
if (!t.window) {
parts.push(` `);
parts.push(`ganzer Lauf (${esc(t.whole)}) `);
return;
}
const a = tx(s.start), b = tx(s.end);
parts.push(` `);
if (b > a) parts.push(` `);
parts.push(` `);
parts.push(` `);
if (t.envelope === "sine" && b > a) { // the strength swells and fades inside the window
const pts = Array.from({ length: 25 }, (_, k) => `${(a + (b - a) * k / 24).toFixed(1)},${(y + LANE_H - 2 - (LANE_H - 5) * Math.sin(Math.PI * k / 24)).toFixed(1)}`);
parts.push(` `);
}
parts.push(`${num(s.start)}–${num(s.end)} % `);
});
if (!st.slots.length) parts.push(`Noch kein Werkzeug gewählt. `);
return parts.join("");
}
function highlights() {
const parts = [], layers = {};
const order = st.slots.map((s, i) => i).sort((a, b) => (a === st.sel) - (b === st.sel));
// other places of the selected slot: dashed, clickable on the map
const cur = st.slots[st.sel];
if (cur) {
const t = TOOLS[cur.tool], here = new Set(placeOf(cur).sites);
const other = new Set(t.places.filter((p) => p.value !== cur.place).flatMap((p) => p.sites).filter((x) => !here.has(x)));
other.forEach((site) => siteShapes(site, cur).forEach((sh) => parts.push(ring(sh, COLORS[st.sel], 0, "dash"))));
}
for (const i of order) {
const s = st.slots[i];
const dim = hoverSlot !== null && hoverSlot !== i;
if (dim) parts.push('');
for (const site of placeOf(s).sites) {
const n = layers[site] || 0;
layers[site] = n + 1;
siteShapes(site, s).forEach((sh) => parts.push(ring(sh, COLORS[i], n, "solid")));
}
if (dim) parts.push(" ");
if (s.tool === "aphasia") parts.push(skippedLayers(s, COLORS[i]));
if (s.tool === "excise" && !wordChips(s.word).length) {
parts.push(`„${esc(s.word || "")}“ steht nicht in Prompt A `);
}
}
if (hoverTool && TOOLS[hoverTool]) {
const seen = new Set();
TOOLS[hoverTool].places.forEach((p) => p.sites.forEach((site) => {
if (seen.has(site)) return;
seen.add(site);
siteShapes(site, { word: guessWord(srv.prompt) }).forEach((sh) => parts.push(ring(sh, "#868e96", 0, "dash")));
}));
}
return parts.join("");
}
// The CLIP layers an early read-out leaves out, over the layer stripes of the CLIP box.
function skippedLayers(s, color) {
const k = Math.min(12, Math.max(0, Math.round(+s.value || 0))), b = BOX.clip;
const out = [];
for (let j = 12 - k; j < 12; j++) {
out.push(` `);
}
const text = k ? `nach Schicht ${12 - k} ausgelesen` : "alle 12 Schichten";
out.push(`${text} `);
return out.join("");
}
function defs() {
const heads = COLORS.concat(["#868e96"]).map((c) =>
` `).join("");
return `
${heads}
`;
}
function paintMap() {
const svg = element.querySelector(".bm-svg");
if (!svg) return;
svg.innerHTML = defs() + textLane() + unet() + imageNode() + effectLayer() + highlights() + timeline();
}
// ---------------------------------------------------------------- palette and slots (HTML)
function renderPalette() {
const pal = element.querySelector(".bm-palette");
pal.innerHTML = CAT.groups.map((g) => `
${esc(g)}
${CAT.tools.filter((t) => t.group === g).map((t) => {
const used = st.slots.map((s, i) => (s.tool === t.key ? i : -1)).filter((i) => i >= 0);
const dots = used.map((i) => ` `).join("");
return `
${t.icon} ${esc(t.label)} ${dots} `;
}).join("")}
`).join("") +
`${st.slots.length >= CAT.max_slots
? "Drei Werkzeuge sind das Maximum: ein weiteres ersetzt das markierte."
: "Klickt ein Werkzeug an, um es einzusetzen (bis zu drei). Mit der Maus darüber seht ihr auf der Karte, wo es wirken kann."}
`;
}
function slotCard(s, i) {
const t = TOOLS[s.tool], c = COLORS[i];
const places = t.places.length > 1
? `${t.places.map((p) => `${esc(p.label)} `).join("")}
Ort: hier wählen oder auf die gestrichelten Stellen der Karte klicken.
` : "";
const word = s.tool === "excise"
? `Wort ` : "";
const lo = Math.min(t.lo, t.hi), hi = Math.max(t.lo, t.hi);
const knob = `${esc(t.knob)}
`;
const win = t.window
? `Zeitfenster
bis %
Oder auf der Zeitleiste unter der Karte ziehen.
`
: t.time_knob ? `Den Zeitpunkt könnt ihr auch auf der Zeitleiste ziehen.
`
: t.after_loop ? `Wirkt nach der Schleife, beim Übersetzen in Pixel.
`
: `Wirkt im ganzen Lauf: ${esc(t.whole)}.
`;
return `
${i + 1} ${t.icon}
${esc(t.label)} ✕
${esc(t.what)}
${places}${word}${knob}${win}
`;
}
function renderSlots() {
const box = element.querySelector(".bm-slots");
const cards = st.slots.map(slotCard);
for (let i = st.slots.length; i < Math.min(st.slots.length + 1, CAT.max_slots); i++) {
cards.push(`${i + 1}
Freier Platz: oben ein Werkzeug wählen.
`);
}
box.innerHTML = cards.join("");
}
function renderAll() {
renderPalette();
renderSlots();
paintMap();
}
// Keep the other input of a pair in step without re-rendering the card (that would steal the focus).
function syncCard(i, except) {
const card = element.querySelector(`.bm-card[data-i="${i}"]`);
if (!card) return;
const s = st.slots[i];
card.querySelectorAll("[data-act]").forEach((inp) => {
if (inp === except || inp.tagName !== "INPUT") return;
const a = inp.dataset.act;
if (a === "value" || a === "start" || a === "end") inp.value = s[a];
});
}
// ---------------------------------------------------------------- events
element.querySelector(".bm-host").innerHTML = ``;
root = element.querySelector(".bm");
const svg = element.querySelector(".bm-svg");
const tip = element.querySelector(".bm-tip");
root.addEventListener("click", (e) => {
const tile = e.target.closest(".bm-tile");
if (tile) { addTool(tile.dataset.key); return; }
const act = e.target.closest("[data-act]");
const card = e.target.closest(".bm-card");
if (act && card && act.dataset.act === "remove") { removeSlot(+card.dataset.i); return; }
if (act && card && act.dataset.act === "place") { st.sel = +card.dataset.i; setPlace(+card.dataset.i, act.dataset.place); return; }
if (card && !card.classList.contains("bm-empty") && +card.dataset.i !== st.sel && !e.target.closest("input")) {
st.sel = +card.dataset.i; renderAll(); return;
}
const site = e.target.closest("[data-site]");
if (site && svg.contains(site) && !e.target.closest("[data-handle]")) siteClick(site.dataset.site);
});
root.addEventListener("input", (e) => {
const inp = e.target, card = inp.closest(".bm-card");
if (!card || !inp.dataset.act) return;
const i = +card.dataset.i, s = st.slots[i], a = inp.dataset.act;
if (!s) return;
if (a === "word") s.word = inp.value;
else {
const v = parseFloat(String(inp.value).replace(",", "."));
if (!Number.isFinite(v)) return;
s[a] = v;
syncCard(i, inp);
}
publish(true);
paintMap();
});
root.addEventListener("pointerover", (e) => {
const tile = e.target.closest(".bm-tile"), card = e.target.closest(".bm-card:not(.bm-empty)");
const ht = tile ? tile.dataset.key : null, hs = card ? +card.dataset.i : null;
if (ht !== hoverTool || hs !== hoverSlot) { hoverTool = ht; hoverSlot = hs; paintMap(); }
});
root.addEventListener("pointerleave", () => { hoverTool = null; hoverSlot = null; paintMap(); });
function svgX(e) {
const pt = svg.createSVGPoint();
pt.x = e.clientX; pt.y = e.clientY;
return pt.matrixTransform(svg.getScreenCTM().inverse()).x;
}
svg.addEventListener("pointerdown", (e) => {
const h = e.target.closest("[data-handle]");
if (!h) return;
const [i, kind] = h.dataset.handle.split(":");
const s = st.slots[+i];
if (!s) return;
e.preventDefault();
st.sel = +i;
drag = { i: +i, kind, x0: svgX(e), start: s.start, end: s.end };
svg.setPointerCapture(e.pointerId);
renderPalette(); renderSlots();
});
svg.addEventListener("pointermove", (e) => {
if (drag) {
const s = st.slots[drag.i];
const d = (svgX(e) - drag.x0) / (X1 - X0) * 100;
const at = Math.round(Math.min(100, Math.max(0, (svgX(e) - X0) / (X1 - X0) * 100)));
if (drag.kind === "start") s.start = Math.min(at, s.end);
else if (drag.kind === "end") s.end = Math.max(at, s.start);
else if (drag.kind === "knob") s.value = at;
else {
const len = drag.end - drag.start;
const a = Math.round(Math.min(100 - len, Math.max(0, drag.start + d)));
s.start = a; s.end = a + len;
}
syncCard(drag.i, null);
paintMap();
return;
}
const t = e.target.closest("[data-tip]");
if (!t) { tip.hidden = true; return; }
const r = element.querySelector(".bm-stage").getBoundingClientRect();
tip.textContent = t.dataset.tip;
tip.hidden = false;
const x = e.clientX - r.left, y = e.clientY - r.top;
tip.style.left = Math.max(0, Math.min(x + 14, r.width - 310)) + "px";
tip.style.top = (y + 18) + "px";
});
function endDrag() {
if (!drag) return;
drag = null;
publish(true);
paintMap();
}
svg.addEventListener("pointerup", endDrag);
svg.addEventListener("pointercancel", endDrag);
svg.addEventListener("pointerleave", () => { tip.hidden = true; });
watch("value", () => {
absorb(props.value, false);
publish(false);
paintMap();
});
absorb(props.value, true);
publish(false);
renderAll();