// TWanSigmaGraphWidget.js – v0.1.0 // ------------------------------- // Companion widget for the TWanSigmaGraph node. Draws a bi‑ // directional sigma schedule, preview text, density controls, // save/load slots, undo, and an ℹ️ info popup. import { app } from "/../scripts/app.js"; import { $el } from "/../scripts/ui.js"; /*—— Constants ——*/ const NODE_CLASS = "TWanSigmaGraph"; const GRAPH_DATA_NAME = "graph_data"; const STEPS_NAME = "steps"; const CANVAS_TYPE = "SIGMA_GRAPH_CANVAS"; const MIN_POINTS = 3; const NUM_SLOTS = 8; const LS_KEY = "sigma_graph_saveSlots"; const UNDO_LIMIT = 1; const MIN_NODE_WIDTH = 180; const GRAB_THRESHOLD = 0.08; const POINT_RADIUS = 6; const POINT_COLOR = "#1E88E5"; /*—— Helper Functions ——*/ /** Linear‑interpolate pts→sigma array of length=steps */ function calcSigmas(pts, steps) { steps = Math.max(1, steps | 0); const p = (pts || []).slice().sort((a, b) => a.x - b.x); if (p.length < 2) return Array(steps).fill(1.0); if (!p.some((pt) => Math.abs(pt.x) < 1e-6)) p.unshift({ x: 0, y: p[0].y }); if (!p.some((pt) => Math.abs(pt.x - 1) < 1e-6)) p.push({ x: 1, y: p[p.length - 1].y }); const out = []; let idx = 0; for (let i = 0; i < steps; i++) { const t = steps === 1 ? 0 : i / (steps - 1); while (idx < p.length - 2 && p[idx + 1].x < t) idx++; const [p1, p2] = [p[idx], p[idx + 1]]; const dx = p2.x - p1.x; let y = dx < 1e-6 ? (Math.abs(t - p2.x) < Math.abs(t - p1.x) ? p2.y : p1.y) : p1.y + ((t - p1.x) / dx) * (p2.y - p1.y); out.push(Math.max(0.001, Math.round(y * 100) / 100)); } return out; } /** Parse either JSON‑[{x,y}] or comma‑list "y,y,y" */ function strToPts(str) { try { const arr = JSON.parse(str); if ( Array.isArray(arr) && arr.every((o) => typeof o.x === "number" && typeof o.y === "number") ) return arr; } catch { /* ignore */ } const nums = str .split(/[^0-9.+-]+/) .map(parseFloat) .filter((n) => !isNaN(n)); if (nums.length < 2) { const y = nums[0] || 1; return [{ x: 0, y }, { x: 1, y: nums[0] != null ? y : 0 }]; } return nums.map((y, i) => ({ x: i / (nums.length - 1), y: Math.round(y * 100) / 100 })); } /** Convert [{x,y}] → "y, y, y" */ function ptsToStr(pts, prec = 2) { return pts.map((p) => p.y.toFixed(prec)).join(", "); } /*—— Main Widget Setup ——*/ function setup(node) { // prevent double init if (node._sigmaSetupDone) return; node._sigmaSetupDone = true; // 1) locate the hidden textarea const gw = node.widgets.find((w) => w.name === GRAPH_DATA_NAME); if (!gw || !gw.element) return; const ta = gw.element; // 2) style it ta.style.minHeight = "10px"; ta.style.boxSizing = "border-box"; ta.style.resize = "vertical"; // 3) per‑node storage key const storageKey = `TWanSigmaGraph_last_${node.id}`; // 4) try load from cache let initialPts = null; const cached = localStorage.getItem(storageKey); if (cached) { try { initialPts = strToPts(cached); ta.value = ptsToStr(initialPts); gw.value = JSON.stringify(initialPts); } catch { initialPts = null; } } // 5) if no cache → four‐point default if (!initialPts) { const defaultText = "1.00, 0.67, 0.33, 0.00"; ta.value = defaultText; initialPts = strToPts(defaultText); gw.value = JSON.stringify(initialPts); } // 6) Build UI: container, canvas, preview const wrap = $el("div", { style: { width: "100%", position: "relative", display: "flex", flexDirection: "column", flexGrow: "1", minHeight: "180px", paddingBottom: "20px", minWidth: `${MIN_NODE_WIDTH}px` } }); const canvas = $el("canvas", { style: { width: "100%", flexGrow: "1", minHeight: "125px", background: "#282828", border: "1px solid #555", borderRadius: "4px", cursor: "crosshair" } }); wrap.appendChild(canvas); const preview = $el("textarea", { placeholder: "Sigmas preview…", readOnly: true, style: { width: "100%", height: '55px', fontFamily: "monospace", background: "#181818", color: "#ccc", border: "1px solid #555", borderRadius: "4px", boxSizing: "border-box", padding: "4px", resize: "none", marginTop: "4px" } }); wrap.appendChild(preview); node.addDOMWidget(CANVAS_TYPE, "custom", wrap, { getHeight: () => Math.max(180, wrap.scrollHeight) }); // enforce min width on resize if (node.size[0] < MIN_NODE_WIDTH) { node.setSize([MIN_NODE_WIDTH, node.size[1]]); } const prevOnResize = node.onResize; node.onResize = function() { prevOnResize?.apply(this, arguments); if (this.size[0] < MIN_NODE_WIDTH) { this.setSize([MIN_NODE_WIDTH, this.size[1]]); } }; /*—— Density Controls (+ / –) ——*/ const addBtn = $el("button", { textContent: "+", title: "Double density", style: { position: "absolute", top: "4px", right: "40px", width: "24px", height: "24px", borderRadius: "3px", border: "1px solid #555", background: "#64B5F6", color: "#fff", cursor: "pointer" } }); const delBtn = $el("button", { textContent: "−", title: "Halve density", style: { position: "absolute", top: "4px", right: "10px", width: "24px", height: "24px", borderRadius: "3px", border: "1px solid #555", background: "#EF9A9A", color: "#fff", cursor: "pointer" } }); wrap.appendChild(addBtn); wrap.appendChild(delBtn); /*—— Slots & Presets ——*/ const slotBar = $el("div", { style: { display: "flex", gap: "4px", marginTop: "4px", alignItems: "center" } }); wrap.appendChild(slotBar); const slotsData = JSON.parse(localStorage.getItem(LS_KEY) || "[]"); let recordMode = false; const recBtn = $el("button", { textContent: "💾", title: "Toggle save mode", style: { minWidth: "28px", cursor: "pointer" } }); slotBar.appendChild(recBtn); const slotBtns = []; for (let i = 0; i < NUM_SLOTS; i++) { const b = $el("button", { textContent: `${i + 1}`, disabled: !slotsData[i] && !recordMode, style: { flex: "1", minWidth: "20px", opacity: slotsData[i]||recordMode ? "1" : "0.3", cursor: slotsData[i]||recordMode ? "pointer" : "not-allowed" } }); slotBar.appendChild(b); slotBtns.push(b); b.onclick = () => { if (recordMode) { slotsData[i] = gw.value; localStorage.setItem(LS_KEY, JSON.stringify(slotsData)); recBtn.click(); } else if (slotsData[i]) { let pts; try { pts = JSON.parse(slotsData[i]); } catch { pts = strToPts(slotsData[i]); } applyPoints(pts); } }; } recBtn.onclick = () => { recordMode = !recordMode; recBtn.style.background = recordMode ? "#4caf50" : ""; slotBtns.forEach((b, i) => { const ok = slotsData[i] || recordMode; b.disabled = !ok; b.style.opacity = ok ? "1" : "0.3"; b.style.cursor = ok ? "pointer" : "not-allowed"; }); }; /*—— Info Popup ——*/ const infoBtn = $el("button", { textContent: "ℹ️", title: "Instructions", style: { flex: "0 0 auto", minWidth: "10px", opacity: "0.6", cursor: "pointer", background: "transparent", border: "none", color: "#ccc" } }); const advBtn = $el("button", { textContent: "§", title: "Advanced (WIP)", disabled: true, style: { flex: "0 0 auto", minWidth: "10px", opacity: "0.4", cursor: "not-allowed", background: "transparent", border: "none", color: "#666" } }); slotBar.appendChild(infoBtn); slotBar.appendChild(advBtn); const overlay = $el("div", { style: { position: "absolute", top: "15%", left: "50%", transform: "translate(-50%, -50%)", width: "240px", background: "#222", color: "#ccc", border: "1px solid #555", borderRadius: "6px", padding: "8px 12px", fontSize: "11px", lineHeight: "1.4", textAlign: "center", display: "none", zIndex: "9999", pointerEvents: "auto" } }); overlay.innerHTML = ` Sigma Schedule Editor
Bidirectional editor for sigma schedules.
Use + / – to adjust density.
Expand node to preview sigmas.
Special thanks to huchenlei! `; wrap.appendChild(overlay); infoBtn.addEventListener("click", (e) => { e.stopPropagation(); overlay.style.display = overlay.style.display === "none" ? "block" : "none"; }); wrap.addEventListener("click", () => { if (overlay.style.display === "block") { overlay.style.display = "none"; } }); /*—— Undo & Draw Logic ——*/ let undoStack = []; function pushUndo(state) { undoStack.unshift(state); if (undoStack.length > UNDO_LIMIT) undoStack.pop(); } function applyPoints(pts) { pushUndo(gw.value); const clean = pts.map((p) => ({ x: p.x, y: Math.round(p.y * 100) / 100 })); gw.value = JSON.stringify(clean); ta.value = ptsToStr(clean); // persist to cache localStorage.setItem(storageKey, gw.value); draw(clean); } const ctx = canvas.getContext("2d"); function draw(overridePts) { const pts = overridePts || strToPts(gw.value); const dpr = window.devicePixelRatio || 1; const w = canvas.clientWidth, h = canvas.clientHeight; canvas.width = w * dpr; canvas.height = h * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, w, h); // grid ctx.strokeStyle = "#444"; ctx.lineWidth = 0.5; ctx.beginPath(); for (let i = 1; i < 10; i++) { const x = (i * w) / 10, y = (i * h) / 10; ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.moveTo(0, y); ctx.lineTo(w, y); } ctx.stroke(); // curve & handles if (pts.length >= 2) { ctx.strokeStyle = "#fff"; ctx.lineWidth = 1.5; ctx.beginPath(); pts.forEach((p, i) => { const px = p.x * w, py = (1 - p.y) * h; i ? ctx.lineTo(px, py) : ctx.moveTo(px, py); }); ctx.stroke(); pts.forEach((p) => { const px = p.x * w, py = (1 - p.y) * h; ctx.fillStyle = POINT_COLOR; ctx.beginPath(); ctx.arc(px, py, POINT_RADIUS, 0, 2 * Math.PI); ctx.fill(); }); } // update text preview const stepsW = node.widgets.find((w) => w.name === STEPS_NAME).value | 0; preview.value = calcSigmas(pts, stepsW) .map((v) => v.toFixed(2)) .join(", "); } /*—— Event Bindings ——*/ let debounce; ta.addEventListener("input", () => { clearTimeout(debounce); debounce = setTimeout(() => { if (ta.value.trim()) draw(strToPts(ta.value)); }, 200); }); ta.addEventListener("blur", () => { if (ta.value.trim()) applyPoints(strToPts(ta.value)); }); ta.addEventListener("keydown", (e) => { if ((e.ctrlKey||e.metaKey) && e.key==="z" && undoStack.length) { e.preventDefault(); const prev = undoStack.shift(); gw.value = prev; const pts = strToPts(prev); ta.value = ptsToStr(pts); draw(pts); } }); addBtn.onclick = () => { const pts = strToPts(gw.value); const n = pts.length + 1; applyPoints(calcSigmas(pts, n).map((y,i)=>({ x:i/(n-1), y }))); }; delBtn.onclick = () => { const pts = strToPts(gw.value); if (pts.length > MIN_POINTS) { const n = pts.length - 1; applyPoints(calcSigmas(pts, n).map((y,i)=>({ x:i/(n-1), y }))); } }; // pointer interactions: drag, dblclick add, RMB delete let dragIdx = -1; canvas.onpointerdown = (e) => { const rect = canvas.getBoundingClientRect(); const x = (e.clientX-rect.left)/rect.width; const y = 1 - (e.clientY-rect.top)/rect.height; const pts = strToPts(gw.value); if (e.button===2) { const idx = pts.findIndex(p=>Math.hypot(p.x-x,p.y-y)0 && idxMIN_POINTS) { pushUndo(gw.value); pts.splice(idx,1); applyPoints(pts); } e.preventDefault(); return; } if (e.detail===2) { pushUndo(gw.value); pts.push({ x, y }); pts.sort((a,b)=>a.x-b.x); applyPoints(pts); return; } dragIdx = pts.findIndex(p=>Math.hypot(p.x-x,p.y-y)=0) e.target.setPointerCapture(e.pointerId); }; canvas.onpointermove = (e) => { const rect = canvas.getBoundingClientRect(); const x = (e.clientX-rect.left)/rect.width; const y = 1 - (e.clientY-rect.top)/rect.height; const pts = strToPts(gw.value); if (dragIdx>=0) { const ny = Math.min(1, Math.max(0, y)); pts[dragIdx] = { x:pts[dragIdx].x, y:ny }; applyPoints(pts); } const over = pts.some(p=>Math.hypot(p.x-x,p.y-y)=0||over ? "pointer":"crosshair"; }; canvas.onpointerup = (e) => { if (dragIdx>=0) { e.target.releasePointerCapture(e.pointerId); dragIdx = -1; } }; canvas.oncontextmenu = (e) => e.preventDefault(); // recompute on step‐count change const sw = node.widgets.find((w) => w.name === STEPS_NAME); if (sw) sw.callback = () => applyPoints(strToPts(ta.value)); // initial draw applyPoints(strToPts(ta.value)); new ResizeObserver(() => draw()).observe(canvas); } /*—— Registration ——*/ app.registerExtension({ name: "TWanSigmaGraph.widget", beforeRegisterNodeDef(nt, nd) { if (nd.name === NODE_CLASS) { const orig = nt.prototype.onConfigure; nt.prototype.onConfigure = function(info) { orig?.apply(this, arguments); setup(this); }; } }, }); if (app.on) { app.on("nodeAdded", (n) => { if (n.type === NODE_CLASS) setup(n); }); } else { setInterval(() => { (app.graph?._nodes||[]).forEach((n) => { if (n.type === NODE_CLASS) setup(n); }); }, 100); }