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,267 @@
|
|
|
1
|
+
import { Node } from "./Node.js";
|
|
2
|
+
import { Edge } from "./Edge.js";
|
|
3
|
+
import { GroupManager } from "../groups/GroupManager.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Graph manages the collection of nodes and edges
|
|
7
|
+
*/
|
|
8
|
+
export class Graph {
|
|
9
|
+
/**
|
|
10
|
+
* Create a new Graph
|
|
11
|
+
* @param {Object} options - Graph configuration
|
|
12
|
+
* @param {Object} options.hooks - Event hooks system
|
|
13
|
+
* @param {Object} options.registry - Node type registry
|
|
14
|
+
*/
|
|
15
|
+
constructor({ hooks, registry }) {
|
|
16
|
+
if (!registry) {
|
|
17
|
+
throw new Error("Graph requires a registry");
|
|
18
|
+
}
|
|
19
|
+
this.nodes = new Map();
|
|
20
|
+
this.edges = new Map();
|
|
21
|
+
this.hooks = hooks;
|
|
22
|
+
this.registry = registry;
|
|
23
|
+
// double buffer for deterministic cycles
|
|
24
|
+
this._valuesA = new Map(); // current
|
|
25
|
+
this._valuesB = new Map(); // next
|
|
26
|
+
this._useAasCurrent = true;
|
|
27
|
+
|
|
28
|
+
this.groupManager = new GroupManager({
|
|
29
|
+
graph: this,
|
|
30
|
+
hooks: this.hooks,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Get a node by its ID
|
|
35
|
+
* @param {string} id - Node ID
|
|
36
|
+
* @returns {Node|null} The node or null if not found
|
|
37
|
+
*/
|
|
38
|
+
getNodeById(id) {
|
|
39
|
+
return this.nodes.get(id) || null;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Add a node to the graph
|
|
43
|
+
* @param {string} type - Node type identifier
|
|
44
|
+
* @param {Object} [opts={}] - Additional node options (x, y, width, height, etc.)
|
|
45
|
+
* @returns {Node} The created node
|
|
46
|
+
* @throws {Error} If node type is not registered
|
|
47
|
+
*/
|
|
48
|
+
addNode(type, opts = {}) {
|
|
49
|
+
const def = this.registry.types.get(type);
|
|
50
|
+
if (!def) {
|
|
51
|
+
const available = Array.from(this.registry.types.keys()).join(", ") || "none";
|
|
52
|
+
throw new Error(`Unknown node type: "${type}". Available types: ${available}`);
|
|
53
|
+
}
|
|
54
|
+
const node = new Node({
|
|
55
|
+
type,
|
|
56
|
+
title: def.title,
|
|
57
|
+
width: def.size?.w,
|
|
58
|
+
height: def.size?.h,
|
|
59
|
+
...opts,
|
|
60
|
+
});
|
|
61
|
+
for (const i of def.inputs || []) node.addInput(i.name, i.datatype, i.portType || "data");
|
|
62
|
+
for (const o of def.outputs || []) node.addOutput(o.name, o.datatype, o.portType || "data");
|
|
63
|
+
def.onCreate?.(node);
|
|
64
|
+
this.nodes.set(node.id, node);
|
|
65
|
+
this.hooks?.emit("node:create", node);
|
|
66
|
+
return node;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Remove a node and its connected edges from the graph
|
|
70
|
+
* @param {string} nodeId - ID of the node to remove
|
|
71
|
+
*/
|
|
72
|
+
removeNode(nodeId) {
|
|
73
|
+
// Remove all edges connected to this node
|
|
74
|
+
for (const [eid, e] of this.edges) {
|
|
75
|
+
if (e.fromNode === nodeId || e.toNode === nodeId) {
|
|
76
|
+
this.edges.delete(eid);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
this.nodes.delete(nodeId);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Add an edge connecting two node ports
|
|
83
|
+
* @param {string} fromNode - Source node ID
|
|
84
|
+
* @param {string} fromPort - Source port ID
|
|
85
|
+
* @param {string} toNode - Target node ID
|
|
86
|
+
* @param {string} toPort - Target port ID
|
|
87
|
+
* @returns {Edge} The created edge
|
|
88
|
+
* @throws {Error} If nodes don't exist
|
|
89
|
+
*/
|
|
90
|
+
addEdge(fromNode, fromPort, toNode, toPort) {
|
|
91
|
+
// Validate nodes exist
|
|
92
|
+
if (!this.nodes.has(fromNode)) {
|
|
93
|
+
throw new Error(`Cannot create edge: source node "${fromNode}" not found`);
|
|
94
|
+
}
|
|
95
|
+
if (!this.nodes.has(toNode)) {
|
|
96
|
+
throw new Error(`Cannot create edge: target node "${toNode}" not found`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const e = new Edge({ fromNode, fromPort, toNode, toPort });
|
|
100
|
+
this.edges.set(e.id, e);
|
|
101
|
+
this.hooks?.emit("edge:create", e);
|
|
102
|
+
return e;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Clear all nodes and edges from the graph
|
|
107
|
+
*/
|
|
108
|
+
clear() {
|
|
109
|
+
this.nodes.clear();
|
|
110
|
+
this.edges.clear();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
updateWorldTransforms() {
|
|
114
|
+
// 1. Find roots
|
|
115
|
+
const roots = [];
|
|
116
|
+
for (const n of this.nodes.values()) {
|
|
117
|
+
if (!n.parent) roots.push(n);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 2. Traverse
|
|
121
|
+
const stack = roots.map((n) => ({ node: n, px: 0, py: 0 }));
|
|
122
|
+
while (stack.length > 0) {
|
|
123
|
+
const { node, px, py } = stack.pop();
|
|
124
|
+
node.computed.x = px + node.pos.x;
|
|
125
|
+
node.computed.y = py + node.pos.y;
|
|
126
|
+
node.computed.w = node.size.width;
|
|
127
|
+
node.computed.h = node.size.height;
|
|
128
|
+
|
|
129
|
+
for (const child of node.children) {
|
|
130
|
+
stack.push({ node: child, px: node.computed.x, py: node.computed.y });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
reparent(node, newParent) {
|
|
136
|
+
if (node.parent === newParent) return;
|
|
137
|
+
|
|
138
|
+
// 1. Calculate current world pos
|
|
139
|
+
const wx = node.computed.x;
|
|
140
|
+
const wy = node.computed.y;
|
|
141
|
+
|
|
142
|
+
// 2. Remove from old parent
|
|
143
|
+
if (node.parent) {
|
|
144
|
+
node.parent.children.delete(node);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 3. Add to new parent
|
|
148
|
+
node.parent = newParent;
|
|
149
|
+
if (newParent) {
|
|
150
|
+
newParent.children.add(node);
|
|
151
|
+
// 4. Calculate new local pos
|
|
152
|
+
// world = parentWorld + local => local = world - parentWorld
|
|
153
|
+
node.pos.x = wx - newParent.computed.x;
|
|
154
|
+
node.pos.y = wy - newParent.computed.y;
|
|
155
|
+
} else {
|
|
156
|
+
// Root
|
|
157
|
+
node.pos.x = wx;
|
|
158
|
+
node.pos.y = wy;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
this.updateWorldTransforms();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// buffer helpers
|
|
165
|
+
_curBuf() {
|
|
166
|
+
return this._useAasCurrent ? this._valuesA : this._valuesB;
|
|
167
|
+
}
|
|
168
|
+
_nextBuf() {
|
|
169
|
+
return this._useAasCurrent ? this._valuesB : this._valuesA;
|
|
170
|
+
}
|
|
171
|
+
swapBuffers() {
|
|
172
|
+
// when moving to next cycle, promote next->current and clear next
|
|
173
|
+
this._useAasCurrent = !this._useAasCurrent;
|
|
174
|
+
this._nextBuf().clear();
|
|
175
|
+
}
|
|
176
|
+
// data helpers
|
|
177
|
+
setOutput(nodeId, portId, value) {
|
|
178
|
+
console.log(`[Graph.setOutput] nodeId: ${nodeId}, portId: ${portId}, value:`, value);
|
|
179
|
+
const key = `${nodeId}:${portId}`;
|
|
180
|
+
this._nextBuf().set(key, value);
|
|
181
|
+
}
|
|
182
|
+
getInput(nodeId, portId) {
|
|
183
|
+
// Find incoming edge to this port
|
|
184
|
+
for (const edge of this.edges.values()) {
|
|
185
|
+
if (edge.toNode === nodeId && edge.toPort === portId) {
|
|
186
|
+
const key = `${edge.fromNode}:${edge.fromPort}`;
|
|
187
|
+
const value = this._curBuf().get(key);
|
|
188
|
+
console.log(`[Graph.getInput] nodeId: ${nodeId}, portId: ${portId}, reading from ${edge.fromNode}:${edge.fromPort}, value:`, value);
|
|
189
|
+
return value;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
console.log(`[Graph.getInput] nodeId: ${nodeId}, portId: ${portId}, no edge found, returning undefined`);
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
toJSON() {
|
|
196
|
+
const json = {
|
|
197
|
+
nodes: [...this.nodes.values()].map((n) => ({
|
|
198
|
+
id: n.id,
|
|
199
|
+
type: n.type,
|
|
200
|
+
title: n.title,
|
|
201
|
+
x: n.pos.x,
|
|
202
|
+
y: n.pos.y,
|
|
203
|
+
w: n.size.width,
|
|
204
|
+
h: n.size.height,
|
|
205
|
+
inputs: n.inputs,
|
|
206
|
+
outputs: n.outputs,
|
|
207
|
+
state: n.state,
|
|
208
|
+
parentId: n.parent?.id || null, // Save parent relationship
|
|
209
|
+
})),
|
|
210
|
+
edges: [...this.edges.values()],
|
|
211
|
+
};
|
|
212
|
+
this.hooks?.emit("graph:serialize", json);
|
|
213
|
+
return json;
|
|
214
|
+
}
|
|
215
|
+
fromJSON(json) {
|
|
216
|
+
this.clear();
|
|
217
|
+
|
|
218
|
+
// Restore nodes first
|
|
219
|
+
for (const nd of json.nodes) {
|
|
220
|
+
const node = new Node({
|
|
221
|
+
id: nd.id,
|
|
222
|
+
type: nd.type,
|
|
223
|
+
title: nd.title,
|
|
224
|
+
x: nd.x,
|
|
225
|
+
y: nd.y,
|
|
226
|
+
width: nd.w,
|
|
227
|
+
height: nd.h,
|
|
228
|
+
});
|
|
229
|
+
// Call onCreate to initialize node with defaults first
|
|
230
|
+
const def = this.registry?.types?.get(nd.type);
|
|
231
|
+
if (def?.onCreate) {
|
|
232
|
+
def.onCreate(node);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
node.inputs = nd.inputs;
|
|
236
|
+
node.outputs = nd.outputs;
|
|
237
|
+
// Merge loaded state over defaults
|
|
238
|
+
node.state = { ...node.state, ...(nd.state || {}) };
|
|
239
|
+
|
|
240
|
+
this.nodes.set(node.id, node);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Restore parent-child relationships
|
|
244
|
+
for (const nd of json.nodes) {
|
|
245
|
+
if (nd.parentId) {
|
|
246
|
+
const node = this.nodes.get(nd.id);
|
|
247
|
+
const parent = this.nodes.get(nd.parentId);
|
|
248
|
+
if (node && parent) {
|
|
249
|
+
node.parent = parent;
|
|
250
|
+
parent.children.add(node);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Restore edges
|
|
256
|
+
for (const ed of json.edges) {
|
|
257
|
+
this.edges.set(ed.id, new Edge(ed));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Update world transforms to calculate proper positions
|
|
261
|
+
this.updateWorldTransforms();
|
|
262
|
+
|
|
263
|
+
this.hooks?.emit("graph:deserialize", json);
|
|
264
|
+
|
|
265
|
+
return this;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import { Graph } from "./Graph.js";
|
|
3
|
+
import { Registry } from "./Registry.js";
|
|
4
|
+
import { createHooks } from "./Hooks.js";
|
|
5
|
+
|
|
6
|
+
describe("Graph", () => {
|
|
7
|
+
let graph;
|
|
8
|
+
let registry;
|
|
9
|
+
let hooks;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
registry = new Registry();
|
|
13
|
+
hooks = createHooks(["node:create", "edge:create"]);
|
|
14
|
+
graph = new Graph({ hooks, registry });
|
|
15
|
+
|
|
16
|
+
// Register a test node type
|
|
17
|
+
registry.register("test/node", {
|
|
18
|
+
title: "Test Node",
|
|
19
|
+
size: { w: 100, h: 50 },
|
|
20
|
+
inputs: [{ name: "in", datatype: "any" }],
|
|
21
|
+
outputs: [{ name: "out", datatype: "any" }],
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("constructor", () => {
|
|
26
|
+
it("should create a graph with empty nodes and edges", () => {
|
|
27
|
+
expect(graph.nodes).toBeInstanceOf(Map);
|
|
28
|
+
expect(graph.edges).toBeInstanceOf(Map);
|
|
29
|
+
expect(graph.nodes.size).toBe(0);
|
|
30
|
+
expect(graph.edges.size).toBe(0);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("should throw error when registry is missing", () => {
|
|
34
|
+
expect(() => {
|
|
35
|
+
new Graph({ hooks });
|
|
36
|
+
}).toThrow(/requires a registry/);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("should initialize GroupManager", () => {
|
|
40
|
+
expect(graph.groupManager).toBeDefined();
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("addNode", () => {
|
|
45
|
+
it("should add a node to the graph", () => {
|
|
46
|
+
const node = graph.addNode("test/node", { x: 100, y: 200 });
|
|
47
|
+
expect(node).toBeDefined();
|
|
48
|
+
expect(node.type).toBe("test/node");
|
|
49
|
+
expect(node.title).toBe("Test Node");
|
|
50
|
+
expect(node.pos.x).toBe(100);
|
|
51
|
+
expect(node.pos.y).toBe(200);
|
|
52
|
+
expect(graph.nodes.has(node.id)).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("should add inputs and outputs from definition", () => {
|
|
56
|
+
const node = graph.addNode("test/node");
|
|
57
|
+
expect(node.inputs.length).toBe(1);
|
|
58
|
+
expect(node.inputs[0].name).toBe("in");
|
|
59
|
+
expect(node.outputs.length).toBe(1);
|
|
60
|
+
expect(node.outputs[0].name).toBe("out");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("should throw error for unknown node type with available types", () => {
|
|
64
|
+
expect(() => {
|
|
65
|
+
graph.addNode("unknown/type");
|
|
66
|
+
}).toThrow(/Available types: test\/node/);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("should emit node:create event", () => {
|
|
70
|
+
let emittedNode = null;
|
|
71
|
+
hooks.on("node:create", (node) => {
|
|
72
|
+
emittedNode = node;
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const node = graph.addNode("test/node");
|
|
76
|
+
expect(emittedNode).toBe(node);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("should call onCreate callback if defined", () => {
|
|
80
|
+
let onCreateCalled = false;
|
|
81
|
+
registry.register("test/with-oncreate", {
|
|
82
|
+
title: "Test",
|
|
83
|
+
onCreate: (node) => {
|
|
84
|
+
onCreateCalled = true;
|
|
85
|
+
node.state.initialized = true;
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const node = graph.addNode("test/with-oncreate");
|
|
90
|
+
expect(onCreateCalled).toBe(true);
|
|
91
|
+
expect(node.state.initialized).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("getNodeById", () => {
|
|
96
|
+
it("should return node by ID", () => {
|
|
97
|
+
const node = graph.addNode("test/node");
|
|
98
|
+
const found = graph.getNodeById(node.id);
|
|
99
|
+
expect(found).toBe(node);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("should return null for non-existent ID", () => {
|
|
103
|
+
const found = graph.getNodeById("non-existent");
|
|
104
|
+
expect(found).toBe(null);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("removeNode", () => {
|
|
109
|
+
it("should remove a node from the graph", () => {
|
|
110
|
+
const node = graph.addNode("test/node");
|
|
111
|
+
graph.removeNode(node.id);
|
|
112
|
+
expect(graph.nodes.has(node.id)).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("should remove edges connected to the node", () => {
|
|
116
|
+
const node1 = graph.addNode("test/node");
|
|
117
|
+
const node2 = graph.addNode("test/node");
|
|
118
|
+
|
|
119
|
+
const edge = graph.addEdge(
|
|
120
|
+
node1.id,
|
|
121
|
+
node1.outputs[0].id,
|
|
122
|
+
node2.id,
|
|
123
|
+
node2.inputs[0].id
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
graph.removeNode(node1.id);
|
|
127
|
+
expect(graph.edges.has(edge.id)).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("addEdge", () => {
|
|
132
|
+
it("should add an edge between two nodes", () => {
|
|
133
|
+
const node1 = graph.addNode("test/node");
|
|
134
|
+
const node2 = graph.addNode("test/node");
|
|
135
|
+
|
|
136
|
+
const edge = graph.addEdge(
|
|
137
|
+
node1.id,
|
|
138
|
+
node1.outputs[0].id,
|
|
139
|
+
node2.id,
|
|
140
|
+
node2.inputs[0].id
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
expect(edge).toBeDefined();
|
|
144
|
+
expect(edge.fromNode).toBe(node1.id);
|
|
145
|
+
expect(edge.toNode).toBe(node2.id);
|
|
146
|
+
expect(graph.edges.has(edge.id)).toBe(true);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("should throw error when source node doesn't exist", () => {
|
|
150
|
+
const node = graph.addNode("test/node");
|
|
151
|
+
expect(() => {
|
|
152
|
+
graph.addEdge("non-existent", "port", node.id, "port");
|
|
153
|
+
}).toThrow(/source node "non-existent" not found/);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("should throw error when target node doesn't exist", () => {
|
|
157
|
+
const node = graph.addNode("test/node");
|
|
158
|
+
expect(() => {
|
|
159
|
+
graph.addEdge(node.id, "port", "non-existent", "port");
|
|
160
|
+
}).toThrow(/target node "non-existent" not found/);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("should emit edge:create event", () => {
|
|
164
|
+
let emittedEdge = null;
|
|
165
|
+
hooks.on("edge:create", (edge) => {
|
|
166
|
+
emittedEdge = edge;
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const node1 = graph.addNode("test/node");
|
|
170
|
+
const node2 = graph.addNode("test/node");
|
|
171
|
+
const edge = graph.addEdge(
|
|
172
|
+
node1.id,
|
|
173
|
+
node1.outputs[0].id,
|
|
174
|
+
node2.id,
|
|
175
|
+
node2.inputs[0].id
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
expect(emittedEdge).toBe(edge);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("clear", () => {
|
|
183
|
+
it("should remove all nodes and edges", () => {
|
|
184
|
+
const node1 = graph.addNode("test/node");
|
|
185
|
+
const node2 = graph.addNode("test/node");
|
|
186
|
+
graph.addEdge(node1.id, node1.outputs[0].id, node2.id, node2.inputs[0].id);
|
|
187
|
+
|
|
188
|
+
expect(graph.nodes.size).toBe(2);
|
|
189
|
+
expect(graph.edges.size).toBe(1);
|
|
190
|
+
|
|
191
|
+
graph.clear();
|
|
192
|
+
|
|
193
|
+
expect(graph.nodes.size).toBe(0);
|
|
194
|
+
expect(graph.edges.size).toBe(0);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe("data flow", () => {
|
|
199
|
+
it("should set and get output values", () => {
|
|
200
|
+
const node = graph.addNode("test/node");
|
|
201
|
+
graph.setOutput(node.id, node.outputs[0].id, "test-value");
|
|
202
|
+
|
|
203
|
+
graph.swapBuffers();
|
|
204
|
+
|
|
205
|
+
const value = graph.getInput(node.id, "any-port");
|
|
206
|
+
expect(value).toBeUndefined(); // No edge connected
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("should retrieve input value from connected edge", () => {
|
|
210
|
+
const node1 = graph.addNode("test/node");
|
|
211
|
+
const node2 = graph.addNode("test/node");
|
|
212
|
+
graph.addEdge(node1.id, node1.outputs[0].id, node2.id, node2.inputs[0].id);
|
|
213
|
+
|
|
214
|
+
graph.setOutput(node1.id, node1.outputs[0].id, "test-value");
|
|
215
|
+
graph.swapBuffers();
|
|
216
|
+
|
|
217
|
+
const value = graph.getInput(node2.id, node2.inputs[0].id);
|
|
218
|
+
expect(value).toBe("test-value");
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe("serialization", () => {
|
|
223
|
+
it("should serialize graph to JSON", () => {
|
|
224
|
+
const node = graph.addNode("test/node", { x: 100, y: 200 });
|
|
225
|
+
const json = graph.toJSON();
|
|
226
|
+
|
|
227
|
+
expect(json.nodes).toBeInstanceOf(Array);
|
|
228
|
+
expect(json.nodes.length).toBe(1);
|
|
229
|
+
expect(json.nodes[0].type).toBe("test/node");
|
|
230
|
+
expect(json.nodes[0].x).toBe(100);
|
|
231
|
+
expect(json.nodes[0].y).toBe(200);
|
|
232
|
+
expect(json.edges).toBeInstanceOf(Array);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("should deserialize graph from JSON", () => {
|
|
236
|
+
const node1 = graph.addNode("test/node", { x: 100, y: 200 });
|
|
237
|
+
const node2 = graph.addNode("test/node", { x: 300, y: 400 });
|
|
238
|
+
const edge = graph.addEdge(
|
|
239
|
+
node1.id,
|
|
240
|
+
node1.outputs[0].id,
|
|
241
|
+
node2.id,
|
|
242
|
+
node2.inputs[0].id
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
const json = graph.toJSON();
|
|
246
|
+
graph.clear();
|
|
247
|
+
|
|
248
|
+
graph.fromJSON(json);
|
|
249
|
+
|
|
250
|
+
expect(graph.nodes.size).toBe(2);
|
|
251
|
+
expect(graph.edges.size).toBe(1);
|
|
252
|
+
expect(graph.getNodeById(node1.id)).toBeDefined();
|
|
253
|
+
expect(graph.getNodeById(node2.id)).toBeDefined();
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { randomUUID } from "../utils/utils";
|
|
2
|
+
|
|
3
|
+
export class Group {
|
|
4
|
+
constructor({
|
|
5
|
+
id,
|
|
6
|
+
title = "",
|
|
7
|
+
x = 0,
|
|
8
|
+
y = 0,
|
|
9
|
+
width = 120,
|
|
10
|
+
height = 80,
|
|
11
|
+
color = "#888",
|
|
12
|
+
state = {},
|
|
13
|
+
} = {}) {
|
|
14
|
+
// ← 인수 미전달 방지
|
|
15
|
+
this.id = id ?? randomUUID();
|
|
16
|
+
this.title = title;
|
|
17
|
+
this.pos = { x, y };
|
|
18
|
+
this.size = { width, height };
|
|
19
|
+
this.color = color;
|
|
20
|
+
this.state = state;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// status
|
|
24
|
+
getStatus() {
|
|
25
|
+
return this.state;
|
|
26
|
+
}
|
|
27
|
+
setStatus(state = {}) {
|
|
28
|
+
this.state = state;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// position
|
|
32
|
+
getPosition() {
|
|
33
|
+
return this.pos;
|
|
34
|
+
}
|
|
35
|
+
setPosition(a, b) {
|
|
36
|
+
// 객체/튜플/숫자 2개 모두 허용하면서 '교체'가 아니라 '변경'만
|
|
37
|
+
if (typeof a === "object" && a !== null) {
|
|
38
|
+
const { x, y } = a;
|
|
39
|
+
if (Number.isFinite(x)) this.pos.x = x;
|
|
40
|
+
if (Number.isFinite(y)) this.pos.y = y;
|
|
41
|
+
} else {
|
|
42
|
+
if (Number.isFinite(a)) this.pos.x = a;
|
|
43
|
+
if (Number.isFinite(b)) this.pos.y = b;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// size
|
|
48
|
+
getSize() {
|
|
49
|
+
return this.size;
|
|
50
|
+
}
|
|
51
|
+
setSize(a, b) {
|
|
52
|
+
if (typeof a === "object" && a !== null) {
|
|
53
|
+
const { width, height } = a;
|
|
54
|
+
if (Number.isFinite(width)) this.size.width = width;
|
|
55
|
+
if (Number.isFinite(height)) this.size.height = height;
|
|
56
|
+
} else {
|
|
57
|
+
if (Number.isFinite(a)) this.size.width = a;
|
|
58
|
+
if (Number.isFinite(b)) this.size.height = b;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// color
|
|
63
|
+
getColor() {
|
|
64
|
+
return this.color;
|
|
65
|
+
}
|
|
66
|
+
setColor(color) {
|
|
67
|
+
this.color = color;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// title
|
|
71
|
+
getTitle() {
|
|
72
|
+
return this.title;
|
|
73
|
+
}
|
|
74
|
+
setTitle(title) {
|
|
75
|
+
this.title = title;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function createHooks(names) {
|
|
2
|
+
const map = Object.fromEntries(names.map((n) => [n, new Set()]));
|
|
3
|
+
return {
|
|
4
|
+
on(name, fn) {
|
|
5
|
+
map[name].add(fn);
|
|
6
|
+
return () => map[name].delete(fn);
|
|
7
|
+
},
|
|
8
|
+
async emit(name, ...args) {
|
|
9
|
+
for (const fn of map[name]) await fn(...args);
|
|
10
|
+
},
|
|
11
|
+
};
|
|
12
|
+
}
|