sophia_controller/nodeeditor/NodeEditor.js

667 lines
18 KiB
JavaScript

import { Node, ServoNode, VariableNode, CurveNode, MathNode, MapNode, NoiseNode } from './Node.js';
import { TextInputControl } from './Control.js';
import { encodeNodeGraph, loadFromBinary } from './NodeSerializer.js';
// === Grid Settings ===
const GRID_SPACING = 20; // Distance between dots in pixels
const CANVAS_COLOR = "#ffffffff";
const GRID_COLOR = "#ff0000ff"; // Dot color
const GRID_RADIUS = 1; // Dot radius in pixels
export class NodeEditor {
constructor(canvas) {
this.canvas = canvas;
canvas.width = canvas.offsetWidth;
canvas.height = canvas.offsetHeight;
this.ctx = canvas.getContext('2d');
this.nodes = [
//new Node(100, 100),
//new Node(300, 200),
// new CurveNode(600, 400, 22),
// new ServoNode(900, 400, 22),
// new VariableNode(300, 800),
// new MathNode(600, 800),
// new MapNode(900, 800),
// new NoiseNode(900, 1200),
];
this.connections = []
this.draggingConnection = null; // { fromNode: Node, x: number, y: number }
this.hoveredConnection = null;
//this.connections.push({ from: this.nodes[0], to: this.nodes[1] });
//this.connections.push({ from: this.nodes[2], to: this.nodes[3] });
this.draggingNode = null;
this.offsetX = 0; // pan X
this.offsetY = 0; // pan Y
this.zoom = 1; // zoom scale
this.isPanning = false;
this.panStart = { x: 0, y: 0 };
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("dblclick", this.onDoubleClick.bind(this));
this.canvas.addEventListener('wheel', this.onWheel.bind(this), { passive: false });
this.canvas.addEventListener("contextmenu", this.onContextMenu.bind(this));
this.modifiers = {
ctrl: false,
shift: false,
alt: false
};
window.addEventListener("keydown", (e) => {
if (e.key === "Control") this.modifiers.ctrl = true;
if (e.key === "Shift") this.modifiers.shift = true;
if (e.key === "Alt") this.modifiers.alt = true;
for (const node of this.nodes) {
for (const control of node.controls) {
if (control.isFocused) {
let clipboardHandled = false;
if (control.onClipboard) {
control.onClipboard(e);
clipboardHandled = e.defaultPrevented;
}
if (!clipboardHandled && control.onKeyDown) {
control.onKeyDown(e.key);
}
e.preventDefault();
return;
}
}
}
});
window.addEventListener("keyup", (e) => {
if (e.key === "Control") this.modifiers.ctrl = false;
if (e.key === "Shift") this.modifiers.shift = false;
if (e.key === "Alt") this.modifiers.alt = false;
});
// let encoded = encodeNodeGraph(this.nodes, this.connections);
// console.log(encoded);
//loadFromBinary(this, encoded);
//this.createGridCanvas();
}
start() {
requestAnimationFrame(this.draw.bind(this));
}
clear() {
this.nodes = [];
this.connections = [];
}
addNode(node) {
this.nodes.push(node);
this._redraw?.(); // or trigger draw manually
}
generateDefaultNodes(curveSets, motorIDs) {
//console.log("Generating Default Nodes");
//console.log(curveSets, motorIDs);
for (var i = 0; i < motorIDs.length; i++) {
let inputNode = new CurveNode(200, 20 + i * 140, motorIDs[i]);
let outputNode = new ServoNode(500, 40 + i * 140, motorIDs[i]);
this.nodes.push(inputNode);
this.nodes.push(outputNode);
this.connections.push({ from: inputNode, to: outputNode });
//console.log(inputNode);
}
this.draw();
//this.addVariableNode(50, 120, "Var");
}
encodeNodeGraph() {
return encodeNodeGraph(this.nodes, this.connections);
}
loadFromBinary(data) {
console.log(data);
this.clear();
loadFromBinary(this, data);
}
draw() {
this.ctx.fillStyle = CANVAS_COLOR;
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
//this.ctx.drawImage(this.gridCanvas, 0, 0);
this.drawGrid();
this.ctx.save();
this.ctx.translate(this.offsetX, this.offsetY);
this.ctx.scale(this.zoom, this.zoom);
this.drawConnections();
for (const node of this.nodes) {
node.draw(this.ctx);
}
this.ctx.restore();
requestAnimationFrame(this.draw.bind(this));
}
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);
if (conn === this.hoveredConnection) {
this.ctx.strokeStyle = "#666"; // lighter on hover
this.ctx.lineWidth = 6.5;
} else {
this.ctx.strokeStyle = "#444";
this.ctx.lineWidth = 4;
}
this.ctx.stroke();
}
// Live dragging connection
if (this.draggingConnection) {
const x1 = this.draggingConnection.fromNode.output.x;
const y1 = this.draggingConnection.fromNode.output.y;
const x2 = this.draggingConnection.x;
const y2 = this.draggingConnection.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();
}
}
createGridCanvas() {
const gridCanvas = document.createElement('canvas');
gridCanvas.width = this.canvas.width;
gridCanvas.height = this.canvas.height;
const ctx = gridCanvas.getContext('2d');
ctx.fillStyle = GRID_COLOR;
for (let x = 0; x < gridCanvas.width; x += GRID_SPACING) {
for (let y = 0; y < gridCanvas.height; y += GRID_SPACING) {
ctx.fillRect(x, y, 1, 1);
}
}
this.gridCanvas = gridCanvas;
}
drawGrid() {
const { width, height } = this.canvas;
const spacing = GRID_SPACING * this.zoom;
const radius = GRID_RADIUS * this.zoom;
// Skip grid rendering if spacing is too small
if (spacing < 2) return;
// Dynamically skip grid points when zoomed out
const skip = spacing < 8 ? Math.ceil(8 / spacing) : 1;
this.ctx.fillStyle = GRID_COLOR;
this.ctx.beginPath();
const startX = (this.offsetX % spacing + spacing) % spacing;
const startY = (this.offsetY % spacing + spacing) % spacing;
let i = 0;
for (let x = startX; x < width; x += spacing, i++) {
if (i % skip !== 0) continue;
let j = 0;
for (let y = startY; y < height; y += spacing, j++) {
if (j % skip !== 0) continue;
this.ctx.fillRect(x - radius / 2, y - radius / 2, radius, radius);
}
}
this.ctx.fill();
}
snapToGrid(x, y) {
const spacing = GRID_SPACING;
const snappedX = Math.round(x / spacing) * spacing;
const snappedY = Math.round(y / spacing) * spacing;
return { x: snappedX, y: snappedY };
}
screenToWorld(mx, my) {
const rect = this.canvas.getBoundingClientRect();
const x = (mx - rect.left - this.offsetX) / this.zoom;
const y = (my - rect.top - this.offsetY) / this.zoom;
return { x, y };
}
onMouseDown(e) {
if (e.button === 1) { // middle mouse
this.isPanning = true;
this.panStart.x = e.clientX;
this.panStart.y = e.clientY;
e.preventDefault();
return;
}
const { x: wx, y: wy } = this.screenToWorld(e.clientX, e.clientY);
for (const node of this.nodes) {
if (node.hasOutput) {
const { x, y } = node.output;
const radius = 8;
const dx = wx - x;
const dy = wy - y;
if (dx * dx + dy * dy < radius * radius) {
this.draggingConnection = {
fromNode: node,
x: wx,
y: wy
};
return;
}
}
for (const control of node.controls) {
control._ctx = this.ctx; // inject context if needed
const handled = control.onEvent?.("mouseDown", {
mx: wx,
my: wy,
parentX: node.x,
parentY: node.y
});
if (handled) return;
}
}
for (let i = this.nodes.length - 1; i >= 0; i--) {
const node = this.nodes[i];
if (node.contains(wx, wy)) {
node.onSelect(true);
node.onDrag(true);
node.offsetX = wx - node.x;
node.offsetY = wy - node.y;
this.draggingNode = node;
this.nodes.splice(i, 1);
this.nodes.push(node);
break;
}
}
}
onMouseMove(e) {
this.hoveredConnection = null;
if (this.isPanning) {
const dx = e.clientX - this.panStart.x;
const dy = e.clientY - this.panStart.y;
this.offsetX += dx;
this.offsetY += dy;
this.panStart.x = e.clientX;
this.panStart.y = e.clientY;
return;
}
const { x: wx, y: wy } = this.screenToWorld(e.clientX, e.clientY);
if (this.draggingConnection) {
this.draggingConnection.x = wx;
this.draggingConnection.y = wy;
return;
}
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;
for (let t = 0; t <= 1; t += 0.01) {
const px = this.bezierPoint(x1, x1 + dx, x2 - dx, x2, t);
const py = this.bezierPoint(y1, y1, y2, y2, t);
const dist = Math.hypot(wx - px, wy - py);
if (dist < 8) {
this.hoveredConnection = conn;
break;
}
}
if (this.hoveredConnection) break;
}
// First, check if any control is under the cursor
let controlHovered = false;
for (let i = this.nodes.length - 1; i >= 0; i--) {
const node = this.nodes[i];
for (const control of node.controls) {
control._ctx = this.ctx; // inject context if needed
if (control.contains?.(wx, wy, node.x, node.y)) {
controlHovered = true;
}
control.onEvent?.("mouseMove", {
mx: wx,
my: wy,
parentX: node.x,
parentY: node.y,
modifiers: { ...this.modifiers, zoom: this.zoom }
});
}
if (!this.draggingNode && !controlHovered) {
for (const node of this.nodes) {
node.onMouseMove(wx, wy);
}
}
}
// If dragging, update position and skip hover logic
if (this.draggingNode) {
const rawX = wx - this.draggingNode.offsetX;
const rawY = wy - this.draggingNode.offsetY;
const { x, y } = this.snapToGrid(rawX, rawY);
this.draggingNode.x = x;
this.draggingNode.y = y;
this.draggingNode.onHover(true);
return;
}
// If a control is hovered, skip node hover
if (controlHovered) {
for (const node of this.nodes) {
node.onHover(false);
}
return;
}
// Otherwise, handle node hover normally
let hoveringHandled = false;
for (let i = this.nodes.length - 1; i >= 0; i--) {
const node = this.nodes[i];
if (!hoveringHandled && node.contains(wx, wy)) {
node.onHover(true);
hoveringHandled = true;
} else {
node.onHover(false);
}
}
}
onMouseUp(e) {
const { x: wx, y: wy } = this.screenToWorld(e.clientX, e.clientY);
if (this.isPanning) {
this.isPanning = false;
return;
}
if (this.draggingConnection) {
for (const node of this.nodes) {
if (node.hasInput) {
const { x, y } = node.input;
const radius = 8;
const dx = wx - x;
const dy = wy - y;
if (dx * dx + dy * dy < radius * radius) {
// Remove any existing connection to this node's input
for (let i = this.connections.length - 1; i >= 0; i--) {
if (this.connections[i].to === node) {
this.connections.splice(i, 1);
}
}
// Add the new connection
this.connections.push({
from: this.draggingConnection.fromNode,
to: node
});
break;
}
}
}
this.draggingConnection = null;
}
for (const node of this.nodes) {
for (const control of node.controls) {
control._ctx = this.ctx; // inject context if needed
const handled = control.onEvent?.("mouseUp", {
mx: wx,
my: wy,
parentX: node.x,
parentY: node.y
});
if (handled) return;
}
}
if (this.draggingNode) {
this.draggingNode.onDrag(false);
this.draggingNode = null;
}
for (const node of this.nodes) {
node.onSelect(false);
}
}
onDoubleClick(e) {
const { x: wx, y: wy } = this.screenToWorld(e.clientX, e.clientY);
// First: check if a node was double-clicked
for (let i = this.nodes.length - 1; i >= 0; i--) {
const node = this.nodes[i];
if (!node.canDelete){
continue;
}
const nodeBounds = {
x: node.x,
y: node.y,
width: node.width ?? 120, // fallback width
height: node.height ?? 60, // fallback height
};
if (
wx >= nodeBounds.x &&
wx <= nodeBounds.x + nodeBounds.width &&
wy >= nodeBounds.y &&
wy <= nodeBounds.y + nodeBounds.height
) {
console.log("Double-clicked node:", node);
this.nodes.splice(i, 1);
// Remove any connections involving this node
this.connections = this.connections.filter(
conn => conn.from !== node && conn.to !== node
);
return;
}
}
// Second: check if a connection was double-clicked
for (let i = this.connections.length - 1; i >= 0; i--) {
const conn = this.connections[i];
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;
for (let t = 0; t <= 1; t += 0.05) {
const px = this.bezierPoint(x1, x1 + dx, x2 - dx, x2, t);
const py = this.bezierPoint(y1, y1, y2, y2, t);
const dist = Math.hypot(wx - px, wy - py);
if (dist < 8) {
console.log("Double-clicked connection");
this.connections.splice(i, 1);
return;
}
}
}
}
onWheel(e) {
const { x: wx, y: wy } = this.screenToWorld(e.clientX, e.clientY);
// Check if any control is under the cursor
for (let i = this.nodes.length - 1; i >= 0; i--) {
const node = this.nodes[i];
for (const control of node.controls) {
if (control.contains?.(wx, wy, node.x, node.y)) {
const direction = e.deltaY < 0 ? 1 : -1;
control.onScroll?.(direction);
e.preventDefault(); // prevent zoom
return;
}
}
}
// No control captured scroll — apply zoom
e.preventDefault();
const zoomFactor = 1.1;
const mouseX = e.clientX;
const mouseY = e.clientY;
const canvasRect = this.canvas.getBoundingClientRect();
const x = (mouseX - canvasRect.left - this.offsetX) / this.zoom;
const y = (mouseY - canvasRect.top - this.offsetY) / this.zoom;
const prevZoom = this.zoom;
if (e.deltaY < 0) {
this.zoom *= zoomFactor;
} else {
this.zoom /= zoomFactor;
}
// Adjust offset to keep world-space point under cursor fixed
this.offsetX -= (wx * this.zoom - wx * prevZoom);
this.offsetY -= (wy * this.zoom - wy * prevZoom);
}
onContextMenu(e) {
e.preventDefault();
const { x: wx, y: wy } = this.screenToWorld(e.clientX, e.clientY);
const menu = document.createElement("div");
menu.className = "context-menu";
menu.style.position = "fixed";
menu.style.left = `${e.clientX}px`;
menu.style.top = `${e.clientY}px`; // drops down from cursor
menu.style.background = "#222";
menu.style.color = "#fff";
menu.style.padding = "4px 0";
menu.style.borderRadius = "6px";
menu.style.boxShadow = "0 4px 12px rgba(0,0,0,0.4)";
menu.style.zIndex = 1000;
menu.style.fontFamily = "sans-serif";
menu.style.minWidth = "160px";
const options = [
//{ label: "Add Servo", action: () => this.addNode(new ServoNode(wx, wy, 0)) },
//{ label: "Add Curve", action: () => this.addNode(new CurveNode(wx, wy, 0)) },
{ label: "Add Noise", action: () => this.addNode(new NoiseNode(wx, wy)) },
{ label: "Add Variable", action: () => this.addNode(new VariableNode(wx, wy)) },
{ label: "Add Math", action: () => this.addNode(new MathNode(wx, wy)) },
{ label: "Add Map", action: () => this.addNode(new MapNode(wx, wy)) },
];
options.forEach(opt => {
const item = document.createElement("div");
item.textContent = opt.label;
item.style.padding = "6px 12px";
item.style.cursor = "pointer";
item.style.transition = "background 0.2s ease";
item.addEventListener("mouseenter", () => {
item.style.background = "#444";
});
item.addEventListener("mouseleave", () => {
item.style.background = "transparent";
});
item.addEventListener("click", () => {
opt.action();
document.body.removeChild(menu);
});
menu.appendChild(item);
});
document.body.appendChild(menu);
const removeMenu = () => {
if (document.body.contains(menu)) {
document.body.removeChild(menu);
}
};
setTimeout(() => {
document.addEventListener("click", removeMenu, { once: true });
}, 0);
}
bezierPoint(p0, p1, p2, p3, t) {
const c0 = (1 - t) ** 3;
const c1 = 3 * (1 - t) ** 2 * t;
const c2 = 3 * (1 - t) * t ** 2;
const c3 = t ** 3;
return c0 * p0 + c1 * p1 + c2 * p2 + c3 * p3;
}
}