667 lines
18 KiB
JavaScript
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;
|
|
}
|
|
|
|
|
|
} |