Files
Temult-TWanSigmaGraph/js/SigmaGraphWidget.js
T
2025-04-12 10:00:35 -04:00

912 lines
37 KiB
JavaScript
Raw 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.
// 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 = `<div style="font-size: 10px; line-height: 1.4;">
<strong>Sigma Schedule Editor</strong><br>
Bidirectional editor for sigma schedules.<br>
<strong>🔄</strong>: Refresh text from graph/steps.<br>
<strong>▼</strong>: Apply text to graph points.<br>
Drag, double-click, right-click to edit points.<br>
Use <strong>+/-</strong> on graph to adjust density.<br>
<i>Special thanks to huchenlei for js help!!<i>
</div>`;
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