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.
@@ -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
+ }