Files
Temult-TWanSigmaGraph/js/TWanSigmaGraphWidget.js
2025-04-17 05:19:55 -04:00

493 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 = `
<strong>Sigma Schedule Editor</strong><br>
Bidirectional editor for sigma schedules.<br>
Use <strong>+ / –</strong> to adjust density.<br>
Expand node to preview sigmas.<br>
<em>Special thanks to huchenlei!</em>
`;
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)<GRAB_THRESHOLD);
if (idx>0 && idx<pts.length-1 && pts.length>MIN_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)<GRAB_THRESHOLD);
if (dragIdx>=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)<GRAB_THRESHOLD);
canvas.style.cursor = dragIdx>=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);
}