First commit

This commit is contained in:
2025-12-25 10:36:24 +01:00
commit 17ce5ef2fa
7 changed files with 2961 additions and 0 deletions

778
GraphExplorer.js Normal file
View File

@@ -0,0 +1,778 @@
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 );
}
}