Download bendmap.js from Pixelkram/biegen: direct link, hf CLI and curl.
- Browser
- Download file 39.5 kB
-
https://huggingface.co/spaces/Pixelkram/biegen/resolve/main/bendmap.js
- Command line
-
hf download hf://spaces/Pixelkram/biegen/bendmap.js
-
curl -L -o bendmap.js https://huggingface.co/spaces/Pixelkram/biegen/resolve/main/bendmap.js
39.5 kB
| // 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, ">").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 `<line x1="${shape.x1}" y1="${y}" x2="${shape.x2 - 8}" y2="${y}" stroke="${color}" stroke-width="${style === "dash" ? 3 : 5}" | |
| ${style === "dash" ? 'stroke-dasharray="6 5"' : ""} marker-end="url(#bm-arrow-${color.slice(1)})" class="bm-hl"/>`; | |
| } | |
| const fill = style === "solid" && layer === 0 ? `fill="${color}" fill-opacity="0.16"` : 'fill="none"'; | |
| return `<rect x="${shape.x - o}" y="${shape.y - o}" width="${shape.w + 2 * o}" height="${shape.h + 2 * o}" rx="${6 + o}" | |
| stroke="${color}" stroke-width="${style === "dash" ? 2 : 3}" ${style === "dash" ? 'stroke-dasharray="5 4"' : ""} ${fill} class="bm-hl"/>`; | |
| } | |
| 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 `<g class="bm-node ${cls}" data-site="${site}" data-tip="${tipFor(tip, site)}"> | |
| <rect x="${b.x}" y="${b.y}" width="${b.w}" height="${b.h}" rx="8"/> | |
| <text x="${b.x + 10}" y="${b.y + (sub ? 17 : b.h / 2 + 5)}" class="bm-t">${esc(label)}</text> | |
| ${sub ? `<text x="${b.x + 10}" y="${b.y + 33}" class="bm-s">${esc(sub)}</text>` : ""}</g>`; | |
| } | |
| function arrow(x1, y1, x2, y2, cls = "") { | |
| return `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" class="bm-arrow ${cls}" marker-end="url(#bm-head)"/>`; | |
| } | |
| 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(`<text x="${x}" y="${y + 13}" class="bm-s">… und ${items.length - n} weitere</text>`); 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(`<g class="bm-chip-svg ${special ? "bm-special" : ""}" data-site="${site}"> | |
| <rect x="${x}" y="${y}" width="${w}" height="20" rx="10"/><text x="${x + w / 2}" y="${y + 14.5}" text-anchor="middle">${esc(text)}</text></g>`); | |
| 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) => | |
| `<rect x="${b.x + b.w - 206 + k * 16}" y="${b.y + 9}" width="10" height="24" rx="2" class="bm-stripe"/>`).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 x="16" y="22" class="bm-h">Text</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)} | |
| <text x="316" y="121" class="bm-t" text-anchor="middle">+</text> | |
| ${arrow(316, 134, 316, 154)} | |
| <g class="bm-node" data-site="clip" data-tip="${tipFor(TIPS.clip, "clip")}"> | |
| <rect x="${BOX.clip.x}" y="${BOX.clip.y}" width="${BOX.clip.w}" height="${BOX.clip.h}" rx="8"/> | |
| <text x="${BOX.clip.x + 10}" y="${BOX.clip.y + 18}" class="bm-t">CLIP-Textencoder</text> | |
| <text x="${BOX.clip.x + 10}" y="${BOX.clip.y + 34}" class="bm-s">12 Schichten</text>${stripes(BOX.clip)}</g> | |
| ${arrow(316, 198, 316, 216)} | |
| <g class="bm-node" data-site="ctx" data-tip="${tipFor(TIPS.ctx, "ctx")}"> | |
| <rect x="${BOX.ctx.x}" y="${BOX.ctx.y}" width="${BOX.ctx.w}" height="${BOX.ctx.h}" rx="8"/> | |
| <text x="${BOX.ctx.x + 10}" y="${BOX.ctx.y + 17}" class="bm-t">Kontext · 77 Vektoren</text></g> | |
| ${ctxChips} | |
| ${arrow(cx, BOX.ctx.y + BOX.ctx.h, cx, FRAME.y - 2)} | |
| <text x="${cx + 8}" y="${FRAME.y - 12}" class="bm-s">in jede <tspan class="bm-strong">Quer</tspan>-Aufmerksamkeit</text> | |
| <g class="${usesB ? "" : "bm-off"}"> | |
| <text x="${BOX.promptB.x}" y="22" class="bm-s">nur für „Zweiter Prompt“</text> | |
| ${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)} | |
| <g class="bm-node bm-thin" data-site="promptB" data-tip="${tipFor(TIPS.promptB, "promptB")}"> | |
| <rect x="${BOX.clipB.x}" y="${BOX.clipB.y}" width="${BOX.clipB.w}" height="${BOX.clipB.h}" rx="8"/> | |
| <text x="${BOX.clipB.x + 10}" y="${BOX.clipB.y + 18}" class="bm-t">CLIP</text> | |
| <text x="${BOX.clipB.x + 10}" y="${BOX.clipB.y + 34}" class="bm-s">dieselben Gewichte</text></g> | |
| ${arrow(bx, BOX.clipB.y + BOX.clipB.h, bx, BOX.ctxB.y - 2)} | |
| <g class="bm-node" data-site="promptB" data-tip="${tipFor(TIPS.promptB, "promptB")}"> | |
| <rect x="${BOX.ctxB.x}" y="${BOX.ctxB.y}" width="${BOX.ctxB.w}" height="${BOX.ctxB.h}" rx="8"/> | |
| <text x="${BOX.ctxB.x + 10}" y="${BOX.ctxB.y + 17}" class="bm-t">Kontext B</text></g> | |
| ${bChips} | |
| <line x1="${bx}" y1="${BOX.ctxB.y + BOX.ctxB.h}" x2="${bx}" y2="${FRAME.y - 2}" class="bm-arrow bm-after" marker-end="url(#bm-head)"/> | |
| <text x="${bx + 8}" y="${FRAME.y - 12}" class="bm-s">ersetzt Kontext A</text> | |
| </g>`; | |
| } | |
| 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 `<rect x="${BLK[b].x}" y="${BLK[b].y}" width="${BW}" height="${BH}" rx="10" class="bm-heatfill" fill-opacity="${a.toFixed(3)}"/>`; | |
| } | |
| 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 `<g class="bm-glyph bm-g${k}" data-site="${site}" data-tip="${tipFor(`${m.label} · ${TIPS[k]} (${count} ×)`, site)}"> | |
| <rect x="${g.x}" y="${g.y}" width="${g.w}" height="${g.h}" rx="5"/> | |
| <text x="${g.x + g.w / 2}" y="${g.y + 14}" text-anchor="middle">${GLYPH[k].text}</text></g>`; | |
| }).join(""); | |
| const none = m.A ? "" : `<text x="${p.x + 48}" y="${p.y + 37}" class="bm-s">nur Faltungen</text>`; | |
| return `<g class="bm-block" data-site="${b}.out" data-tip="${tipFor(`${m.label} · ${m.res} Stellen · ${m.ch} Kanäle. ${TIPS.out}`, `${b}.out`)}"> | |
| <rect x="${p.x}" y="${p.y}" width="${BW}" height="${BH}" rx="10" class="bm-block-bg"/> | |
| ${heat(b)} | |
| <text x="${p.x + 8}" y="${p.y + 17}" class="bm-t">${m.label}</text> | |
| <text x="${p.x + BW - 8}" y="${p.y + 17}" class="bm-s" text-anchor="end">${m.res}</text></g> | |
| ${glyphs}${none}`; | |
| } | |
| function unet() { | |
| const parts = [`<rect x="${FRAME.x}" y="${FRAME.y}" width="${FRAME.w}" height="${FRAME.h}" rx="16" class="bm-frame"/> | |
| <text x="${FRAME.x + 12}" y="${FRAME.y + 22}" class="bm-h">UNet</text> | |
| <text x="${FRAME.x + 62}" y="${FRAME.y + 22}" class="bm-s">läuft einmal pro Schritt</text>`]; | |
| // 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(`<g class="bm-skip" data-site="skip.${u}" data-tip="${tipFor(`Skip-Verbindung: trägt die Merkmale aus ${META[d].label} direkt zu ${META[u].label}.`, `skip.${u}`)}"> | |
| <line x1="${x1}" y1="${y}" x2="${x2}" y2="${y}" class="bm-hit"/> | |
| <line x1="${x1}" y1="${y}" x2="${x2 - 4}" y2="${y}" class="bm-arrow bm-skipline" marker-end="url(#bm-head)"/> | |
| <text x="${(x1 + x2) / 2}" y="${y - 5}" text-anchor="middle" class="bm-s">Skip</text></g>`); | |
| } | |
| ["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(`<path d="M ${rx} ${BOX.sampler.y + BOX.sampler.h} L ${rx} ${LOOP_Y} L ${lx} ${LOOP_Y} L ${lx} ${BOX.latent.y + BOX.latent.h + 2}" class="bm-arrow" marker-end="url(#bm-head)"/>`); | |
| parts.push(`<text x="${lx + 10}" y="${LOOP_Y - 6}" class="bm-s">nächster Schritt</text>`); | |
| parts.push(`<text x="${lx + 8}" y="${BOX.latent.y + BOX.latent.h + 40}" class="bm-s">× Schritte</text>`); | |
| // after the last step | |
| const vx = BOX.vae.x + BOX.vae.w / 2; | |
| parts.push(`<circle cx="${vx}" cy="${LOOP_Y}" r="3" class="bm-headpath"/>`); | |
| parts.push(`<line x1="${vx}" y1="${LOOP_Y}" x2="${vx}" y2="${BOX.vae.y - 2}" class="bm-arrow bm-after" marker-end="url(#bm-head)"/>`); | |
| parts.push(`<text x="${vx - 8}" y="${BOX.vae.y - 7}" class="bm-s" text-anchor="end">nach dem letzten Schritt</text>`); | |
| 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 | |
| ? `<image href="${esc(e.thumb)}" x="${b.x}" y="${b.y}" width="${b.w}" height="${b.h}" preserveAspectRatio="xMidYMid slice" clip-path="url(#bm-img-clip)"/>` | |
| : `<text x="${b.x + b.w / 2}" y="${b.y + b.h / 2 + 5}" text-anchor="middle" class="bm-s">Bild</text>`; | |
| return `<g class="bm-node" data-site="image" data-tip="${esc(TIPS.image)}"> | |
| <clipPath id="bm-img-clip"><rect x="${b.x}" y="${b.y}" width="${b.w}" height="${b.h}" rx="8"/></clipPath> | |
| <rect x="${b.x}" y="${b.y}" width="${b.w}" height="${b.h}" rx="8"/>${inner}</g>`; | |
| } | |
| 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) => `<text x="746" y="${1004 + 17 * k}" class="bm-s">${esc(l)}</text>`).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(`<line x1="${p.x + 8}" y1="${p.y + 89}" x2="${p.x + 142}" y2="${p.y + 89}" class="bm-base"/>`); | |
| parts.push(`<polyline points="${pts}" class="bm-spark" pointer-events="none"/>`); | |
| parts.push(`<text x="${p.x + 8}" y="${p.y + 66}" class="bm-s">Änderung</text>`); | |
| parts.push(`<text x="${p.x + BW - 8}" y="${p.y + 66}" text-anchor="end" class="bm-s bm-val">bis ${pct(Math.max(...v))}</text>`); | |
| } | |
| // 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(`<polyline points="${tx(0)},${LAT_Y} ${pts}" class="bm-spark bm-latent" pointer-events="none"/>`); | |
| parts.push(`<text x="${X1 + 6}" y="${LAT_Y - 26 * Math.min(1, lat[lat.length - 1] / ltop) + 4}" class="bm-s bm-val">${pct(lat[lat.length - 1])}</text>`); | |
| } | |
| 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(`<g class="bm-caption"> | |
| <text x="746" y="1000" class="bm-t">Gemessen</text> | |
| ${stale ? '<text x="746" y="1017" class="bm-s">(zum vorigen Eingriff)</text>' : ""} | |
| <text x="746" y="1038" class="bm-s">Bild: ${pct(meanImg)} anders</text> | |
| <text x="746" y="1060" class="bm-s">Füllung der Blöcke:</text> | |
| <text x="746" y="1077" class="bm-s">erster veränderter Schritt</text> | |
| <text x="746" y="1094" class="bm-s">(${first})</text> | |
| <text x="746" y="1116" class="bm-s">Linie: alle Schritte</text></g>`); | |
| return `<g class="bm-effect ${stale ? "bm-stale" : ""}">${parts.join("")}</g>`; | |
| } | |
| function timeline() { | |
| const parts = []; | |
| parts.push(`<text x="16" y="${TL_AXIS + 4}" class="bm-h">Zeit</text>`); | |
| parts.push(`<text x="16" y="${LAT_Y - 14}" class="bm-s">Latent: Abstand zum</text><text x="16" y="${LAT_Y}" class="bm-s">Lauf ohne Eingriff</text>`); | |
| parts.push(`<line x1="${X0}" y1="${LAT_Y}" x2="${X1}" y2="${LAT_Y}" class="bm-base"/>`); | |
| parts.push(`<line x1="${X0}" y1="${TL_AXIS}" x2="${X1}" y2="${TL_AXIS}" class="bm-axis"/>`); | |
| for (const p of [0, 25, 50, 75, 100]) { | |
| parts.push(`<line x1="${tx(p)}" y1="${TL_AXIS - 3}" x2="${tx(p)}" y2="${TL_AXIS + 3}" class="bm-axis"/>`); | |
| parts.push(`<text x="${tx(p)}" y="${TL_AXIS + 15}" text-anchor="middle" class="bm-s bm-tick">${p} %</text>`); | |
| } | |
| parts.push(`<text x="${X0 - 8}" y="${TL_AXIS + 4}" text-anchor="end" class="bm-s">Rauschen</text>`); | |
| parts.push(`<text x="${X1 + 8}" y="${TL_AXIS + 4}" class="bm-s">Bild</text>`); | |
| 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(`<text x="16" y="${y + 14}" class="bm-t bm-lane-label ${sel}" style="fill:${c}">${i + 1} ${esc(clip(t.label, 27))}</text>`); | |
| if (t.after_loop) { | |
| parts.push(`<rect x="${X1 + 14}" y="${y}" width="${W - X1 - 22}" height="${LANE_H}" rx="5" fill="${c}" fill-opacity="0.85"/>`); | |
| parts.push(`<text x="${X1 + 20}" y="${y + 13}" class="bm-on-band">danach</text>`); | |
| return; | |
| } | |
| if (t.time_knob) { | |
| const a = tx(s.value); | |
| parts.push(`<rect x="${a}" y="${y}" width="${Math.max(0, X1 - a)}" height="${LANE_H}" rx="5" fill="${c}" fill-opacity="0.85" data-handle="${i}:knob" class="bm-band"/>`); | |
| parts.push(`<rect x="${a - 5}" y="${y - 3}" width="10" height="${LANE_H + 6}" rx="3" class="bm-handle" style="stroke:${c}" data-handle="${i}:knob" data-tip="${esc(t.knob + ": ziehen")}"/>`); | |
| parts.push(`<text x="${Math.min(a + 8, X1 - 60)}" y="${y + 13}" class="bm-on-band" pointer-events="none">ab ${num(s.value)} %</text>`); | |
| return; | |
| } | |
| if (!t.window) { | |
| parts.push(`<rect x="${X0}" y="${y}" width="${X1 - X0}" height="${LANE_H}" rx="5" fill="${c}" fill-opacity="0.35" class="bm-hatch"/>`); | |
| parts.push(`<text x="${X0 + 8}" y="${y + 14}" class="bm-s">ganzer Lauf (${esc(t.whole)})</text>`); | |
| return; | |
| } | |
| const a = tx(s.start), b = tx(s.end); | |
| parts.push(`<rect x="${X0}" y="${y + LANE_H / 2 - 1}" width="${X1 - X0}" height="2" class="bm-base-lane"/>`); | |
| if (b > a) parts.push(`<rect x="${a}" y="${y}" width="${b - a}" height="${LANE_H}" rx="5" fill="${c}" fill-opacity="0.85" data-handle="${i}:move" class="bm-band" data-tip="Fenster verschieben"/>`); | |
| parts.push(`<rect x="${a - 5}" y="${y - 3}" width="10" height="${LANE_H + 6}" rx="3" class="bm-handle" style="stroke:${c}" data-handle="${i}:start" data-tip="Beginn ziehen"/>`); | |
| parts.push(`<rect x="${b - 5}" y="${y - 3}" width="10" height="${LANE_H + 6}" rx="3" class="bm-handle" style="stroke:${c}" data-handle="${i}:end" data-tip="Ende ziehen"/>`); | |
| 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(`<polyline points="${pts.join(" ")}" class="bm-envelope" pointer-events="none"/>`); | |
| } | |
| parts.push(`<text x="${Math.min(Math.max(a, X0) + 10, X1 - 70)}" y="${y + 13}" class="bm-on-band" pointer-events="none">${num(s.start)}–${num(s.end)} %</text>`); | |
| }); | |
| if (!st.slots.length) parts.push(`<text x="${X0}" y="${TL_LANE + 21}" class="bm-s">Noch kein Werkzeug gewählt.</text>`); | |
| 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('<g opacity="0.25">'); | |
| 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("</g>"); | |
| if (s.tool === "aphasia") parts.push(skippedLayers(s, COLORS[i])); | |
| if (s.tool === "excise" && !wordChips(s.word).length) { | |
| parts.push(`<text x="${BOX.ctx.x + 10}" y="${BOX.ctx.y + BOX.ctx.h - 8}" class="bm-s" style="fill:${COLORS[i]}">„${esc(s.word || "")}“ steht nicht in Prompt A</text>`); | |
| } | |
| } | |
| 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(`<rect x="${b.x + b.w - 206 + j * 16}" y="${b.y + 9}" width="10" height="24" rx="2" fill="${color}" fill-opacity="0.8" class="bm-hl"/>`); | |
| } | |
| const text = k ? `nach Schicht ${12 - k} ausgelesen` : "alle 12 Schichten"; | |
| out.push(`<text x="${b.x + 178}" y="${b.y + 26}" class="bm-s bm-hl" style="fill:${color}">${text}</text>`); | |
| return out.join(""); | |
| } | |
| function defs() { | |
| const heads = COLORS.concat(["#868e96"]).map((c) => | |
| `<marker id="bm-arrow-${c.slice(1)}" viewBox="0 0 10 10" refX="6" refY="5" markerWidth="5" markerHeight="5" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="${c}"/></marker>`).join(""); | |
| return `<defs><marker id="bm-head" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"> | |
| <path d="M 0 0 L 10 5 L 0 10 z" class="bm-headpath"/></marker>${heads} | |
| <pattern id="bm-hatch" width="8" height="8" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"> | |
| <line x1="0" y1="0" x2="0" y2="8" stroke="currentColor" stroke-width="2" stroke-opacity="0.25"/></pattern></defs>`; | |
| } | |
| 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) => `<div class="bm-group"><span class="bm-group-label">${esc(g)}</span> | |
| ${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) => `<span class="bm-dot" style="background:${COLORS[i]}"></span>`).join(""); | |
| return `<button type="button" class="bm-tile ${used.length ? "bm-used" : ""}" data-key="${t.key}" title="${esc(t.what)}"> | |
| <span class="bm-ico">${t.icon}</span><span>${esc(t.label)}</span>${dots}</button>`; | |
| }).join("")}</div>`).join("") + | |
| `<div class="bm-pal-hint">${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."}</div>`; | |
| } | |
| function slotCard(s, i) { | |
| const t = TOOLS[s.tool], c = COLORS[i]; | |
| const places = t.places.length > 1 | |
| ? `<div class="bm-places">${t.places.map((p) => `<button type="button" class="bm-pchip ${p.value === s.place ? "bm-on" : ""}" | |
| data-act="place" data-place="${p.value}">${esc(p.label)}</button>`).join("")}</div> | |
| <div class="bm-hint">Ort: hier wählen oder auf die gestrichelten Stellen der Karte klicken.</div>` : ""; | |
| const word = s.tool === "excise" | |
| ? `<label class="bm-row">Wort <input type="text" data-act="word" value="${esc(s.word || "")}" placeholder="Wort aus Prompt A"></label>` : ""; | |
| const lo = Math.min(t.lo, t.hi), hi = Math.max(t.lo, t.hi); | |
| const knob = `<div class="bm-row bm-knob"><span>${esc(t.knob)}</span> | |
| <input type="range" data-act="value" min="${lo}" max="${hi}" step="${t.step}" value="${s.value}"> | |
| <input type="number" data-act="value" step="any" value="${s.value}"></div>`; | |
| const win = t.window | |
| ? `<div class="bm-row bm-win"><span>Zeitfenster</span><input type="number" data-act="start" step="any" value="${s.start}"> | |
| <span>bis</span><input type="number" data-act="end" step="any" value="${s.end}"><span>%</span></div> | |
| <div class="bm-hint">Oder auf der Zeitleiste unter der Karte ziehen.</div>` | |
| : t.time_knob ? `<div class="bm-hint">Den Zeitpunkt könnt ihr auch auf der Zeitleiste ziehen.</div>` | |
| : t.after_loop ? `<div class="bm-hint">Wirkt nach der Schleife, beim Übersetzen in Pixel.</div>` | |
| : `<div class="bm-hint">Wirkt im ganzen Lauf: ${esc(t.whole)}.</div>`; | |
| return `<div class="bm-card ${i === st.sel ? "bm-sel" : ""}" data-i="${i}" style="--c:${c}"> | |
| <div class="bm-card-head"><span class="bm-badge">${i + 1}</span><span class="bm-ico">${t.icon}</span> | |
| <b>${esc(t.label)}</b><button type="button" class="bm-x" data-act="remove" title="Werkzeug entfernen">✕</button></div> | |
| <p class="bm-what">${esc(t.what)}</p>${places}${word}${knob}${win} | |
| <p class="bm-foot">${esc(t.group)} · aus dem Neural-Bending-Rezept „${esc(t.preset)}“ · bei ${num(t.neutral)} keine Wirkung</p></div>`; | |
| } | |
| 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(`<div class="bm-card bm-empty" data-i="${i}"><span class="bm-badge" style="--c:${COLORS[i]}">${i + 1}</span> | |
| Freier Platz: oben ein Werkzeug wählen.</div>`); | |
| } | |
| 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 = `<div class="bm"> | |
| <div class="bm-stage"><svg class="bm-svg" viewBox="0 0 ${W} ${H}" role="img" aria-label="Karte von Stable Diffusion 1.5"></svg> | |
| <div class="bm-tip" hidden></div></div> | |
| <div class="bm-side"><div class="bm-palette"></div><div class="bm-slots"></div></div></div>`; | |
| 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(); | |