if( typeof document != "undefined" ) { document.nodes = { }; } const colors = { background: "#222", gridLine: "#444", connectionWire: "#00aaff", nodeBackground: "#2d2d2d", nodeBorder: "#777", nodeNameBackground: "#2d2d2d", nodeNameText: "#fff", inputPortFill: "#aaa", outputPortFill: "#00aaff", }; export class Node { name = ""; x = 0; y = 0; inputs = new Array(); outputs = new Array(); width = 0; cornerRadius = 10; constructor(name, x, y, inputs, outputs) { this.name = name; this.x = x; this.y = y; this.inputs = inputs; this.outputs = outputs; } getHeight() { return 40 + Math.max(this.inputs.length, this.outputs.length) * 20; } updateWidth(ctx) { const textWidth = ctx.measureText(this.name).width; this.width = Math.max(200, textWidth + 40); } roundRect(ctx, x, y, w, h, r) { ctx.beginPath(); ctx.moveTo(x + r, y); ctx.lineTo(x + w - r, y); ctx.quadraticCurveTo(x + w, y, x + w, y + r); ctx.lineTo(x + w, y + h - r); ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h); ctx.lineTo(x + r, y + h); ctx.quadraticCurveTo(x, y + h, x, y + h - r); ctx.lineTo(x, y + r); ctx.quadraticCurveTo(x, y, x + r, y); ctx.closePath(); } draw( ctx ) { const nodeHeight = this.getHeight(); if( typeof document != "undefined" ) { document.nodes[ this.name ] = { x:this.x, y: this.y }; } this.roundRect(ctx, this.x, this.y, this.width, nodeHeight, this.cornerRadius); ctx.fillStyle = colors.nodeBackground; ctx.fill(); ctx.strokeStyle = colors.nodeBorder; ctx.lineWidth = 2; this.roundRect(ctx, this.x, this.y, this.width, nodeHeight, this.cornerRadius); ctx.stroke(); ctx.fillStyle = colors.nodeNameText; ctx.fillText(this.name, this.x + 20, this.y + 20); ctx.fillStyle = colors.inputPortFill; for (let i = 0; i < this.inputs.length; i++) { const pos = this.getInputPos(i); ctx.beginPath(); ctx.arc(pos.x, pos.y, 6, 0, Math.PI * 2); ctx.fill(); ctx.fillText(this.inputs[i], pos.x + 12, pos.y); } ctx.fillStyle = colors.outputPortFill; for (let i = 0; i < this.outputs.length; i++) { const pos = this.getOutputPos(i); ctx.beginPath(); ctx.arc(pos.x, pos.y, 6, 0, Math.PI * 2); ctx.fill(); const label = this.outputs[i]; const labelWidth = ctx.measureText(label).width; ctx.fillText(label, pos.x - 12 - labelWidth, pos.y); } } getInputPos(index) { return { x: this.x, y: this.y + 40 + index * 20 }; } getOutputPos(index) { return { x: this.x + this.width, y: this.y + 40 + index * 20 }; } containsPoint(x, y) { const nodeHeight = this.getHeight(); return x >= this.x && x <= this.x + this.width && y >= this.y && y <= this.y + nodeHeight; } } export class Edge { fromNode = null; fromIndex = 0; toNode = null; toIndex = 0; toPos = null; constructor(fromNode, fromIndex, toNode = null, toIndex = null, toPos = null) { this.fromNode = fromNode; this.fromIndex = fromIndex; this.toNode = toNode; this.toIndex = toIndex; this.toPos = toPos; } draw(ctx) { const fromPos = this.fromNode.getOutputPos(this.fromIndex); let toPos; if (this.toNode !== null && this.toIndex !== null) { toPos = this.toNode.getInputPos(this.toIndex); } else if (this.toPos !== null) { toPos = this.toPos; } else { return; } const cp1 = { x: fromPos.x + 50, y: fromPos.y }; const cp2 = { x: toPos.x - 50, y: toPos.y }; ctx.beginPath(); ctx.moveTo(fromPos.x, fromPos.y); ctx.bezierCurveTo(cp1.x, cp1.y, cp2.x, cp2.y, toPos.x, toPos.y); ctx.stroke(); } distance(a, b) { return Math.hypot(a.x - b.x, a.y - b.y); } pointNear(pt, tolerance) { const fromPos = this.fromNode.getOutputPos(this.fromIndex); let toPos; if (this.toNode !== null && this.toIndex !== null) { toPos = this.toNode.getInputPos(this.toIndex); } else if (this.toPos !== null) { toPos = this.toPos; } else { return false; } const cp1 = { x: fromPos.x + 50, y: fromPos.y }; const cp2 = { x: toPos.x - 50, y: toPos.y }; const samples = 20; for (let i = 0; i <= samples; i++) { const t = i / samples; const x = Math.pow(1 - t, 3) * fromPos.x + 3 * Math.pow(1 - t, 2) * t * cp1.x + 3 * (1 - t) * Math.pow(t, 2) * cp2.x + Math.pow(t, 3) * toPos.x; const y = Math.pow(1 - t, 3) * fromPos.y + 3 * Math.pow(1 - t, 2) * t * cp1.y + 3 * (1 - t) * Math.pow(t, 2) * cp2.y + Math.pow(t, 3) * toPos.y; if (this.distance(pt, { x, y }) < tolerance) { return true; } } return false; } } export class GraphExplorer { isPanning = false; dragNode = null; isDrawScheduled = false; dragOffsetX = 0; dragOffsetY = 0; nodes = new Array(); edges = new Array(); dragWire = null; lastMousePos = null; panX = 40; panY = 20; panStartMouse = null; panStartOffsetX = 0; panStartOffsetY = 0; // Inertia variables velocityX = 0; velocityY = 0; friction = 0.93; velocityThreshold = 0.1; zoom = 1.; minZoom = 0.1; maxZoom = 4; canvas; context; requestDraw() { if ( this.isDrawScheduled ) { return; } this.isDrawScheduled = true; requestAnimationFrame(() => { this.isDrawScheduled = false; this.draw(); }); } draw( clear = true ) { this.context.setTransform(1, 0, 0, 1, 0, 0); if( clear ){ this.context.clearRect(0, 0, this.width, this.height); } this.context.save(); this.context.translate(this.panX, this.panY); this.context.scale(this.zoom, this.zoom); this.drawGrid(this.context); for (const node of this.nodes) { node.updateWidth(this.context); node.draw(this.context); } for (const edge of this.edges) { edge.draw(this.context); } this.context.restore(); } screenToWorld(x, y) { return { x: (x - this.panX) / this.zoom, y: (y - this.panY) / this.zoom, }; } worldToScreen(x, y) { return { x: x * this.zoom + this.panX, y: y * this.zoom + this.panY, }; } getNodeAtPoint(x, y) { const pt = this.screenToWorld(x, y); for (let i = this.nodes.length - 1; i >= 0; i--) { if ( this.nodes[i].containsPoint(pt.x, pt.y) ) { return this.nodes[i]; } } return null; } getWireAtPoint(x, y) { const pt = this.screenToWorld(x, y); for (let i = this.edges.length - 1; i >= 0; i--) { if (this.edges[i].pointNear(pt, 10 / this.zoom)) { return this.edges[i]; } } return null; } drawGrid( context ) { const gridSpacing = 50; const panWorldX = -this.panX / this.zoom; const panWorldY = -this.panY / this.zoom; const widthWorld = this.context.canvas.width / this.zoom; const heightWorld = this.context.canvas.height / this.zoom; const startX = Math.floor(panWorldX / gridSpacing) * gridSpacing; const endX = panWorldX + widthWorld; const startY = Math.floor(panWorldY / gridSpacing) * gridSpacing; const endY = panWorldY + heightWorld; this.context.strokeStyle = "#282727"; this.context.lineWidth = 1 / this.zoom; this.context.beginPath(); // Draw vertical lines within viewport for (let x = startX; x <= endX; x += gridSpacing) { this.context.moveTo(x, panWorldY); this.context.lineTo(x, panWorldY + heightWorld); } // Draw horizontal lines within viewport for (let y = startY; y <= endY; y += gridSpacing) { this.context.moveTo(panWorldX, y); this.context.lineTo(panWorldX + widthWorld, y); } this.context.stroke(); } getMousePos( e ) { const rect = this.canvas.getBoundingClientRect(); return { x: (e.clientX - rect.left), y: (e.clientY - rect.top), }; } setup( canvas ) { this.canvas = canvas; this.context = canvas.getContext("2d"); this.canvas.addEventListener("mousedown", this.onMouseDown.bind( this ) ); this.canvas.addEventListener("mousemove", this.onMouseMove.bind( this ) ); this.canvas.addEventListener("mouseup", this.onMouseUp.bind( this ) ); this.canvas.addEventListener("contextmenu", this.onContextMenu.bind( this ) ); window.addEventListener("resize", this.onResize.bind( this ) ); document.addEventListener("click", this.onClick.bind( this ) ); document.getElementById("deleteNodeBtn").addEventListener("click", this.deleteNodeClick.bind(this) ); document.getElementById("deleteWireBtn").addEventListener("click", this.deleteWireButton.bind( this ) ); canvas.addEventListener("wheel", this.onWheel.bind( this ) ); } async wait( milliseconds ) { return new Promise(function( resolve ) { setTimeout( resolve, milliseconds ); }); } async onResize( windowWidth, windowHeight ) { const devicePixelRatio = window.devicePixelRatio || 1; this.width = windowWidth; this.height = windowHeight; this.canvas.width = this.width * devicePixelRatio; this.canvas.height = this.height * devicePixelRatio; this.canvas.style.width = this.width + "px"; this.canvas.style.height = this.height + "px"; this.context.setTransform( 1, 0, 0, 1, 0, 0 ); this.context.scale( devicePixelRatio, devicePixelRatio ); //await this.wait(100) this.draw( false ); } onMouseDown( e ) { const pos = this.getMousePos(e); const worldPos = this.screenToWorld( pos.x, pos.y ); if (e.button === 0) { const node = this.getNodeAtPoint( pos.x, pos.y ); if (node) { // Check if clicked on output port to start wire drag for (let i = 0; i < node.outputs.length; i++) { const outputPos = node.getOutputPos(i); const dist = Math.hypot(worldPos.x - outputPos.x, worldPos.y - outputPos.y); if (dist < 10) { // 10 is port radius threshold this.dragWire = new Edge(node, i, null, null, worldPos); this.edges.push(this.dragWire); this.requestDraw(); return; // Early return to prevent node dragging start } } // Otherwise start dragging node this.dragNode = node; this.dragOffsetX = worldPos.x - node.x; this.dragOffsetY = worldPos.y - node.y; this.velocityX = 0; this.velocityY = 0; } else { // Start panning canvas if clicked empty background this.isPanning = true; this.panStartMouse = { x: pos.x, y: pos.y }; this.panStartOffsetX = this.panX; this.panStartOffsetY = this.panY; this.velocityX = 0; this.velocityY = 0; } } this.lastMousePos = pos; this.requestDraw(); } onMouseMove( e ) { console.log("test"); const pos = this.getMousePos(e); const worldPos = this.screenToWorld(pos.x, pos.y); if (this.dragWire) { // Update wire temporary end point in world coordinates this.dragWire.toPos = worldPos; this.requestDraw(); } else if (this.dragNode) { const prevX = this.dragNode.x; const prevY = this.dragNode.y; this.dragNode.x = worldPos.x - this.dragOffsetX; this.dragNode.y = worldPos.y - this.dragOffsetY; this.velocityX = (this.dragNode.x - prevX) / (e.movementX || 1); this.velocityY = (this.dragNode.y - prevY) / (e.movementY || 1); this.requestDraw(); } else if (this.isPanning) { const dx = pos.x - this.panStartMouse.x; const dy = pos.y - this.panStartMouse.y; const prevPanX = this.panX; const prevPanY = this.panY; this.panX = this.panStartOffsetX + dx; this.panY = this.panStartOffsetY + dy; this.velocityX = this.panX - prevPanX; this.velocityY = this.panY - prevPanY; this.requestDraw(); } this.lastMousePos = pos; } onMouseUp( e ) { if ( e.button === 0) { if ( this.dragWire ) { const pos = this.getMousePos( e ); const worldPos = this.screenToWorld( pos.x, pos.y ); // Try to attach to input port of a node let connected = false; for (const node of this.nodes) { for (let i = 0; i < node.inputs.length; i++) { const inputPos = node.getInputPos( i ); const dist = Math.hypot( worldPos.x - inputPos.x, worldPos.y - inputPos.y ); if (dist < 10) { // Attach wire end to this input port this.dragWire.toNode = node; this.dragWire.toIndex = i; this.dragWire.toPos = null; connected = true; break; } } if (connected) break; } if (!connected) { // Remove the wire if not connected const idx = this.edges.indexOf(this.dragWire); if (idx >= 0) this.edges.splice(idx, 1); } this.dragWire = null; this.requestDraw(); } else if (this.dragNode) { this.dragNode = null; } else if (this.isPanning) { this.isPanning = false; } } } onContextMenu(e) { const wireMenu = document.getElementById("wireMenu"); const nodeMenu = document.getElementById("nodeMenu"); e.preventDefault(); const pos = this.getMousePos( e ); const worldPos = this.screenToWorld( pos.x, pos.y ); // Hide both menus by default wireMenu.style.display = "none"; nodeMenu.style.display = "none"; const node = this.getNodeAtPoint( pos.x, pos.y ); if (node) { // Show node context menu nodeMenu.style.left = e.clientX + "px"; nodeMenu.style.top = e.clientY + "px"; nodeMenu.style.display = "block"; // Store clicked node reference for later use (e.g., deletion) nodeMenu.currentNode = node; } else { const wire = this.getWireAtPoint( pos.x, pos.y ); if (wire) { // Show wire context menu wireMenu.style.left = e.clientX + "px"; wireMenu.style.top = e.clientY + "px"; wireMenu.style.display = "block"; // Store clicked wire reference for later use wireMenu.currentWire = wire; } } } onClick( e ) { const wireMenu = document.getElementById("wireMenu"); const nodeMenu = document.getElementById("nodeMenu"); // Hide context menus on any click outside them if (e.target !== nodeMenu && !nodeMenu.contains(e.target)) { nodeMenu.style.display = "none"; nodeMenu.currentNode = null; } if (e.target !== wireMenu && !wireMenu.contains(e.target)) { wireMenu.style.display = "none"; wireMenu.currentWire = null; } } deleteNodeClick() { const wireMenu = document.getElementById("wireMenu"); const nodeMenu = document.getElementById("nodeMenu"); if (nodeMenu.currentNode) { const index = this.nodes.indexOf(nodeMenu.currentNode); if (index !== -1) { // Remove edges connected to this node for (let i = this.edges.length - 1; i >= 0; i--) { if (this.edges[i].fromNode === nodeMenu.currentNode || this.edges[i].toNode === nodeMenu.currentNode) { this.edges.splice(i, 1); } } this.nodes.splice(index, 1); } nodeMenu.style.display = "none"; nodeMenu.currentNode = null; this.requestDraw(); } } deleteWireButton() { const wireMenu = document.getElementById("wireMenu"); if (wireMenu.currentWire) { const index = this.edges.indexOf(wireMenu.currentWire); if (index !== -1) { this.edges.splice(index, 1); } wireMenu.style.display = "none"; wireMenu.currentWire = null; this.requestDraw(); } } onWheel( event ) { event.preventDefault(); const zoomFactor = event.deltaY < 0 ? 1.1 : 0.9; this.zoomAtCenter( zoomFactor, this.canvas.width, this.canvas.height); this.requestDraw(); } zoomAtCenter( zoomDelta, canvasWidth, canvasHeight ) { const oldZoom = this.zoom; const centerX = canvasWidth / 2; const centerY = canvasHeight / 2; // Clamp new this.zoom this.zoom = Math.min(this.maxZoom, Math.max(this.minZoom, this.zoom * zoomDelta)); // Adjust pan so this.zoom happens at canvas center (keep world pos under center fixed) this.panX = centerX - (centerX - this.panX) * (this.zoom / oldZoom); this.panY = centerY - (centerY - this.panY) * (this.zoom / oldZoom); } inertiaLoop() { if (!this.isPanning && !this.dragNode) { // Apply this.friction this.velocityX *= this.friction; this.velocityY *= this.friction; this.panX += this.velocityX; this.panY += this.velocityY; if (Math.abs(this.velocityX) > this.velocityThreshold || Math.abs(this.velocityY) > this.velocityThreshold) { this.draw(); requestAnimationFrame(this.inertiaLoop.bind(this)); } } else { requestAnimationFrame(this.inertiaLoop.bind(this)); } } addNode( node ) { this.nodes.push( node ); } addEdge( edge ) { this.edges.push( edge ); } }