import { ServoNode, CurveNode, VariableNode, NoiseNode, MathNode, MapNode, NODE_TYPES } from "./nodes.js" export class NodeEditor { constructor(canvas, options = {}) { this.canvas = canvas; canvas.width = canvas.offsetWidth; canvas.height = canvas.offsetHeight; this.ctx = canvas.getContext("2d"); this.nodes = []; this.connections = []; this.draggingNode = null; this.draggingWire = null; this.motorIds = options.motorIds || []; // pass motor ID list here this.panX = 0; this.panY = 0; this.isPanning = false; this.lastPan = { x: 0, y: 0 }; this.zoom = 1.0; this.focusedInput = null; this._bindEvents(); this._redraw(); } generateDefaultNodes(curveSets, motorIDs) { //console.log("Generating Default Nodes"); //console.log(curveSets, motorIDs); for (var i = 0; i < motorIDs.length; i++) { let inputNode = this.addCurveNode(200, 20 + i * 140, "Curve", motorIDs[i]); let outputNode = this.addServoNode(500, 40 + i * 140, "Servo Output", motorIDs[i]); this.connections.push({ from: inputNode, to: outputNode }); } //this.addVariableNode(50, 120, "Var"); } getNodeByID(id) { return this.nodes.find(node => node.id === id) || null; } getAllInputs() { let inputs = []; for (var i = 0; i < this.nodes.length; i++) { inputs.push(...this.nodes[i].inputs); } return inputs; } encodeNodeGraph() { const bufferSize = 1024; // adjust based on expected graph size const buffer = new ArrayBuffer(bufferSize); const view = new DataView(buffer); let offset = 0; const nodes = this.nodes; // Node count (1 byte) view.setUint8(offset++, nodes.length); // Encode nodes nodes.forEach((node, index) => { const typeCode = NODE_TYPES[node.type]; node.id = index; view.setUint8(offset++, node.type); // Node type view.setUint8(offset++, node.id); // Node ID view.setUint16(offset, node.x, true); offset += 2; view.setUint16(offset, node.y, true); offset += 2; switch (node.type) { case NODE_TYPES.Servo: view.setUint8(offset++, node.motorId); break; case NODE_TYPES.Curve: view.setUint8(offset++, node.curveId); break; case NODE_TYPES.Noise: view.setFloat32(offset, node.inputFrequency.numericValue, true); offset += 4; view.setUint16(offset, node.inputSeed.numericValue); offset += 2; break; case NODE_TYPES.Variable: view.setUint8(offset++, node.variableDropdown.selectedIndex); view.setUint8(offset++, node.arg0Input.numericValue); break; case NODE_TYPES.Math: view.setUint8(offset++, node.operatorDropdown.selectedIndex); view.setFloat32(offset, node.valueInput.numericValue, true); offset += 4; break; case NODE_TYPES.Map: view.setFloat32(offset, node.inMinInput.numericValue, true); offset += 4; view.setFloat32(offset, node.inMaxInput.numericValue, true); offset += 4; view.setFloat32(offset, node.outMinInput.numericValue, true); offset += 4; view.setFloat32(offset, node.outMaxInput.numericValue, true); offset += 4; console.log(node.inMinInput.numericValue, node.inMaxInput.numericValue, node.outMinInput.numericValue, node.outMaxInput.numericValue); break; default: console.warn("Unknown node type:", node.type); } }); // Connection count (1 byte) view.setUint8(offset++, this.connections.length); // Encode connections this.connections.forEach(conn => { view.setUint8(offset++, conn.from.id); view.setUint8(offset++, conn.to.id); }); // Slice the buffer to actual used size return new Uint8Array(buffer.slice(0, offset)); } loadFromBinary(data) { this.nodes = [] console.log(this.connections); this.connections = [] const view = new DataView(data.buffer); let offset = 0; const nodeCount = view.getUint8(offset++); const idMap = {}; // Map node IDs to actual node instances for (let i = 0; i < nodeCount; i++) { const type = view.getUint8(offset++); const id = view.getUint8(offset++); const x = view.getUint16(offset, true); offset += 2; const y = view.getUint16(offset, true); offset += 2; let node = null; switch (type) { case NODE_TYPES.Servo: { const motorID = view.getUint8(offset++); node = this.addServoNode(x, y, "Servo Output", motorID); break; } case NODE_TYPES.Curve: { const curveID = view.getUint8(offset++); node = this.addCurveNode(x, y, "Curve", curveID); break; } case NODE_TYPES.Noise: { const frequency = view.getFloat32(offset, true); offset += 4; const seed = view.getUint16(offset, true); offset += 2; node = this.addNoiseNode(x, y, "Noise"); node.inputFrequency.value = frequency; node.inputSeed.value = seed; break; } case NODE_TYPES.Variable: { const source = view.getUint8(offset++); const arg0 = view.getUint8(offset++); node = this.addVariableNode(x, y, "Variable"); node.variableDropdown.selectedIndex = source; node.arg0Input.value = arg0; break; } case NODE_TYPES.Math: { const op = view.getUint8(offset++); const value = view.getFloat32(offset, true); offset += 4; node = this.addMathNode(x, y, "Math"); console.log(op, value); node.operatorDropdown.selectedIndex = op; node.valueInput.value = value; break; } case NODE_TYPES.Map: { const inMin = view.getFloat32(offset, true); offset += 4; const inMax = view.getFloat32(offset, true); offset += 4; const outMin = view.getFloat32(offset, true); offset += 4; const outMax = view.getFloat32(offset, true); offset += 4; node = this.addMapNode(x, y, "Map"); node.inMinInput.value = inMin; node.inMaxInput.value = inMax; node.outMinInput.value = outMin; node.outMaxInput.value = outMax; break; } default: { node = this.addNode(x, y, "Unknown Node"); break; } } if (node) { node.id = id; idMap[id] = node; } } console.log(this.getNodeByID(0)); // 🔗 Load connections this.connection = []; const connectionCount = view.getUint8(offset++); for (let i = 0; i < connectionCount; i++) { const fromID = view.getUint8(offset++); const toID = view.getUint8(offset++); const fromNode = idMap[fromID]; const toNode = idMap[toID]; if (fromNode && toNode) { this.connections.push({ from: fromNode, to: toNode }); } } // Reencode all positions to SIGNED ints this.nodes.forEach(node => { node.x = (node.x << 16) >> 16; node.y = (node.y << 16) >> 16; }); this._redraw(); } addNode(x, y, label, options = {}) { const node = new Node(x, y, label, options); this.nodes.push(node); this._redraw(); return node; } addServoNode(x, y, label = "Servo Output", motorID) { //console.log(motorID); const node = new ServoNode(x, y, label, motorID); this.nodes.push(node); this._redraw(); return node; } addCurveNode(x, y, label = "Curve", motorID) { //console.log(motorID); const node = new CurveNode(x, y, label, motorID); this.nodes.push(node); this._redraw(); return node; } addInputNode(x, y, label = "Input Node", options = { defaultValue: 1.0 }) { const node = new InputNode(x, y, label, options); this.nodes.push(node); this._redraw(); return node; } addNoiseNode(x, y, label = "Noise Generator", options = {}) { const node = new NoiseNode(x, y, label, options); this.nodes.push(node); this._redraw(); return node; } addVariableNode(x, y, label = "Variable") { const node = new VariableNode(x, y, label); this.nodes.push(node); this._redraw(); return node; } addMathNode(x, y, label = "Math") { const node = new MathNode(x, y, label); this.nodes.push(node); this._redraw(); return node; } addMapNode(x, y, label = "Map") { const node = new MapNode(x, y, label); this.nodes.push(node); this._redraw(); return node; } _bindEvents() { this.canvas.addEventListener("mousedown", (e) => this._onMouseDown(e)); this.canvas.addEventListener("mousemove", (e) => this._onMouseMove(e)); this.canvas.addEventListener("mouseup", (e) => this._onMouseUp(e)); this.canvas.addEventListener("dblclick", (e) => this._onDoubleClick(e)); window.addEventListener("keydown", (e) => this._onKeyDown(e)); this.canvas.addEventListener("wheel", (e) => this._onWheel(e)); this.canvas.addEventListener("contextmenu", (e) => { e.preventDefault(); const { x, y } = this._getMouse(e); // canvas-space for node placement this._showContextMenu(e.pageX, e.pageY, x, y); // page-space for menu placement }); } _onMouseDown(e) { const { x, y } = this._getMouse(e); if (e.button === 1) { // middle mouse this.isPanning = true; console.log("pan") this.lastPan = this._getMouse(e); e.preventDefault(); return; } this.focusedInput = null; let allInputs = this.getAllInputs(); for (const input of allInputs) { if (input.handleClick(x, y)) { this.focusedInput = input; } else { input.focused = false; } } for (const node of this.nodes) { if (node.contains(x, y)) { if (node.handleClick(x, y)) { this._redraw(); return; } this.draggingNode = node; node.offsetX = x - node.x; node.offsetY = y - node.y; return; } if (node.hitOutput(x, y)) { this.draggingWire = { x1: node.output.x, y1: node.output.y, x2: x, y2: y, from: node }; return; } } } _onMouseMove(e) { const { x, y } = this._getMouse(e); let needsRedraw = false; if (this.isPanning) { const { x, y } = this._getMouse(e); const { screenX, screenY } = this._getMouse(e); this.panX += screenX - this.lastPan.screenX; this.panY += screenY - this.lastPan.screenY; this.lastPan = { screenX, screenY }; this._redraw(); //return; } // Required for highlighting dropdown options onMouseOver for (const node of this.nodes) { //if (node instanceof ServoNode || node instanceof VariableNode) { if (node.handleMouseMove(x, y)) { needsRedraw = true; } //} } if (needsRedraw) this._redraw(); if (this.draggingNode) { this.draggingNode.x = x - this.draggingNode.offsetX; this.draggingNode.y = y - this.draggingNode.offsetY; this.draggingNode.updatePorts(); this._redraw(); } if (this.draggingWire) { this.draggingWire.x2 = x; this.draggingWire.y2 = y; this._redraw(); } } _onMouseUp(e) { const { x, y } = this._getMouse(e); if (this.isPanning) { this.isPanning = false; } if (this.draggingWire) { for (const node of this.nodes) { if (node.hitInput(x, y)) { // Remove any existing connection to this input for (let i = this.connections.length - 1; i >= 0; i--) { if (this.connections[i].to === node) { this.connections.splice(i, 1); } } // Add new connection this.connections.push({ from: this.draggingWire.from, to: node }); break; } } this.draggingWire = null; this._redraw(); } this.draggingNode = null; } _onDoubleClick(e) { const { x, y } = this._getMouse(e); // First: check for wire deletion for (let i = this.connections.length - 1; i >= 0; i--) { const conn = this.connections[i]; if (this._isPointNearLine(x, y, conn.from.output.x, conn.from.output.y, conn.to.input.x, conn.to.input.y)) { this.connections.splice(i, 1); this._redraw(); return; } } // Second: check for node deletion for (let i = this.nodes.length - 1; i >= 0; i--) { const node = this.nodes[i]; if (node.contains(x, y) && node.canDelete) { // Remove connections to/from this node this.connections = this.connections.filter(conn => conn.from !== node && conn.to !== node); this.nodes.splice(i, 1); this._redraw(); return; } } } _getMouse(e) { const rect = this.canvas.getBoundingClientRect(); const screenX = e.clientX - rect.left; const screenY = e.clientY - rect.top; const canvasX = (screenX - this.panX) / this.zoom; const canvasY = (screenY - this.panY) / this.zoom; return { x: canvasX, y: canvasY, screenX, screenY }; } _onKeyDown(e) { let needsRedraw = false; for (const node of this.nodes) { if (node.handleKey && node.handleKey(e)) { needsRedraw = true; } } if (needsRedraw) this._redraw(); } _onWheel(e) { const zoomFactor = 1.1; if (e.deltaY < 0) { this.zoom *= zoomFactor; } else { this.zoom /= zoomFactor; } this.zoom = Math.max(0.2, Math.min(3.0, this.zoom)); // clamp zoom this._redraw(); e.preventDefault(); } _isPointNearLine(px, py, x1, y1, x2, y2, threshold = 10) { const dx = x2 - x1; const dy = y2 - y1; const lengthSquared = dx * dx + dy * dy; if (lengthSquared === 0) return false; const t = Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / lengthSquared)); const closestX = x1 + t * dx; const closestY = y1 + t * dy; const dist = Math.hypot(px - closestX, py - closestY); return dist < threshold; } _showContextMenu(screenX, screenY, canvasX, canvasY) { const menu = document.getElementById("contextMenu"); menu.innerHTML = ""; // clear previous items menu.style.left = `${screenX}px`; menu.style.top = `${screenY}px`; menu.style.display = "block"; const hideMenu = () => { menu.style.display = "none"; window.removeEventListener("click", hideMenu); }; window.addEventListener("click", hideMenu); // ✅ Only include Variable node let item = document.createElement("div"); item.className = "menu-item"; item.textContent = "➕ Add Variable Node"; item.onclick = () => { this.addVariableNode(canvasX, canvasY, "Variable"); hideMenu(); }; menu.appendChild(item); item = document.createElement("div"); item.className = "menu-item"; item.textContent = "➕ Add Math Node"; item.onclick = () => { this.addMathNode(canvasX, canvasY, "Math"); hideMenu(); }; menu.appendChild(item); item = document.createElement("div"); item.className = "menu-item"; item.textContent = "➕ Add Map Node"; item.onclick = () => { this.addMapNode(canvasX, canvasY, "Map"); hideMenu(); }; menu.appendChild(item); item = document.createElement("div"); item.className = "menu-item"; item.textContent = "➕ Add Noise Node"; item.onclick = () => { this.addNoiseNode(canvasX, canvasY, "Noise"); hideMenu(); }; menu.appendChild(item); } _drawConnections() { this.ctx.strokeStyle = "#444"; this.ctx.lineWidth = 2; for (const conn of this.connections) { const x1 = conn.from.output.x; const y1 = conn.from.output.y; const x2 = conn.to.input.x; const y2 = conn.to.input.y; const dx = Math.abs(x2 - x1) * 0.5; this.ctx.beginPath(); this.ctx.moveTo(x1, y1); this.ctx.bezierCurveTo( x1 + dx, y1, x2 - dx, y2, x2, y2 ); this.ctx.stroke(); } } _redraw() { this.ctx.save(); this.ctx.setTransform(1, 0, 0, 1, 0, 0); // reset transform this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); // clear full canvas this.ctx.setTransform(this.zoom, 0, 0, this.zoom, this.panX, this.panY); this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); this._drawConnections(); for (const node of this.nodes) { node.draw(this.ctx); } if (this.draggingWire) { this.ctx.strokeStyle = "#888"; this.ctx.lineWidth = 2; this.ctx.beginPath(); this.ctx.moveTo(this.draggingWire.x1, this.draggingWire.y1); this.ctx.lineTo(this.draggingWire.x2, this.draggingWire.y2); this.ctx.stroke(); } this.ctx.restore(); } }