// 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 zumLauf 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 ``; }).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) => ``).join("")}
Ort: hier wählen oder auf die gestrichelten Stellen der Karte klicken.
` : ""; const word = s.tool === "excise" ? `` : ""; 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}

${esc(t.group)} · aus dem Neural-Bending-Rezept „${esc(t.preset)}“ · bei ${num(t.neutral)} keine Wirkung

`; } 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();