// 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);
}