html-overlay-node 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/example.json +522 -0
- package/dist/html-overlay-node.es.js +3596 -0
- package/dist/html-overlay-node.es.js.map +1 -0
- package/dist/html-overlay-node.umd.js +2 -0
- package/dist/html-overlay-node.umd.js.map +1 -0
- package/index.css +232 -0
- package/package.json +65 -0
- package/readme.md +437 -0
- package/src/core/CommandStack.js +26 -0
- package/src/core/Edge.js +28 -0
- package/src/core/Edge.test.js +73 -0
- package/src/core/Graph.js +267 -0
- package/src/core/Graph.test.js +256 -0
- package/src/core/Group.js +77 -0
- package/src/core/Hooks.js +12 -0
- package/src/core/Hooks.test.js +108 -0
- package/src/core/Node.js +70 -0
- package/src/core/Node.test.js +113 -0
- package/src/core/Registry.js +71 -0
- package/src/core/Registry.test.js +88 -0
- package/src/core/Runner.js +211 -0
- package/src/core/commands.js +125 -0
- package/src/groups/GroupManager.js +116 -0
- package/src/index.js +1030 -0
- package/src/interact/ContextMenu.js +400 -0
- package/src/interact/Controller.js +856 -0
- package/src/minimap/Minimap.js +146 -0
- package/src/render/CanvasRenderer.js +606 -0
- package/src/render/HtmlOverlay.js +161 -0
- package/src/render/hitTest.js +38 -0
- package/src/ui/PropertyPanel.css +277 -0
- package/src/ui/PropertyPanel.js +269 -0
- package/src/utils/utils.js +75 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { createHooks } from "./Hooks.js";
|
|
3
|
+
|
|
4
|
+
describe("Hooks", () => {
|
|
5
|
+
describe("createHooks", () => {
|
|
6
|
+
it("should create a hooks system with specified events", () => {
|
|
7
|
+
const hooks = createHooks(["test:event", "another:event"]);
|
|
8
|
+
expect(hooks).toBeDefined();
|
|
9
|
+
expect(typeof hooks.on).toBe("function");
|
|
10
|
+
expect(typeof hooks.off).toBe("function");
|
|
11
|
+
expect(typeof hooks.emit).toBe("function");
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe("on", () => {
|
|
16
|
+
it("should register an event listener", () => {
|
|
17
|
+
const hooks = createHooks(["test:event"]);
|
|
18
|
+
let called = false;
|
|
19
|
+
hooks.on("test:event", () => {
|
|
20
|
+
called = true;
|
|
21
|
+
});
|
|
22
|
+
hooks.emit("test:event");
|
|
23
|
+
expect(called).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("should register multiple listeners for same event", () => {
|
|
27
|
+
const hooks = createHooks(["test:event"]);
|
|
28
|
+
let count = 0;
|
|
29
|
+
hooks.on("test:event", () => count++);
|
|
30
|
+
hooks.on("test:event", () => count++);
|
|
31
|
+
hooks.emit("test:event");
|
|
32
|
+
expect(count).toBe(2);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("should pass data to listeners", () => {
|
|
36
|
+
const hooks = createHooks(["test:event"]);
|
|
37
|
+
let receivedData = null;
|
|
38
|
+
hooks.on("test:event", (data) => {
|
|
39
|
+
receivedData = data;
|
|
40
|
+
});
|
|
41
|
+
hooks.emit("test:event", { value: 42 });
|
|
42
|
+
expect(receivedData).toEqual({ value: 42 });
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("off", () => {
|
|
47
|
+
it("should unregister a specific listener", () => {
|
|
48
|
+
const hooks = createHooks(["test:event"]);
|
|
49
|
+
let count = 0;
|
|
50
|
+
const listener = () => count++;
|
|
51
|
+
|
|
52
|
+
hooks.on("test:event", listener);
|
|
53
|
+
hooks.emit("test:event");
|
|
54
|
+
expect(count).toBe(1);
|
|
55
|
+
|
|
56
|
+
hooks.off("test:event", listener);
|
|
57
|
+
hooks.emit("test:event");
|
|
58
|
+
expect(count).toBe(1); // Still 1, not 2
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("should not affect other listeners", () => {
|
|
62
|
+
const hooks = createHooks(["test:event"]);
|
|
63
|
+
let count1 = 0;
|
|
64
|
+
let count2 = 0;
|
|
65
|
+
const listener1 = () => count1++;
|
|
66
|
+
const listener2 = () => count2++;
|
|
67
|
+
|
|
68
|
+
hooks.on("test:event", listener1);
|
|
69
|
+
hooks.on("test:event", listener2);
|
|
70
|
+
|
|
71
|
+
hooks.off("test:event", listener1);
|
|
72
|
+
hooks.emit("test:event");
|
|
73
|
+
|
|
74
|
+
expect(count1).toBe(0);
|
|
75
|
+
expect(count2).toBe(1);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("emit", () => {
|
|
80
|
+
it("should call all registered listeners", () => {
|
|
81
|
+
const hooks = createHooks(["test:event"]);
|
|
82
|
+
const calls = [];
|
|
83
|
+
hooks.on("test:event", () => calls.push(1));
|
|
84
|
+
hooks.on("test:event", () => calls.push(2));
|
|
85
|
+
hooks.on("test:event", () => calls.push(3));
|
|
86
|
+
|
|
87
|
+
hooks.emit("test:event");
|
|
88
|
+
expect(calls).toEqual([1, 2, 3]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("should not throw when emitting event with no listeners", () => {
|
|
92
|
+
const hooks = createHooks(["test:event"]);
|
|
93
|
+
expect(() => {
|
|
94
|
+
hooks.emit("test:event");
|
|
95
|
+
}).not.toThrow();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("should pass multiple arguments to listeners", () => {
|
|
99
|
+
const hooks = createHooks(["test:event"]);
|
|
100
|
+
let receivedArgs = null;
|
|
101
|
+
hooks.on("test:event", (...args) => {
|
|
102
|
+
receivedArgs = args;
|
|
103
|
+
});
|
|
104
|
+
hooks.emit("test:event", "arg1", "arg2", 123);
|
|
105
|
+
expect(receivedArgs).toEqual(["arg1", "arg2", 123]);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
});
|
package/src/core/Node.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { randomUUID } from "../utils/utils.js";
|
|
2
|
+
|
|
3
|
+
// src/core/Node.js
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Node represents a single node in the graph
|
|
7
|
+
*/
|
|
8
|
+
export class Node {
|
|
9
|
+
/**
|
|
10
|
+
* Create a new Node
|
|
11
|
+
* @param {Object} options - Node configuration
|
|
12
|
+
* @param {string} [options.id] - Unique identifier (auto-generated if not provided)
|
|
13
|
+
* @param {string} options.type - Node type identifier
|
|
14
|
+
* @param {string} [options.title] - Display title (defaults to type)
|
|
15
|
+
* @param {number} [options.x=0] - X position
|
|
16
|
+
* @param {number} [options.y=0] - Y position
|
|
17
|
+
* @param {number} [options.width=160] - Node width
|
|
18
|
+
* @param {number} [options.height=60] - Node height
|
|
19
|
+
*/
|
|
20
|
+
constructor({ id, type, title, x = 0, y = 0, width = 160, height = 60 }) {
|
|
21
|
+
if (!type) {
|
|
22
|
+
throw new Error("Node type is required");
|
|
23
|
+
}
|
|
24
|
+
this.id = id ?? randomUUID();
|
|
25
|
+
this.type = type;
|
|
26
|
+
this.title = title ?? type;
|
|
27
|
+
this.pos = { x, y };
|
|
28
|
+
this.size = { width, height };
|
|
29
|
+
this.inputs = []; // {id,name,datatype,portType,dir}
|
|
30
|
+
this.outputs = []; // {id,name,datatype,portType,dir}
|
|
31
|
+
this.state = {}; // User state data
|
|
32
|
+
|
|
33
|
+
// Tree Structure
|
|
34
|
+
this.parent = null; // Parent Node (or null if root)
|
|
35
|
+
this.children = new Set(); // Set<Node>
|
|
36
|
+
this.computed = { x: 0, y: 0, w: 0, h: 0 }; // World Transform
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Add an input port to this node
|
|
41
|
+
* @param {string} name - Port name
|
|
42
|
+
* @param {string} [datatype="any"] - Data type for the port
|
|
43
|
+
* @param {string} [portType="data"] - Port type: "exec" or "data"
|
|
44
|
+
* @returns {Object} The created port
|
|
45
|
+
*/
|
|
46
|
+
addInput(name, datatype = "any", portType = "data") {
|
|
47
|
+
if (!name || typeof name !== "string") {
|
|
48
|
+
throw new Error("Input port name must be a non-empty string");
|
|
49
|
+
}
|
|
50
|
+
const port = { id: randomUUID(), name, datatype, portType, dir: "in" };
|
|
51
|
+
this.inputs.push(port);
|
|
52
|
+
return port;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Add an output port to this node
|
|
57
|
+
* @param {string} name - Port name
|
|
58
|
+
* @param {string} [datatype="any"] - Data type for the port
|
|
59
|
+
* @param {string} [portType="data"] - Port type: "exec" or "data"
|
|
60
|
+
* @returns {Object} The created port
|
|
61
|
+
*/
|
|
62
|
+
addOutput(name, datatype = "any", portType = "data") {
|
|
63
|
+
if (!name || typeof name !== "string") {
|
|
64
|
+
throw new Error("Output port name must be a non-empty string");
|
|
65
|
+
}
|
|
66
|
+
const port = { id: randomUUID(), name, datatype, portType, dir: "out" };
|
|
67
|
+
this.outputs.push(port);
|
|
68
|
+
return port;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import { Node } from "./Node.js";
|
|
3
|
+
|
|
4
|
+
describe("Node", () => {
|
|
5
|
+
describe("constructor", () => {
|
|
6
|
+
it("should create a node with default values", () => {
|
|
7
|
+
const node = new Node({ type: "test/node" });
|
|
8
|
+
expect(node.type).toBe("test/node");
|
|
9
|
+
expect(node.title).toBe("test/node");
|
|
10
|
+
expect(node.pos).toEqual({ x: 0, y: 0 });
|
|
11
|
+
expect(node.size).toEqual({ width: 160, height: 60 });
|
|
12
|
+
expect(node.inputs).toEqual([]);
|
|
13
|
+
expect(node.outputs).toEqual([]);
|
|
14
|
+
expect(node.state).toEqual({});
|
|
15
|
+
expect(node.id).toBeDefined();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("should create a node with custom values", () => {
|
|
19
|
+
const node = new Node({
|
|
20
|
+
id: "custom-id",
|
|
21
|
+
type: "test/node",
|
|
22
|
+
title: "Custom Title",
|
|
23
|
+
x: 100,
|
|
24
|
+
y: 200,
|
|
25
|
+
width: 300,
|
|
26
|
+
height: 400,
|
|
27
|
+
});
|
|
28
|
+
expect(node.id).toBe("custom-id");
|
|
29
|
+
expect(node.type).toBe("test/node");
|
|
30
|
+
expect(node.title).toBe("Custom Title");
|
|
31
|
+
expect(node.pos).toEqual({ x: 100, y: 200 });
|
|
32
|
+
expect(node.size).toEqual({ width: 300, height: 400 });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("should throw error when type is missing", () => {
|
|
36
|
+
expect(() => {
|
|
37
|
+
new Node({});
|
|
38
|
+
}).toThrow(/type is required/);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("should initialize tree structure", () => {
|
|
42
|
+
const node = new Node({ type: "test/node" });
|
|
43
|
+
expect(node.parent).toBe(null);
|
|
44
|
+
expect(node.children).toBeInstanceOf(Set);
|
|
45
|
+
expect(node.children.size).toBe(0);
|
|
46
|
+
expect(node.computed).toEqual({ x: 0, y: 0, w: 0, h: 0 });
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("addInput", () => {
|
|
51
|
+
let node;
|
|
52
|
+
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
node = new Node({ type: "test/node" });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("should add an input port", () => {
|
|
58
|
+
const port = node.addInput("test", "number");
|
|
59
|
+
expect(port.name).toBe("test");
|
|
60
|
+
expect(port.datatype).toBe("number");
|
|
61
|
+
expect(port.dir).toBe("in");
|
|
62
|
+
expect(port.id).toBeDefined();
|
|
63
|
+
expect(node.inputs).toContain(port);
|
|
64
|
+
expect(node.inputs.length).toBe(1);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("should default datatype to 'any'", () => {
|
|
68
|
+
const port = node.addInput("test");
|
|
69
|
+
expect(port.datatype).toBe("any");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("should throw error when name is missing", () => {
|
|
73
|
+
expect(() => {
|
|
74
|
+
node.addInput("");
|
|
75
|
+
}).toThrow(/must be a non-empty string/);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("should throw error when name is not a string", () => {
|
|
79
|
+
expect(() => {
|
|
80
|
+
node.addInput(null);
|
|
81
|
+
}).toThrow(/must be a non-empty string/);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("addOutput", () => {
|
|
86
|
+
let node;
|
|
87
|
+
|
|
88
|
+
beforeEach(() => {
|
|
89
|
+
node = new Node({ type: "test/node" });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("should add an output port", () => {
|
|
93
|
+
const port = node.addOutput("result", "string");
|
|
94
|
+
expect(port.name).toBe("result");
|
|
95
|
+
expect(port.datatype).toBe("string");
|
|
96
|
+
expect(port.dir).toBe("out");
|
|
97
|
+
expect(port.id).toBeDefined();
|
|
98
|
+
expect(node.outputs).toContain(port);
|
|
99
|
+
expect(node.outputs.length).toBe(1);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("should default datatype to 'any'", () => {
|
|
103
|
+
const port = node.addOutput("result");
|
|
104
|
+
expect(port.datatype).toBe("any");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("should throw error when name is missing", () => {
|
|
108
|
+
expect(() => {
|
|
109
|
+
node.addOutput("");
|
|
110
|
+
}).toThrow(/must be a non-empty string/);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// src/core/Registry.js
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Registry for managing node type definitions
|
|
5
|
+
*/
|
|
6
|
+
export class Registry {
|
|
7
|
+
constructor() {
|
|
8
|
+
this.types = new Map();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Register a new node type
|
|
13
|
+
* @param {string} type - Unique type identifier (e.g., "core/Note")
|
|
14
|
+
* @param {Object} def - Node definition
|
|
15
|
+
* @param {string} [def.title] - Display title
|
|
16
|
+
* @param {Object} [def.size] - Default size {w, h}
|
|
17
|
+
* @param {Array} [def.inputs] - Input port definitions
|
|
18
|
+
* @param {Array} [def.outputs] - Output port definitions
|
|
19
|
+
* @param {Function} [def.onCreate] - Called when node is created
|
|
20
|
+
* @param {Function} [def.onExecute] - Called each execution cycle
|
|
21
|
+
* @param {Function} [def.onDraw] - Custom drawing function
|
|
22
|
+
* @param {Object} [def.html] - HTML overlay configuration
|
|
23
|
+
* @throws {Error} If type is already registered or invalid
|
|
24
|
+
*/
|
|
25
|
+
register(type, def) {
|
|
26
|
+
if (!type || typeof type !== 'string') {
|
|
27
|
+
throw new Error(`Invalid node type: type must be a non-empty string, got ${typeof type}`);
|
|
28
|
+
}
|
|
29
|
+
if (!def || typeof def !== 'object') {
|
|
30
|
+
throw new Error(`Invalid definition for type "${type}": definition must be an object`);
|
|
31
|
+
}
|
|
32
|
+
if (this.types.has(type)) {
|
|
33
|
+
throw new Error(`Node type "${type}" is already registered. Use unregister() first to replace it.`);
|
|
34
|
+
}
|
|
35
|
+
this.types.set(type, def);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Unregister a node type
|
|
40
|
+
* @param {string} type - Type identifier to unregister
|
|
41
|
+
* @throws {Error} If type doesn't exist
|
|
42
|
+
*/
|
|
43
|
+
unregister(type) {
|
|
44
|
+
if (!this.types.has(type)) {
|
|
45
|
+
throw new Error(`Cannot unregister type "${type}": type is not registered`);
|
|
46
|
+
}
|
|
47
|
+
this.types.delete(type);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Remove all registered node types
|
|
52
|
+
*/
|
|
53
|
+
removeAll() {
|
|
54
|
+
this.types.clear();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Get the definition for a registered node type
|
|
59
|
+
* @param {string} type - Type identifier
|
|
60
|
+
* @returns {Object} Node definition
|
|
61
|
+
* @throws {Error} If type is not registered
|
|
62
|
+
*/
|
|
63
|
+
createInstance(type) {
|
|
64
|
+
const def = this.types.get(type);
|
|
65
|
+
if (!def) {
|
|
66
|
+
const available = Array.from(this.types.keys()).join(', ') || 'none';
|
|
67
|
+
throw new Error(`Unknown node type: "${type}". Available types: ${available}`);
|
|
68
|
+
}
|
|
69
|
+
return def;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import { Registry } from "./Registry.js";
|
|
3
|
+
|
|
4
|
+
describe("Registry", () => {
|
|
5
|
+
let registry;
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
registry = new Registry();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
describe("register", () => {
|
|
12
|
+
it("should register a new node type", () => {
|
|
13
|
+
const def = { title: "Test Node", size: { w: 100, h: 50 } };
|
|
14
|
+
registry.register("test/node", def);
|
|
15
|
+
expect(registry.types.has("test/node")).toBe(true);
|
|
16
|
+
expect(registry.types.get("test/node")).toBe(def);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("should throw error when registering duplicate type", () => {
|
|
20
|
+
const def = { title: "Test" };
|
|
21
|
+
registry.register("test/node", def);
|
|
22
|
+
expect(() => {
|
|
23
|
+
registry.register("test/node", def);
|
|
24
|
+
}).toThrow(/already registered/);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("should throw error when type is not a string", () => {
|
|
28
|
+
expect(() => {
|
|
29
|
+
registry.register(null, {});
|
|
30
|
+
}).toThrow(/must be a non-empty string/);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("should throw error when definition is not an object", () => {
|
|
34
|
+
expect(() => {
|
|
35
|
+
registry.register("test/node", null);
|
|
36
|
+
}).toThrow(/must be an object/);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("unregister", () => {
|
|
41
|
+
it("should unregister an existing type", () => {
|
|
42
|
+
const def = { title: "Test" };
|
|
43
|
+
registry.register("test/node", def);
|
|
44
|
+
registry.unregister("test/node");
|
|
45
|
+
expect(registry.types.has("test/node")).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("should throw error when unregistering non-existent type", () => {
|
|
49
|
+
expect(() => {
|
|
50
|
+
registry.unregister("non/existent");
|
|
51
|
+
}).toThrow(/is not registered/);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("removeAll", () => {
|
|
56
|
+
it("should remove all registered types", () => {
|
|
57
|
+
registry.register("test/a", { title: "A" });
|
|
58
|
+
registry.register("test/b", { title: "B" });
|
|
59
|
+
expect(registry.types.size).toBe(2);
|
|
60
|
+
|
|
61
|
+
registry.removeAll();
|
|
62
|
+
expect(registry.types.size).toBe(0);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("createInstance", () => {
|
|
67
|
+
it("should return the definition for a registered type", () => {
|
|
68
|
+
const def = { title: "Test" };
|
|
69
|
+
registry.register("test/node", def);
|
|
70
|
+
const instance = registry.createInstance("test/node");
|
|
71
|
+
expect(instance).toBe(def);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("should throw error for unknown type with available types listed", () => {
|
|
75
|
+
registry.register("test/a", { title: "A" });
|
|
76
|
+
registry.register("test/b", { title: "B" });
|
|
77
|
+
expect(() => {
|
|
78
|
+
registry.createInstance("unknown");
|
|
79
|
+
}).toThrow(/Available types: test\/a, test\/b/);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("should show 'none' when no types are available", () => {
|
|
83
|
+
expect(() => {
|
|
84
|
+
registry.createInstance("unknown");
|
|
85
|
+
}).toThrow(/Available types: none/);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
export class Runner {
|
|
2
|
+
constructor({ graph, registry, hooks, cyclesPerFrame = 1 }) {
|
|
3
|
+
this.graph = graph;
|
|
4
|
+
this.registry = registry;
|
|
5
|
+
this.hooks = hooks;
|
|
6
|
+
this.running = false;
|
|
7
|
+
this._raf = null;
|
|
8
|
+
this._last = 0;
|
|
9
|
+
this.cyclesPerFrame = Math.max(1, cyclesPerFrame | 0);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// 외부에서 실행 중인지 확인
|
|
13
|
+
isRunning() {
|
|
14
|
+
return this.running;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// 실행 도중에도 CPS 변경 가능
|
|
18
|
+
setCyclesPerFrame(n) {
|
|
19
|
+
this.cyclesPerFrame = Math.max(1, n | 0);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
step(cycles = 1, dt = 0) {
|
|
23
|
+
const nCycles = Math.max(1, cycles | 0);
|
|
24
|
+
for (let c = 0; c < nCycles; c++) {
|
|
25
|
+
for (const node of this.graph.nodes.values()) {
|
|
26
|
+
const def = this.registry.types.get(node.type);
|
|
27
|
+
if (def?.onExecute) {
|
|
28
|
+
try {
|
|
29
|
+
def.onExecute(node, {
|
|
30
|
+
dt,
|
|
31
|
+
graph: this.graph,
|
|
32
|
+
getInput: (portName) => {
|
|
33
|
+
const p =
|
|
34
|
+
node.inputs.find((i) => i.name === portName) ||
|
|
35
|
+
node.inputs[0];
|
|
36
|
+
return p ? this.graph.getInput(node.id, p.id) : undefined;
|
|
37
|
+
},
|
|
38
|
+
setOutput: (portName, value) => {
|
|
39
|
+
const p =
|
|
40
|
+
node.outputs.find((o) => o.name === portName) ||
|
|
41
|
+
node.outputs[0];
|
|
42
|
+
if (p) this.graph.setOutput(node.id, p.id, value);
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
} catch (err) {
|
|
46
|
+
this.hooks?.emit?.("error", err);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// commit writes for this cycle
|
|
51
|
+
this.graph.swapBuffers();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Execute connected nodes once from a starting node
|
|
57
|
+
* @param {string} startNodeId - ID of the node to start from
|
|
58
|
+
* @param {number} dt - Delta time
|
|
59
|
+
*/
|
|
60
|
+
runOnce(startNodeId, dt = 0) {
|
|
61
|
+
console.log("[Runner.runOnce] Starting exec flow from node:", startNodeId);
|
|
62
|
+
|
|
63
|
+
const executedNodes = [];
|
|
64
|
+
const allConnectedNodes = new Set();
|
|
65
|
+
let currentNodeId = startNodeId;
|
|
66
|
+
|
|
67
|
+
// Follow exec flow
|
|
68
|
+
while (currentNodeId) {
|
|
69
|
+
const node = this.graph.nodes.get(currentNodeId);
|
|
70
|
+
if (!node) {
|
|
71
|
+
console.warn(`[Runner.runOnce] Node not found: ${currentNodeId}`);
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
executedNodes.push(currentNodeId);
|
|
76
|
+
allConnectedNodes.add(currentNodeId);
|
|
77
|
+
console.log(`[Runner.runOnce] Executing: ${node.title} (${node.type})`);
|
|
78
|
+
|
|
79
|
+
// Find and add data dependency nodes (nodes providing input data)
|
|
80
|
+
for (const input of node.inputs) {
|
|
81
|
+
if (input.portType === "data") {
|
|
82
|
+
// Find edge feeding this data input
|
|
83
|
+
for (const edge of this.graph.edges.values()) {
|
|
84
|
+
if (edge.toNode === currentNodeId && edge.toPort === input.id) {
|
|
85
|
+
const sourceNode = this.graph.nodes.get(edge.fromNode);
|
|
86
|
+
if (sourceNode && !allConnectedNodes.has(edge.fromNode)) {
|
|
87
|
+
allConnectedNodes.add(edge.fromNode);
|
|
88
|
+
// Execute data source node before current node
|
|
89
|
+
this.executeNode(edge.fromNode, dt);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Execute current node
|
|
97
|
+
this.executeNode(currentNodeId, dt);
|
|
98
|
+
|
|
99
|
+
// Find next node via exec output
|
|
100
|
+
currentNodeId = this.findNextExecNode(currentNodeId);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
console.log("[Runner.runOnce] Executed nodes:", executedNodes.length);
|
|
104
|
+
|
|
105
|
+
// Find all edges involved (both exec and data)
|
|
106
|
+
const connectedEdges = new Set();
|
|
107
|
+
for (const edge of this.graph.edges.values()) {
|
|
108
|
+
if (allConnectedNodes.has(edge.fromNode) && allConnectedNodes.has(edge.toNode)) {
|
|
109
|
+
connectedEdges.add(edge.id);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
console.log("[Runner.runOnce] Connected edges count:", connectedEdges.size);
|
|
114
|
+
return { connectedNodes: allConnectedNodes, connectedEdges };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Find the next node to execute by following exec output
|
|
119
|
+
* @param {string} nodeId - Current node ID
|
|
120
|
+
* @returns {string|null} Next node ID or null
|
|
121
|
+
*/
|
|
122
|
+
findNextExecNode(nodeId) {
|
|
123
|
+
const node = this.graph.nodes.get(nodeId);
|
|
124
|
+
if (!node) return null;
|
|
125
|
+
|
|
126
|
+
// Find exec output port
|
|
127
|
+
const execOutput = node.outputs.find(p => p.portType === "exec");
|
|
128
|
+
if (!execOutput) return null;
|
|
129
|
+
|
|
130
|
+
// Find edge from exec output
|
|
131
|
+
for (const edge of this.graph.edges.values()) {
|
|
132
|
+
if (edge.fromNode === nodeId && edge.fromPort === execOutput.id) {
|
|
133
|
+
return edge.toNode;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Execute a single node
|
|
142
|
+
* @param {string} nodeId - Node ID to execute
|
|
143
|
+
* @param {number} dt - Delta time
|
|
144
|
+
*/
|
|
145
|
+
executeNode(nodeId, dt) {
|
|
146
|
+
const node = this.graph.nodes.get(nodeId);
|
|
147
|
+
if (!node) return;
|
|
148
|
+
|
|
149
|
+
const def = this.registry.types.get(node.type);
|
|
150
|
+
if (!def?.onExecute) return;
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
def.onExecute(node, {
|
|
154
|
+
dt,
|
|
155
|
+
graph: this.graph,
|
|
156
|
+
getInput: (portName) => {
|
|
157
|
+
const p = node.inputs.find((i) => i.name === portName) || node.inputs[0];
|
|
158
|
+
return p ? this.graph.getInput(node.id, p.id) : undefined;
|
|
159
|
+
},
|
|
160
|
+
setOutput: (portName, value) => {
|
|
161
|
+
const p = node.outputs.find((o) => o.name === portName) || node.outputs[0];
|
|
162
|
+
if (p) {
|
|
163
|
+
// Write directly to current buffer so other nodes can read it immediately
|
|
164
|
+
const key = `${node.id}:${p.id}`;
|
|
165
|
+
this.graph._curBuf().set(key, value);
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
} catch (err) {
|
|
170
|
+
this.hooks?.emit?.("error", err);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
start() {
|
|
175
|
+
if (this.running) return;
|
|
176
|
+
this.running = true;
|
|
177
|
+
this._last = 0;
|
|
178
|
+
this.hooks?.emit?.("runner:start");
|
|
179
|
+
|
|
180
|
+
const loop = (t) => {
|
|
181
|
+
if (!this.running) return;
|
|
182
|
+
const dtMs = this._last ? t - this._last : 0;
|
|
183
|
+
this._last = t;
|
|
184
|
+
const dt = dtMs / 1000; // seconds
|
|
185
|
+
|
|
186
|
+
// 1) 스텝 실행
|
|
187
|
+
this.step(this.cyclesPerFrame, dt);
|
|
188
|
+
|
|
189
|
+
// 2) 프레임 훅 (렌더러/컨트롤러는 여기서 running, time, dt를 받아 표현 업데이트)
|
|
190
|
+
this.hooks?.emit?.("runner:tick", {
|
|
191
|
+
time: t,
|
|
192
|
+
dt,
|
|
193
|
+
running: true,
|
|
194
|
+
cps: this.cyclesPerFrame,
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
this._raf = requestAnimationFrame(loop);
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
this._raf = requestAnimationFrame(loop);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
stop() {
|
|
204
|
+
if (!this.running) return;
|
|
205
|
+
this.running = false;
|
|
206
|
+
if (this._raf) cancelAnimationFrame(this._raf);
|
|
207
|
+
this._raf = null;
|
|
208
|
+
this._last = 0;
|
|
209
|
+
this.hooks?.emit?.("runner:stop");
|
|
210
|
+
}
|
|
211
|
+
}
|