// SigmaGraphWidget.js
// Refactored to use the addDOMWidget API
console.log(window.LiteGraph);
console.log(window.app);
console.log(window.ui);
console.log("[SigmaGraphWidget] top-level: file loaded successfully");
import { app } from "/../scripts/app.js";
import { $el } from "/../scripts/ui.js";
//import { LiteGraph } from "/scripts/litegraph.js";
/** Node and widget constants */
const nodeClassName = "SigmaGraphNode";
const graphDataWidgetName = "graph_data"; // Name of the hidden STRING widget
const stepsWidgetName = "steps";
const customWidgetName = "sigma_graph_ui"; // Name for our new custom DOM widget
/** How many preset slots we allow for saving/loading graph shapes. */
const numPresetSlots = 9;
/** Minimum number of points allowed on the graph */
const minGraphPoints = 3;
// --- Helper Functions (now mostly standalone) ---
/**
* Calculates sigma values for the text preview based on points and steps.
*/
function calculateSigmasJS(points, steps) {
const sigmaValues = [];
steps = Math.max(1, Math.floor(steps));
if (!points || points.length < 1) {
for (let i = 0; i < steps; i++) { sigmaValues.push(1.0); }
return sigmaValues;
}
if (steps === 1) {
const startPoint = points.find((p) => Math.abs(p.x - 0.0) < 1e-6);
return [startPoint ? Math.max(0.001, startPoint.y) : (points[0]?.y ?? 1.0)];
}
let localPoints = JSON.parse(JSON.stringify(points)); // Deep copy
if (!Array.isArray(localPoints) || localPoints.length < 2) {
const yVal = localPoints[0]?.y ?? 1.0;
for (let i = 0; i < steps; i++) {
sigmaValues.push(Math.max(0.001, yVal - (yVal * i) / (steps - 1)));
}
return sigmaValues;
}
localPoints.sort((a, b) => a.x - b.x);
if (localPoints[0].x > 1e-6) { // Tolerance for boundary check
localPoints.unshift({ x: 0.0, y: localPoints[0].y });
}
if (localPoints[localPoints.length - 1].x < 1.0 - 1e-6) { // Tolerance
localPoints.push({ x: 1.0, y: localPoints[localPoints.length - 1].y });
}
let currentPointIndex = 0;
for (let i = 0; i < steps; i++) {
const stepProgress = steps === 1 ? 0 : i / (steps - 1);
while (
currentPointIndex < localPoints.length - 2 &&
localPoints[currentPointIndex + 1].x < stepProgress - 1e-6 // Tolerance
) {
currentPointIndex++;
}
const p1 = localPoints[currentPointIndex];
const p2 = localPoints[currentPointIndex + 1] || p1;
let sigma;
const x_diff = p2.x - p1.x;
if (x_diff <= 1e-6) {
sigma = p1.y;
} else {
const clampedProgress = Math.max(p1.x, Math.min(p2.x, stepProgress));
const interpFactor = (clampedProgress - p1.x) / x_diff;
sigma = p1.y + interpFactor * (p2.y - p1.y);
}
sigmaValues.push(Math.max(0.001, sigma));
}
return sigmaValues;
}
/**
* Generates `numTargetPoints` points by interpolating along `existingPoints`.
*/
function interpolatePoints(existingPoints, numTargetPoints) {
if (numTargetPoints < 2 || !existingPoints || existingPoints.length < 2) {
return [ { x: 0, y: 1 }, { x: 1, y: 0 } ];
}
const newPoints = [];
const sortedExisting = [...existingPoints].sort((a, b) => a.x - b.x);
if (sortedExisting[0].x > 1e-6) {
sortedExisting.unshift({ x: 0.0, y: sortedExisting[0].y });
}
if (sortedExisting[sortedExisting.length - 1].x < 1.0 - 1e-6) {
sortedExisting.push({ x: 1.0, y: sortedExisting[sortedExisting.length - 1].y });
}
let currentSegmentIndex = 0;
for (let i = 0; i < numTargetPoints; i++) {
const targetX = numTargetPoints === 1 ? 0 : i / (numTargetPoints - 1);
while (
currentSegmentIndex < sortedExisting.length - 2 &&
sortedExisting[currentSegmentIndex + 1].x < targetX - 1e-6 // Tolerance
) {
currentSegmentIndex++;
}
const p1 = sortedExisting[currentSegmentIndex];
const p2 = sortedExisting[currentSegmentIndex + 1] || p1;
let interpolatedY;
const segmentXDiff = p2.x - p1.x;
if (segmentXDiff <= 1e-6) {
interpolatedY = p1.y;
} else {
const clampedTargetX = Math.max(p1.x, Math.min(p2.x, targetX));
const interpFactor = (clampedTargetX - p1.x) / segmentXDiff;
interpolatedY = p1.y + interpFactor * (p2.y - p1.y);
}
newPoints.push({ x: targetX, y: Math.max(0.001, interpolatedY) });
}
const uniqueNewPoints = [];
const seenX = new Set();
for (const p of newPoints) {
let isDuplicate = false;
for(const sx of seenX) { if (Math.abs(p.x - sx) < 1e-6) { isDuplicate = true; break; } }
if (!isDuplicate) { uniqueNewPoints.push(p); seenX.add(p.x); }
}
if (uniqueNewPoints.length > 0) {
if (!uniqueNewPoints.some(p => Math.abs(p.x - 0.0) < 1e-6))
uniqueNewPoints.unshift({x: 0, y: newPoints[0].y});
if (!uniqueNewPoints.some(p => Math.abs(p.x - 1.0) < 1e-6))
uniqueNewPoints.push({x: 1, y: newPoints[newPoints.length-1].y});
} else {
return [ { x: 0, y: 1 }, { x: 1, y: 0 } ];
}
return uniqueNewPoints.sort((a,b) => a.x - b.x);
}
/**
* Redraws the curve on the canvas.
*/
function drawCurve(widgetState) {
const canvasEl = widgetState.canvasEl;
if (!canvasEl || !widgetState._points) return;
const rect = canvasEl.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return;
const dpr = window.devicePixelRatio || 1;
const displayWidth = Math.round(rect.width);
const displayHeight = Math.round(rect.height);
if (canvasEl.width !== displayWidth * dpr || canvasEl.height !== displayHeight * dpr) {
canvasEl.width = displayWidth * dpr;
canvasEl.height = displayHeight * dpr;
canvasEl.style.width = `${displayWidth}px`;
canvasEl.style.height = `${displayHeight}px`;
}
const ctx = canvasEl.getContext("2d");
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, displayWidth, displayHeight);
ctx.strokeStyle = "#444";
ctx.lineWidth = 0.5;
ctx.beginPath();
for (let i = 1; i < 10; i++) {
const vx = (i * displayWidth) / 10;
const vy = (i * displayHeight) / 10;
ctx.moveTo(vx, 0);
ctx.lineTo(vx, displayHeight);
ctx.moveTo(0, vy);
ctx.lineTo(displayWidth, vy);
}
ctx.stroke();
const sortedPoints = [...widgetState._points].sort((a, b) => a.x - b.x);
if (sortedPoints.length < 2) return;
ctx.strokeStyle = "white";
ctx.lineWidth = 1.5;
ctx.beginPath();
sortedPoints.forEach((pt, idx) => {
const px = pt.x * displayWidth;
const py = (1 - pt.y) * displayHeight;
if (idx === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
});
ctx.stroke();
widgetState._points.forEach((pt, idx) => {
const ratio = widgetState._points.length > 1 ? idx / (widgetState._points.length - 1) : 0;
const startColor = { r: 61, g: 3, b: 0 };
const endColor = { r: 255, g: 0, b: 0 };
const r = Math.round(startColor.r + ratio * (endColor.r - startColor.r));
const g = Math.round(startColor.g + ratio * (endColor.g - startColor.g));
const b = Math.round(startColor.b + ratio * (endColor.b - startColor.b));
ctx.fillStyle = `rgb(${r}, ${g}, ${b})`;
const px = pt.x * displayWidth;
const py = (1 - pt.y) * displayHeight;
ctx.beginPath();
ctx.arc(px, py, 4, 0, Math.PI * 2);
ctx.fill();
});
}
/**
* Updates the sigma text area display.
*/
function updateSigmaDisplay(widgetState) {
if (!widgetState.sigmaDisplayEl || !widgetState.stepsWidget || !widgetState._points) return;
const steps = widgetState.stepsWidget.value;
const sigmas = calculateSigmasJS(widgetState._points, steps);
widgetState.sigmaDisplayEl.value = sigmas.map((s) => s.toFixed(3)).join(", ");
}
/**
* Updates the enabled state of the remove (-) button.
*/
function updateRemoveButtonState(widgetState) {
if (!widgetState.removeButtonEl || !widgetState._points) return;
const canRemove = widgetState._points.length > minGraphPoints;
widgetState.removeButtonEl.disabled = !canRemove;
widgetState.removeButtonEl.style.opacity = canRemove ? "1" : "0.5";
widgetState.removeButtonEl.style.cursor = canRemove ? "pointer" : "not-allowed";
}
// --- Main Extension Registration ---
app.registerExtension({
name: "sigma_graph.widget",
async beforeRegisterNodeDef(nodeType, nodeData, appInstance) {
if (nodeData.name !== nodeClassName) return;
// Patch onConfigure to set up the custom widget
const originalOnConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function (info) {
originalOnConfigure?.apply(this, arguments);
if (this.widgets?.find(w => w.name === customWidgetName)) { return; }
console.log(`[SigmaGraphWidget] ${this.id}: Patched onConfigure running - setting up widget.`);
const dataWidget = this.widgets.find((w) => w.name === graphDataWidgetName);
const stepsWidget = this.widgets.find((w) => w.name === stepsWidgetName);
if (!dataWidget) { console.error(`[SigmaGraphWidget] ${this.id}: Could not find data widget '${graphDataWidgetName}'!`); return; }
if (!stepsWidget) { console.error(`[SigmaGraphWidget] ${this.id}: Could not find steps widget '${stepsWidgetName}'!`); }
if (dataWidget.inputEl) { dataWidget.inputEl.style.display = "none"; }
try {
const widgetIndex = this.widgets.findIndex(w => w === dataWidget);
if (widgetIndex !== -1 && this.inputs && this.inputs[widgetIndex]) {
const slotElement = this.graphcanvas?.canvas?.querySelector(`.node_${this.id}_slot_${widgetIndex}`);
if (slotElement?.previousElementSibling?.tagName === 'LABEL') {
slotElement.previousElementSibling.style.display = 'none';
}
}
} catch(e) { console.warn("[SigmaGraphWidget] Error trying to hide label:", e); }
// Build the root element for the widget UI
const rootElement = $el("div", {
className: "sigma-graph-widget-container",
style: { width: "100%", height: "100%", padding: "5px", boxSizing: "border-box", display: "flex", flexDirection: "column", gap: "10px" },
});
// Initialize widget state and store key elements in widgetState object
const widgetState = {
_points: [],
_saveSlots: new Array(numPresetSlots).fill(null),
_saveSlotButtons: [],
_recordMode: false,
_draggingIndex: -1,
_rightClickIndex: -1,
rootElement: rootElement,
node: this,
dataWidget: dataWidget,
stepsWidget: stepsWidget,
sigmaDisplayEl: null,
canvasContainerEl: null,
canvasEl: null,
presetContainerEl: null,
addButtonEl: null,
removeButtonEl: null,
refreshButton: null, // We'll assign the refresh button here
originalStepsCallback: stepsWidget ? stepsWidget.callback : null,
saveTimeout: null,
boundMouseUpHandler: null,
};
const saveStateToDataWidget = () => {
clearTimeout(widgetState.saveTimeout);
widgetState.saveTimeout = setTimeout(() => {
widgetState._points.sort((a, b) => a.x - b.x);
const serialized = JSON.stringify(widgetState._points);
if (widgetState.dataWidget.value !== serialized) {
widgetState.dataWidget.value = serialized;
}
}, 100);
};
// --- Build the UI ---
// Sigma display and button grid container
const sigmaDisplayContainer = $el("div", { style: { display: "flex", flexDirection: "row", gap: "5px", alignItems: "stretch" } });
const sigmaDisplay = $el("textarea", {
placeholder: "Sigma values...",
style: {
flexGrow: "1",
minHeight: "5px",
fontSize: "0.9em",
background: "#181818",
color: "#ccc",
border: "1px solid #555",
borderRadius: "3px",
boxSizing: "border-box",
resize: "auto",
fontFamily: "monospace",
padding: "2px 4px",
}
});
widgetState.sigmaDisplayEl = sigmaDisplay;
sigmaDisplayContainer.appendChild(sigmaDisplay);
const buttonGridContainer = $el("div", { style: { display: "flex", flexDirection: "row", gap: "1px", height: "45px" } });
sigmaDisplayContainer.appendChild(buttonGridContainer);
rootElement.appendChild(sigmaDisplayContainer);
// Left column for refresh & advanced buttons
const leftButtonColumn = $el("div", { style: { display: "flex", flexDirection: "column", alignItems: "stretch", width: "20px", borderRadius: "3px", overflow: "hidden", border: "1px solid #555", background: "#444" } });
buttonGridContainer.appendChild(leftButtonColumn);
// Create refreshButton and attach it to widgetState.refreshButton
widgetState.refreshButton = $el("button", {
textContent: "🔄",
style: {
flex: "1 1 auto",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "13px",
border: "none",
borderBottom: "1px solid #555",
background: "transparent",
color: "#ccc",
cursor: "pointer",
lineHeight: "normal",
padding: "0"
},
title: "Refresh text display from current graph/steps"
});
leftButtonColumn.appendChild(widgetState.refreshButton);
// Create advancedButton (for demonstration purposes)
const advancedButton = $el("button", {
textContent: "⚙️",
style: {
flex: "1 1 auto",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "13px",
border: "none",
background: "transparent",
color: "#666",
cursor: "not-allowed",
lineHeight: "normal",
padding: "0"
},
title: "Advanced Mode (Not Implemented)"
});
leftButtonColumn.appendChild(advancedButton);
// Right column for info & apply buttons
const rightButtonColumn = $el("div", { style: { display: "flex", flexDirection: "column", alignItems: "stretch", width: "20px", borderRadius: "3px", overflow: "hidden", border: "1px solid #555", background: "#444" } });
buttonGridContainer.appendChild(rightButtonColumn);
const infoButton = $el("button", {
textContent: "ℹ",
style: {
flex: "1 1 auto",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "13px",
border: "none",
borderBottom: "1px solid #555",
background: "transparent",
color: "#ccc",
cursor: "pointer",
lineHeight: "normal",
padding: "0"
},
title: "Show instructions about this node"
});
rightButtonColumn.appendChild(infoButton);
const applyButton = $el("button", {
textContent: "▼",
style: {
flex: "1 1 auto",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "13px",
border: "none",
background: "transparent",
color: "#ccc",
cursor: "pointer",
lineHeight: "normal",
padding: "0"
},
title: "Apply sigma values from the text area to the graph points"
});
rightButtonColumn.appendChild(applyButton);
// Info overlay element
const infoOverlay = $el("div", {
style: {
position: "absolute",
top: "55%",
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: "none"
}
});
infoOverlay.innerHTML = `
Sigma Schedule Editor
Bidirectional editor for sigma schedules.
🔄: Refresh text from graph/steps.
▼: Apply text to graph points.
Drag, double-click, right-click to edit points.
Use +/- on graph to adjust density.
Special thanks to huchenlei for js help!!
`;
rootElement.appendChild(infoOverlay);
// Canvas container and canvas element
const canvasContainer = $el("div", {
style: {
position: "relative",
width: "100%",
flexGrow: "1",
minHeight: "50px",
boxSizing: "border-box",
cursor: "crosshair",
overflow: "hidden"
}
});
const canvas = $el("canvas", {
style: {
display: "block",
width: "100%",
height: "100%",
background: "#282828",
border: "2px solid #555",
borderRadius: "3px",
boxSizing: "border-box"
}
});
widgetState.canvasContainerEl = canvasContainer;
widgetState.canvasEl = canvas;
canvasContainer.appendChild(canvas);
rootElement.appendChild(canvasContainer);
// Add and remove buttons for graph points
const addButton = $el("button", {
textContent: "+",
style: {
position: "absolute",
top: "4px",
right: "30px",
width: "22px",
height: "22px",
fontSize: "16px",
lineHeight: "20px",
textAlign: "center",
padding: "0",
background: "#4CAF50",
color: "white",
border: "1px solid #388E3C",
borderRadius: "3px",
cursor: "pointer",
zIndex: "10"
},
title: "Add points (increase density)"
});
canvasContainer.appendChild(addButton);
widgetState.addButtonEl = addButton;
const removeBtn = $el("button", {
textContent: "-",
style: {
position: "absolute",
top: "4px",
right: "4px",
width: "22px",
height: "22px",
fontSize: "18px",
lineHeight: "18px",
textAlign: "center",
padding: "0",
background: "#f44336",
color: "white",
border: "1px solid #d32f2f",
borderRadius: "3px",
cursor: "pointer",
zIndex: "10"
},
title: "Remove points (decrease density)"
});
canvasContainer.appendChild(removeBtn);
widgetState.removeButtonEl = removeBtn;
// Preset container and save mode button
const presetContainer = $el("div", {
style: { display: "inline-flex", flexDirection: "row", alignItems: "center", gap: "5px", marginTop: "0px" }
});
widgetState.presetContainerEl = presetContainer;
rootElement.appendChild(presetContainer);
const saveModeButton = $el("button", {
textContent: "💾",
style: {
width: "30px",
height: "30px",
borderRadius: "3px",
border: "1px solid #555",
background: "#96322f",
color: "#fff",
fontSize: "15px",
cursor: "pointer",
padding: "0",
lineHeight: "30px",
textAlign: "center"
},
title: "Toggle Save Mode (then click a slot to save). Otherwise, click to load."
});
presetContainer.appendChild(saveModeButton);
// Load existing preset slots from localStorage
const savedSlotsJSON = localStorage.getItem("sigma_graph_saveSlots");
if (savedSlotsJSON) {
try {
const savedSlots = JSON.parse(savedSlotsJSON);
if (Array.isArray(savedSlots) && savedSlots.length === numPresetSlots) {
widgetState._saveSlots = savedSlots;
}
} catch (err) {
console.error("Error parsing saved slots:", err);
}
}
const updateSaveSlotVisibility = () => {
widgetState._saveSlotButtons.forEach((btn, idx) => {
if (widgetState._recordMode) {
btn.style.visibility = "visible";
btn.style.backgroundColor = "#4caf50";
} else {
if (widgetState._saveSlots[idx]) {
btn.style.visibility = "visible";
btn.style.backgroundColor = "#273648";
} else {
btn.style.visibility = "hidden";
}
}
});
saveModeButton.style.background = widgetState._recordMode ? "#4caf50" : "#333";
};
for (let i = 0; i < numPresetSlots; i++) {
const slotContainer = $el("div", { style: { width: "30px", height: "30px", display: "inline-block" } });
const slotButton = $el("button", {
textContent: `${i + 1}`,
style: { width: "100%", height: "100%", boxSizing: "border-box", padding: "0", borderRadius: "3px", border: "1px solid #555", background: "#333", color: "#fff", fontSize: "15px", cursor: "pointer" },
title: `Slot #${i + 1}`
});
slotButton.addEventListener("click", () => {
if (widgetState._recordMode) {
widgetState._saveSlots[i] = JSON.stringify(widgetState._points);
localStorage.setItem("sigma_graph_saveSlots", JSON.stringify(widgetState._saveSlots));
widgetState._recordMode = false;
updateSaveSlotVisibility();
} else {
if (widgetState._saveSlots[i]) {
try {
const loadedPoints = JSON.parse(widgetState._saveSlots[i]);
customWidget.value = loadedPoints;
} catch (e) {
console.error(`Error parsing slot ${i + 1}:`, e);
}
}
}
});
widgetState._saveSlotButtons.push(slotButton);
slotContainer.appendChild(slotButton);
presetContainer.appendChild(slotContainer);
}
updateSaveSlotVisibility();
saveModeButton.addEventListener("click", () => {
widgetState._recordMode = !widgetState._recordMode;
updateSaveSlotVisibility();
});
// --- Define Options for addDOMWidget ---
const widgetOptions = {
setValue: (value) => {
let parsedPoints = [];
try {
if (typeof value === 'string') {
parsedPoints = JSON.parse(value || '[]');
} else if (Array.isArray(value)) {
parsedPoints = value;
} else {
throw new Error("Invalid value type");
}
if (!Array.isArray(parsedPoints))
throw new Error("Parsed value is not an array");
} catch (e) {
console.error("[SigmaGraphWidget] setValue error:", e);
parsedPoints = [ { x: 0, y: 1 }, { x: 1, y: 0 } ];
}
if (parsedPoints.length < 1)
parsedPoints = [ { x: 0, y: 1 }, { x: 1, y: 0 } ];
if (!parsedPoints.some((p) => Math.abs(p.x - 0.0) < 1e-6))
parsedPoints.push({ x: 0, y: 1 });
if (!parsedPoints.some((p) => Math.abs(p.x - 1.0) < 1e-6))
parsedPoints.push({ x: 1, y: 0 });
parsedPoints.sort((a, b) => a.x - b.x);
const uniquePoints = [];
const seenX = new Set();
for(const p of parsedPoints) {
let isDuplicate = false;
for(const sx of seenX) {
if (Math.abs(p.x - sx) < 1e-6) { isDuplicate = true; break; }
}
if (!isDuplicate) { uniquePoints.push(p); seenX.add(p.x); }
}
parsedPoints = uniquePoints;
while (parsedPoints.length < minGraphPoints) {
const mid = Math.floor(parsedPoints.length / 2);
const p1 = parsedPoints[mid-1];
const p2 = parsedPoints[mid];
parsedPoints.splice(mid, 0, { x: (p1.x+p2.x)/2, y: (p1.y+p2.y)/2 });
}
widgetState._points = parsedPoints;
drawCurve(widgetState);
updateSigmaDisplay(widgetState);
updateRemoveButtonState(widgetState);
saveStateToDataWidget();
},
getValue: () => {
widgetState._points.sort((a, b) => a.x - b.x);
return JSON.stringify(widgetState._points);
},
afterResize: () => {
requestAnimationFrame(() => drawCurve(widgetState));
},
};
// --- Add the Custom DOM Widget ---
const customWidget = this.addDOMWidget(customWidgetName, "custom", rootElement, widgetOptions);
if (!customWidget) {
console.error(`[SigmaGraphWidget] ${this.id}: Failed to add DOM widget!`);
return;
}
// --- Attach Event Listeners ---
widgetState.refreshButton.addEventListener("click", () => { updateSigmaDisplay(widgetState); });
advancedButton.addEventListener("click", () => { /* No action */ });
infoButton.addEventListener("click", () => { infoOverlay.style.display = (infoOverlay.style.display === "none") ? "block" : "none"; });
applyButton.addEventListener("click", () => {
const textValue = widgetState.sigmaDisplayEl.value.trim();
if (!textValue) return;
const parts = textValue.split(",").map(s => s.trim()).filter(s => s !== "");
const parsedSigmas = parts.map(Number);
if (parsedSigmas.some(isNaN) || parsedSigmas.length === 0) return;
let pointsChanged = false;
widgetState._points.sort((a, b) => a.x - b.x);
widgetState._points.forEach((pt) => {
let targetIndex;
if (parsedSigmas.length === 1) targetIndex = 0;
else {
targetIndex = Math.round(pt.x * (parsedSigmas.length - 1));
targetIndex = Math.max(0, Math.min(parsedSigmas.length - 1, targetIndex));
}
const newY = Math.max(0.001, parsedSigmas[targetIndex]);
if (pt.y !== newY) { pt.y = newY; pointsChanged = true; }
});
if (pointsChanged) {
drawCurve(widgetState);
saveStateToDataWidget();
widgetState.node.setDirtyCanvas(true, true);
}
});
addButton.addEventListener("click", () => {
const numCurrent = widgetState._points.length;
const numToAdd = Math.max(1, Math.floor(numCurrent * 0.5));
widgetState._points = interpolatePoints(widgetState._points, numCurrent + numToAdd);
drawCurve(widgetState);
updateSigmaDisplay(widgetState);
updateRemoveButtonState(widgetState);
saveStateToDataWidget();
});
removeBtn.addEventListener("click", () => {
const numCurrent = widgetState._points.length;
if (numCurrent <= minGraphPoints) return;
const numToRemove = Math.max(1, Math.floor(numCurrent * 0.5));
let targetNumPoints = Math.max(minGraphPoints, numCurrent - numToRemove);
const actualNumToRemove = numCurrent - targetNumPoints;
if (actualNumToRemove <= 0) return;
const candidates = [];
const sortedPoints = [...widgetState._points].sort((a, b) => a.x - b.x);
for (let i = 1; i < sortedPoints.length - 1; i++) {
const p_prev = sortedPoints[i - 1];
const p_curr = sortedPoints[i];
const p_next = sortedPoints[i + 1];
const x_diff = p_next.x - p_prev.x;
let interpolatedY;
if (x_diff <= 1e-6) interpolatedY = p_prev.y;
else {
const ratio = (p_curr.x - p_prev.x) / x_diff;
interpolatedY = p_prev.y + ratio * (p_next.y - p_prev.y);
}
candidates.push({ point: p_curr, distance: Math.abs(p_curr.y - interpolatedY) });
}
candidates.sort((a, b) => a.distance - b.distance);
const pointsToRemove = new Set(candidates.slice(0, actualNumToRemove).map(c => c.point));
widgetState._points = widgetState._points.filter(pt => !pointsToRemove.has(pt));
drawCurve(widgetState);
updateSigmaDisplay(widgetState);
updateRemoveButtonState(widgetState);
saveStateToDataWidget();
});
widgetState.boundMouseUpHandler = () => {
if (widgetState._draggingIndex >= 0) {
widgetState._draggingIndex = -1;
if (widgetState.canvasEl) widgetState.canvasEl.style.cursor = "crosshair";
saveStateToDataWidget();
}
};
document.addEventListener("mouseup", widgetState.boundMouseUpHandler, true);
canvas.addEventListener("mousedown", (evt) => {
if (evt.target === addButton || evt.target === removeBtn) return;
evt.stopPropagation();
const rect = widgetState.canvasEl.getBoundingClientRect();
const x = evt.clientX - rect.left;
const y = evt.clientY - rect.top;
const clickRadiusSq = 64;
widgetState._draggingIndex = -1;
widgetState._rightClickIndex = -1;
let closestDistSq = Infinity;
let clickedIndex = -1;
widgetState._points.forEach((pt, i) => {
const px = pt.x * rect.width;
const py = (1 - pt.y) * rect.height;
const distSq = (px - x) ** 2 + (py - y) ** 2;
if (distSq < clickRadiusSq && distSq < closestDistSq) {
closestDistSq = distSq;
clickedIndex = i;
}
});
if (clickedIndex !== -1) {
if (evt.button === 0) {
widgetState._draggingIndex = clickedIndex;
widgetState.canvasEl.style.cursor = "grabbing";
} else if (evt.button === 2) {
widgetState._rightClickIndex = clickedIndex;
}
}
});
canvas.addEventListener("mousemove", (evt) => {
if (evt.target === addButton || evt.target === removeBtn) return;
const rect = widgetState.canvasEl.getBoundingClientRect();
const x = evt.clientX - rect.left;
const y = evt.clientY - rect.top;
if (widgetState._draggingIndex >= 0) {
evt.stopPropagation();
let nx = x / rect.width;
let ny = 1 - y / rect.height;
nx = Math.max(0, Math.min(1, nx));
ny = Math.max(0, Math.min(1, ny));
const point = widgetState._points[widgetState._draggingIndex];
if (point.x === 0 || point.x === 1) nx = point.x;
widgetState._points[widgetState._draggingIndex] = { x: nx, y: ny };
drawCurve(widgetState);
updateSigmaDisplay(widgetState);
saveStateToDataWidget();
} else {
let hovering = false;
const hoverRadiusSq = 64;
widgetState._points.forEach((pt) => {
const px = pt.x * rect.width;
const py = (1 - pt.y) * rect.height;
if ((px - x) ** 2 + (py - y) ** 2 < hoverRadiusSq)
hovering = true;
});
widgetState.canvasEl.style.cursor = hovering ? "grab" : "crosshair";
}
});
canvas.addEventListener("dblclick", (evt) => {
if (evt.target === addButton || evt.target === removeBtn) return;
evt.stopPropagation();
const rect = widgetState.canvasEl.getBoundingClientRect();
let nx = (evt.clientX - rect.left) / rect.width;
let ny = 1 - (evt.clientY - rect.top) / rect.height;
nx = Math.max(0, Math.min(1, nx));
ny = Math.max(0, Math.min(1, ny));
widgetState._points.push({ x: nx, y: ny });
drawCurve(widgetState);
updateSigmaDisplay(widgetState);
updateRemoveButtonState(widgetState);
saveStateToDataWidget();
});
canvas.addEventListener("contextmenu", (evt) => {
if (evt.target === addButton || evt.target === removeBtn) { evt.preventDefault(); return; }
evt.preventDefault();
evt.stopPropagation();
if (widgetState._rightClickIndex >= 0) {
const pt = widgetState._points[widgetState._rightClickIndex];
if (widgetState._points.length <= minGraphPoints) { /* Show a message if needed */ }
else if (pt.x > 1e-6 && pt.x < 1.0 - 1e-6) {
widgetState._points.splice(widgetState._rightClickIndex, 1);
drawCurve(widgetState);
updateSigmaDisplay(widgetState);
updateRemoveButtonState(widgetState);
saveStateToDataWidget();
} else { /* Do not remove boundary points */ }
}
widgetState._rightClickIndex = -1;
});
// Steps Widget Hook
if (stepsWidget) {
stepsWidget.callback = (value) => {
if (widgetState.originalStepsCallback) { widgetState.originalStepsCallback.call(stepsWidget, value); }
updateSigmaDisplay(widgetState);
};
}
// Resize Observer for canvas
widgetState.resizeObserver = new ResizeObserver(() => {
requestAnimationFrame(() => drawCurve(widgetState));
});
widgetState.resizeObserver.observe(canvasContainer);
// --- Initial Setup ---
requestAnimationFrame(() => {
console.log(`[SigmaGraphWidget] ${this.id}: Running initial setup in rAF.`);
if (customWidget?.value !== undefined) {
customWidget.value = dataWidget.value;
} else {
console.error(`[SigmaGraphWidget] ${this.id}: customWidget or customWidget.value missing for initial load.`);
let initialPoints = [];
try {
initialPoints = JSON.parse(dataWidget.value || '[]');
} catch(e) {
initialPoints = [{x:0,y:1},{x:1,y:0}];
}
widgetState._points = initialPoints;
drawCurve(widgetState);
updateSigmaDisplay(widgetState);
updateRemoveButtonState(widgetState);
}
this.setSize(this.computeSize());
this.setDirtyCanvas(true, true);
if (this.graph) { this.graph.setDirtyCanvas(true, true); }
});
// --- Cleanup on Removal ---
const originalOnRemoved = this.onRemoved;
this.onRemoved = () => {
console.log(`[SigmaGraphWidget] ${this.id}: Cleaning up.`);
if (widgetState.boundMouseUpHandler) { document.removeEventListener("mouseup", widgetState.boundMouseUpHandler, true); }
if (widgetState.resizeObserver) { widgetState.resizeObserver.disconnect(); widgetState.resizeObserver = null; }
if (stepsWidget && widgetState.originalStepsCallback) { stepsWidget.callback = widgetState.originalStepsCallback; }
if (originalOnRemoved) { originalOnRemoved.apply(this, arguments); }
};
// Mark node dirty after adding the widget
this.setDirtyCanvas(true, true);
}; // End of onConfigure patch
// --- Node Appearance ---
nodeType.prototype.resizable = true;
const originalComputeSize = nodeType.prototype.computeSize;
nodeType.prototype.computeSize = function(out) {
const size = originalComputeSize ? originalComputeSize.apply(this, [null]) : [this.constructor.NODE_WIDTH || 140, 0];
const customWidgetContentHeight = 225;
size[0] = Math.max(290, size[0]);
let baseHeight = this.constructor.title_height || 20;
if (this.inputs) {
this.inputs.forEach(input => { if (input.name !== graphDataWidgetName) { baseHeight += 20; } });
}
if (this.outputs) { baseHeight += this.outputs.length * 20; }
size[1] = baseHeight + customWidgetContentHeight;
size[1] = Math.max(280, size[1]);
if (out) { out[0] = size[0]; out[1] = size[1]; }
return size;
};
nodeType.prototype.onResize = function() { /* No-op */ };
}, // End of beforeRegisterNodeDef
}); // End of app.registerExtension