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,606 @@
|
|
|
1
|
+
import { hitTestNode, portRect } from "./hitTest.js";
|
|
2
|
+
|
|
3
|
+
export class CanvasRenderer {
|
|
4
|
+
static FONT_SIZE = 12;
|
|
5
|
+
static SELECTED_NODE_COLOR = "#6cf";
|
|
6
|
+
constructor(canvas, { theme = {}, registry, edgeStyle = "orthogonal" } = {}) {
|
|
7
|
+
this.canvas = canvas;
|
|
8
|
+
this.ctx = canvas.getContext("2d");
|
|
9
|
+
this.registry = registry; // to call per-node onDraw
|
|
10
|
+
|
|
11
|
+
// viewport transform
|
|
12
|
+
this.scale = 1;
|
|
13
|
+
this.minScale = 0.25;
|
|
14
|
+
this.maxScale = 3;
|
|
15
|
+
this.offsetX = 0;
|
|
16
|
+
this.offsetY = 0;
|
|
17
|
+
|
|
18
|
+
// 'bezier' | 'line' | 'orthogonal'
|
|
19
|
+
this.edgeStyle = edgeStyle;
|
|
20
|
+
|
|
21
|
+
this.theme = Object.assign(
|
|
22
|
+
{
|
|
23
|
+
bg: "#0d0d0f", // Darker background
|
|
24
|
+
grid: "#1a1a1d", // Subtle grid
|
|
25
|
+
node: "#16161a", // Darker nodes
|
|
26
|
+
nodeBorder: "#2a2a2f", // Subtle border
|
|
27
|
+
title: "#1f1f24", // Darker header
|
|
28
|
+
text: "#e4e4e7", // Softer white
|
|
29
|
+
textMuted: "#a1a1aa", // Muted text
|
|
30
|
+
port: "#6366f1", // Indigo for data ports
|
|
31
|
+
portExec: "#10b981", // Emerald for exec ports
|
|
32
|
+
edge: "#52525b", // Neutral edge color
|
|
33
|
+
edgeActive: "#8b5cf6", // Purple for active
|
|
34
|
+
accent: "#6366f1", // Indigo accent
|
|
35
|
+
accentBright: "#818cf8", // Brighter accent
|
|
36
|
+
},
|
|
37
|
+
theme
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
setEdgeStyle(style) {
|
|
41
|
+
this.edgeStyle =
|
|
42
|
+
style === "line" || style === "orthogonal" ? style : "bezier";
|
|
43
|
+
}
|
|
44
|
+
setRegistry(reg) {
|
|
45
|
+
this.registry = reg;
|
|
46
|
+
}
|
|
47
|
+
resize(w, h) {
|
|
48
|
+
this.canvas.width = w;
|
|
49
|
+
this.canvas.height = h;
|
|
50
|
+
}
|
|
51
|
+
setTransform({
|
|
52
|
+
scale = this.scale,
|
|
53
|
+
offsetX = this.offsetX,
|
|
54
|
+
offsetY = this.offsetY,
|
|
55
|
+
} = {}) {
|
|
56
|
+
this.scale = Math.min(this.maxScale, Math.max(this.minScale, scale));
|
|
57
|
+
this.offsetX = offsetX;
|
|
58
|
+
this.offsetY = offsetY;
|
|
59
|
+
}
|
|
60
|
+
panBy(dx, dy) {
|
|
61
|
+
this.offsetX += dx;
|
|
62
|
+
this.offsetY += dy;
|
|
63
|
+
}
|
|
64
|
+
zoomAt(factor, cx, cy) {
|
|
65
|
+
// factor > 1 zoom in, < 1 zoom out, centered at screen point (cx, cy)
|
|
66
|
+
const prev = this.scale;
|
|
67
|
+
const next = Math.min(
|
|
68
|
+
this.maxScale,
|
|
69
|
+
Math.max(this.minScale, prev * factor)
|
|
70
|
+
);
|
|
71
|
+
if (next === prev) return;
|
|
72
|
+
// keep the world point under cursor fixed: adjust offset
|
|
73
|
+
const wx = (cx - this.offsetX) / prev;
|
|
74
|
+
const wy = (cy - this.offsetY) / prev;
|
|
75
|
+
this.offsetX = cx - wx * next;
|
|
76
|
+
this.offsetY = cy - wy * next;
|
|
77
|
+
this.scale = next;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
screenToWorld(x, y) {
|
|
81
|
+
return {
|
|
82
|
+
x: (x - this.offsetX) / this.scale,
|
|
83
|
+
y: (y - this.offsetY) / this.scale,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
worldToScreen(x, y) {
|
|
87
|
+
return {
|
|
88
|
+
x: x * this.scale + this.offsetX,
|
|
89
|
+
y: y * this.scale + this.offsetY,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
_applyTransform() {
|
|
93
|
+
const { ctx } = this;
|
|
94
|
+
ctx.setTransform(this.scale, 0, 0, this.scale, this.offsetX, this.offsetY);
|
|
95
|
+
}
|
|
96
|
+
_resetTransform() {
|
|
97
|
+
this.ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Drawing ────────────────────────────────────────────────────────────────
|
|
101
|
+
_drawArrowhead(x1, y1, x2, y2, size = 10) {
|
|
102
|
+
const { ctx } = this;
|
|
103
|
+
const s = size / this.scale; // 줌에 따라 크기 보정
|
|
104
|
+
const ang = Math.atan2(y2 - y1, x2 - x1);
|
|
105
|
+
|
|
106
|
+
ctx.beginPath();
|
|
107
|
+
ctx.moveTo(x2, y2);
|
|
108
|
+
ctx.lineTo(
|
|
109
|
+
x2 - s * Math.cos(ang - Math.PI / 6),
|
|
110
|
+
y2 - s * Math.sin(ang - Math.PI / 6)
|
|
111
|
+
);
|
|
112
|
+
ctx.lineTo(
|
|
113
|
+
x2 - s * Math.cos(ang + Math.PI / 6),
|
|
114
|
+
y2 - s * Math.sin(ang + Math.PI / 6)
|
|
115
|
+
);
|
|
116
|
+
ctx.closePath();
|
|
117
|
+
ctx.fill(); // 선 색상과 동일한 fill이 자연스러움
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
_drawScreenText(
|
|
121
|
+
text,
|
|
122
|
+
lx,
|
|
123
|
+
ly,
|
|
124
|
+
{
|
|
125
|
+
fontPx = 12,
|
|
126
|
+
color = this.theme.text,
|
|
127
|
+
align = "left",
|
|
128
|
+
baseline = "alphabetic",
|
|
129
|
+
dpr = 1, // 추후 devicePixelRatio 도입
|
|
130
|
+
} = {}
|
|
131
|
+
) {
|
|
132
|
+
const { ctx } = this;
|
|
133
|
+
const { x: sx, y: sy } = this.worldToScreen(lx, ly);
|
|
134
|
+
|
|
135
|
+
ctx.save();
|
|
136
|
+
// 화면 좌표계(스케일=1)로 리셋
|
|
137
|
+
this._resetTransform();
|
|
138
|
+
|
|
139
|
+
// 픽셀 스냅(번짐 방지)
|
|
140
|
+
const px = Math.round(sx) + 0.5;
|
|
141
|
+
const py = Math.round(sy) + 0.5;
|
|
142
|
+
|
|
143
|
+
ctx.font = `${fontPx * this.scale}px system-ui`;
|
|
144
|
+
ctx.fillStyle = color;
|
|
145
|
+
ctx.textAlign = align;
|
|
146
|
+
ctx.textBaseline = baseline;
|
|
147
|
+
ctx.fillText(text, px, py);
|
|
148
|
+
ctx.restore();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
drawGrid() {
|
|
152
|
+
const { ctx, canvas, theme, scale, offsetX, offsetY } = this;
|
|
153
|
+
// clear screen in screen space
|
|
154
|
+
|
|
155
|
+
this._resetTransform();
|
|
156
|
+
ctx.fillStyle = theme.bg;
|
|
157
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
158
|
+
|
|
159
|
+
// draw grid in world space so it pans/zooms
|
|
160
|
+
this._applyTransform();
|
|
161
|
+
// Make grid subtle but visible
|
|
162
|
+
ctx.strokeStyle = this._rgba(theme.grid, 0.35); // Subtle but visible
|
|
163
|
+
ctx.lineWidth = 1 / scale; // keep 1px apparent
|
|
164
|
+
|
|
165
|
+
const base = 20; // world units
|
|
166
|
+
const step = base;
|
|
167
|
+
|
|
168
|
+
// visible world bounds
|
|
169
|
+
const x0 = -offsetX / scale;
|
|
170
|
+
const y0 = -offsetY / scale;
|
|
171
|
+
const x1 = (canvas.width - offsetX) / scale;
|
|
172
|
+
const y1 = (canvas.height - offsetY) / scale;
|
|
173
|
+
|
|
174
|
+
const startX = Math.floor(x0 / step) * step;
|
|
175
|
+
const startY = Math.floor(y0 / step) * step;
|
|
176
|
+
|
|
177
|
+
ctx.beginPath();
|
|
178
|
+
for (let x = startX; x <= x1; x += step) {
|
|
179
|
+
ctx.moveTo(x, y0);
|
|
180
|
+
ctx.lineTo(x, y1);
|
|
181
|
+
}
|
|
182
|
+
for (let y = startY; y <= y1; y += step) {
|
|
183
|
+
ctx.moveTo(x0, y);
|
|
184
|
+
ctx.lineTo(x1, y);
|
|
185
|
+
}
|
|
186
|
+
ctx.stroke();
|
|
187
|
+
|
|
188
|
+
this._resetTransform();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
draw(
|
|
192
|
+
graph,
|
|
193
|
+
{
|
|
194
|
+
selection = new Set(),
|
|
195
|
+
tempEdge = null,
|
|
196
|
+
running = false,
|
|
197
|
+
time = performance.now(),
|
|
198
|
+
dt = 0,
|
|
199
|
+
groups = null,
|
|
200
|
+
activeEdges = new Set(),
|
|
201
|
+
} = {}
|
|
202
|
+
) {
|
|
203
|
+
// Update transforms first
|
|
204
|
+
graph.updateWorldTransforms();
|
|
205
|
+
|
|
206
|
+
this.drawGrid();
|
|
207
|
+
const { ctx, theme } = this;
|
|
208
|
+
this._applyTransform();
|
|
209
|
+
|
|
210
|
+
ctx.save();
|
|
211
|
+
|
|
212
|
+
// 1. Draw Groups (Backgrounds)
|
|
213
|
+
for (const n of graph.nodes.values()) {
|
|
214
|
+
if (n.type === "core/Group") {
|
|
215
|
+
const sel = selection.has(n.id);
|
|
216
|
+
const def = this.registry?.types?.get(n.type);
|
|
217
|
+
if (def?.onDraw) def.onDraw(n, { ctx, theme });
|
|
218
|
+
else this._drawNode(n, sel);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// 2. Draw Edges (BEFORE nodes so ports appear on top)
|
|
223
|
+
ctx.lineWidth = 1.5 / this.scale; // Thinner, more refined edges
|
|
224
|
+
|
|
225
|
+
// Calculate animation values if running
|
|
226
|
+
let dashArray = null;
|
|
227
|
+
let dashOffset = 0;
|
|
228
|
+
if (running) {
|
|
229
|
+
const speed = 120;
|
|
230
|
+
const phase = (((time / 1000) * speed) / this.scale) % CanvasRenderer.FONT_SIZE;
|
|
231
|
+
dashArray = [6 / this.scale, 6 / this.scale];
|
|
232
|
+
dashOffset = -phase;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
for (const e of graph.edges.values()) {
|
|
236
|
+
const shouldAnimate = activeEdges && activeEdges.size > 0 && activeEdges.has(e.id);
|
|
237
|
+
|
|
238
|
+
if (running && shouldAnimate && dashArray) {
|
|
239
|
+
ctx.setLineDash(dashArray);
|
|
240
|
+
ctx.lineDashOffset = dashOffset;
|
|
241
|
+
} else {
|
|
242
|
+
ctx.setLineDash([]);
|
|
243
|
+
ctx.lineDashOffset = 0;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const isActive = activeEdges && activeEdges.has(e.id);
|
|
247
|
+
if (isActive) {
|
|
248
|
+
ctx.strokeStyle = "#00ffff";
|
|
249
|
+
ctx.lineWidth = 3 * this.scale;
|
|
250
|
+
} else {
|
|
251
|
+
ctx.strokeStyle = theme.edge;
|
|
252
|
+
ctx.lineWidth = 1.5 / this.scale;
|
|
253
|
+
}
|
|
254
|
+
this._drawEdge(graph, e);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// temp edge preview
|
|
258
|
+
if (tempEdge) {
|
|
259
|
+
const a = this.screenToWorld(tempEdge.x1, tempEdge.y1);
|
|
260
|
+
const b = this.screenToWorld(tempEdge.x2, tempEdge.y2);
|
|
261
|
+
|
|
262
|
+
const prevDash = this.ctx.getLineDash();
|
|
263
|
+
this.ctx.setLineDash([6 / this.scale, 6 / this.scale]);
|
|
264
|
+
|
|
265
|
+
let ptsForArrow = null;
|
|
266
|
+
if (this.edgeStyle === "line") {
|
|
267
|
+
this._drawLine(a.x, a.y, b.x, b.y);
|
|
268
|
+
ptsForArrow = [{ x: a.x, y: a.y }, { x: b.x, y: b.y }];
|
|
269
|
+
} else if (this.edgeStyle === "orthogonal") {
|
|
270
|
+
ptsForArrow = this._drawOrthogonal(a.x, a.y, b.x, b.y);
|
|
271
|
+
} else {
|
|
272
|
+
this._drawCurve(a.x, a.y, b.x, b.y);
|
|
273
|
+
ptsForArrow = [{ x: a.x, y: a.y }, { x: b.x, y: b.y }];
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
this.ctx.setLineDash(prevDash);
|
|
277
|
+
|
|
278
|
+
if (ptsForArrow && ptsForArrow.length >= 2) {
|
|
279
|
+
const p1 = ptsForArrow[ptsForArrow.length - 2];
|
|
280
|
+
const p2 = ptsForArrow[ptsForArrow.length - 1];
|
|
281
|
+
this.ctx.fillStyle = this.theme.edge;
|
|
282
|
+
this.ctx.strokeStyle = this.theme.edge;
|
|
283
|
+
this._drawArrowhead(p1.x, p1.y, p2.x, p2.y, 12);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// 3. Draw Other Nodes (AFTER edges)
|
|
288
|
+
// For nodes with HTML overlays, skip ports initially
|
|
289
|
+
for (const n of graph.nodes.values()) {
|
|
290
|
+
if (n.type !== "core/Group") {
|
|
291
|
+
const sel = selection.has(n.id);
|
|
292
|
+
const def = this.registry?.types?.get(n.type);
|
|
293
|
+
const hasHtmlOverlay = !!(def?.html);
|
|
294
|
+
|
|
295
|
+
// Draw node, but skip ports if it has HTML overlay
|
|
296
|
+
this._drawNode(n, sel, hasHtmlOverlay);
|
|
297
|
+
if (def?.onDraw) def.onDraw(n, { ctx, theme });
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// 4. Draw ports for HTML overlay nodes LAST (so they appear above HTML)
|
|
302
|
+
for (const n of graph.nodes.values()) {
|
|
303
|
+
if (n.type !== "core/Group") {
|
|
304
|
+
const def = this.registry?.types?.get(n.type);
|
|
305
|
+
const hasHtmlOverlay = !!(def?.html);
|
|
306
|
+
|
|
307
|
+
if (hasHtmlOverlay) {
|
|
308
|
+
this._drawPorts(n);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
this._resetTransform();
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
_rgba(hex, a) {
|
|
317
|
+
const c = hex.replace("#", "");
|
|
318
|
+
const n = parseInt(
|
|
319
|
+
c.length === 3
|
|
320
|
+
? c
|
|
321
|
+
.split("")
|
|
322
|
+
.map((x) => x + x)
|
|
323
|
+
.join("")
|
|
324
|
+
: c,
|
|
325
|
+
16
|
|
326
|
+
);
|
|
327
|
+
const r = (n >> 16) & 255,
|
|
328
|
+
g = (n >> 8) & 255,
|
|
329
|
+
b = n & 255;
|
|
330
|
+
return `rgba(${r},${g},${b},${a})`;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
_drawNode(node, selected, skipPorts = false) {
|
|
334
|
+
const { ctx, theme } = this;
|
|
335
|
+
const r = 8;
|
|
336
|
+
const { x, y, w, h } = node.computed;
|
|
337
|
+
|
|
338
|
+
// Draw subtle shadow
|
|
339
|
+
if (!selected) {
|
|
340
|
+
ctx.save();
|
|
341
|
+
ctx.shadowColor = "rgba(0, 0, 0, 0.3)";
|
|
342
|
+
ctx.shadowBlur = 8 / this.scale;
|
|
343
|
+
ctx.shadowOffsetY = 2 / this.scale;
|
|
344
|
+
ctx.fillStyle = "rgba(0, 0, 0, 0.2)";
|
|
345
|
+
roundRect(ctx, x, y, w, h, r);
|
|
346
|
+
ctx.fill();
|
|
347
|
+
ctx.restore();
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Draw main body
|
|
351
|
+
ctx.fillStyle = theme.node;
|
|
352
|
+
ctx.strokeStyle = selected ? theme.accentBright : theme.nodeBorder;
|
|
353
|
+
ctx.lineWidth = (selected ? 1.5 : 1) / this.scale;
|
|
354
|
+
roundRect(ctx, x, y, w, h, r);
|
|
355
|
+
ctx.fill();
|
|
356
|
+
ctx.stroke();
|
|
357
|
+
|
|
358
|
+
// Draw header
|
|
359
|
+
ctx.fillStyle = theme.title;
|
|
360
|
+
roundRect(ctx, x, y, w, 24, { tl: r, tr: r, br: 0, bl: 0 });
|
|
361
|
+
ctx.fill();
|
|
362
|
+
|
|
363
|
+
// Header border (only top and sides)
|
|
364
|
+
ctx.strokeStyle = selected ? theme.accentBright : theme.nodeBorder;
|
|
365
|
+
ctx.lineWidth = (selected ? 1.5 : 1) / this.scale;
|
|
366
|
+
ctx.beginPath();
|
|
367
|
+
// Top-left corner to top-right corner
|
|
368
|
+
ctx.moveTo(x + r, y);
|
|
369
|
+
ctx.lineTo(x + w - r, y);
|
|
370
|
+
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
|
|
371
|
+
// Right side down to header bottom
|
|
372
|
+
ctx.lineTo(x + w, y + 24);
|
|
373
|
+
// Move to left side header bottom
|
|
374
|
+
ctx.moveTo(x, y + 24);
|
|
375
|
+
// Left side up to top-left corner
|
|
376
|
+
ctx.lineTo(x, y + r);
|
|
377
|
+
ctx.quadraticCurveTo(x, y, x + r, y);
|
|
378
|
+
ctx.stroke();
|
|
379
|
+
|
|
380
|
+
this._drawScreenText(node.title, x + 8, y + CanvasRenderer.FONT_SIZE, {
|
|
381
|
+
fontPx: CanvasRenderer.FONT_SIZE,
|
|
382
|
+
color: theme.text,
|
|
383
|
+
baseline: "middle",
|
|
384
|
+
align: "left",
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
// Skip port drawing if requested (for HTML overlay nodes)
|
|
388
|
+
if (skipPorts) return;
|
|
389
|
+
|
|
390
|
+
// Draw input ports
|
|
391
|
+
node.inputs.forEach((p, i) => {
|
|
392
|
+
const rct = portRect(node, p, i, "in");
|
|
393
|
+
const cx = rct.x + rct.w / 2;
|
|
394
|
+
const cy = rct.y + rct.h / 2;
|
|
395
|
+
|
|
396
|
+
if (p.portType === "exec") {
|
|
397
|
+
// Draw exec port - rounded square
|
|
398
|
+
const portSize = 8;
|
|
399
|
+
ctx.fillStyle = theme.portExec;
|
|
400
|
+
ctx.strokeStyle = "rgba(16, 185, 129, 0.3)";
|
|
401
|
+
ctx.lineWidth = 2 / this.scale;
|
|
402
|
+
ctx.beginPath();
|
|
403
|
+
ctx.roundRect(cx - portSize / 2, cy - portSize / 2, portSize, portSize, 2);
|
|
404
|
+
ctx.fill();
|
|
405
|
+
ctx.stroke();
|
|
406
|
+
} else {
|
|
407
|
+
// Draw data port - circle with outline
|
|
408
|
+
ctx.fillStyle = theme.port;
|
|
409
|
+
ctx.strokeStyle = "rgba(99, 102, 241, 0.3)";
|
|
410
|
+
ctx.lineWidth = 2 / this.scale;
|
|
411
|
+
ctx.beginPath();
|
|
412
|
+
ctx.arc(cx, cy, 5, 0, Math.PI * 2);
|
|
413
|
+
ctx.fill();
|
|
414
|
+
ctx.stroke();
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
// Draw output ports
|
|
419
|
+
node.outputs.forEach((p, i) => {
|
|
420
|
+
const rct = portRect(node, p, i, "out");
|
|
421
|
+
const cx = rct.x + rct.w / 2;
|
|
422
|
+
const cy = rct.y + rct.h / 2;
|
|
423
|
+
|
|
424
|
+
if (p.portType === "exec") {
|
|
425
|
+
// Draw exec port - rounded square
|
|
426
|
+
const portSize = 8;
|
|
427
|
+
ctx.fillStyle = theme.portExec;
|
|
428
|
+
ctx.strokeStyle = "rgba(16, 185, 129, 0.3)";
|
|
429
|
+
ctx.lineWidth = 2 / this.scale;
|
|
430
|
+
ctx.beginPath();
|
|
431
|
+
ctx.roundRect(cx - portSize / 2, cy - portSize / 2, portSize, portSize, 2);
|
|
432
|
+
ctx.fill();
|
|
433
|
+
ctx.stroke();
|
|
434
|
+
} else {
|
|
435
|
+
// Draw data port - circle with outline
|
|
436
|
+
ctx.fillStyle = theme.port;
|
|
437
|
+
ctx.strokeStyle = "rgba(99, 102, 241, 0.3)";
|
|
438
|
+
ctx.lineWidth = 2 / this.scale;
|
|
439
|
+
ctx.beginPath();
|
|
440
|
+
ctx.arc(cx, cy, 5, 0, Math.PI * 2);
|
|
441
|
+
ctx.fill();
|
|
442
|
+
ctx.stroke();
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
_drawPorts(node) {
|
|
448
|
+
const { ctx, theme } = this;
|
|
449
|
+
|
|
450
|
+
// Draw input ports
|
|
451
|
+
node.inputs.forEach((p, i) => {
|
|
452
|
+
const rct = portRect(node, p, i, "in");
|
|
453
|
+
const cx = rct.x + rct.w / 2;
|
|
454
|
+
const cy = rct.y + rct.h / 2;
|
|
455
|
+
|
|
456
|
+
if (p.portType === "exec") {
|
|
457
|
+
// Draw exec port - rounded square with subtle glow
|
|
458
|
+
const portSize = 8;
|
|
459
|
+
ctx.fillStyle = theme.portExec;
|
|
460
|
+
ctx.strokeStyle = "rgba(16, 185, 129, 0.3)";
|
|
461
|
+
ctx.lineWidth = 2 / this.scale;
|
|
462
|
+
ctx.beginPath();
|
|
463
|
+
ctx.roundRect(cx - portSize / 2, cy - portSize / 2, portSize, portSize, 2);
|
|
464
|
+
ctx.fill();
|
|
465
|
+
ctx.stroke();
|
|
466
|
+
} else {
|
|
467
|
+
// Draw data port - circle with subtle outline
|
|
468
|
+
ctx.fillStyle = theme.port;
|
|
469
|
+
ctx.strokeStyle = "rgba(99, 102, 241, 0.3)";
|
|
470
|
+
ctx.lineWidth = 2 / this.scale;
|
|
471
|
+
ctx.beginPath();
|
|
472
|
+
ctx.arc(cx, cy, 5, 0, Math.PI * 2);
|
|
473
|
+
ctx.fill();
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
// Draw output ports
|
|
478
|
+
node.outputs.forEach((p, i) => {
|
|
479
|
+
const rct = portRect(node, p, i, "out");
|
|
480
|
+
const cx = rct.x + rct.w / 2;
|
|
481
|
+
const cy = rct.y + rct.h / 2;
|
|
482
|
+
|
|
483
|
+
if (p.portType === "exec") {
|
|
484
|
+
// Draw exec port - rounded square
|
|
485
|
+
const portSize = 8;
|
|
486
|
+
ctx.fillStyle = theme.portExec;
|
|
487
|
+
ctx.strokeStyle = "rgba(16, 185, 129, 0.3)";
|
|
488
|
+
ctx.lineWidth = 2 / this.scale;
|
|
489
|
+
ctx.beginPath();
|
|
490
|
+
ctx.roundRect(cx - portSize / 2, cy - portSize / 2, portSize, portSize, 2);
|
|
491
|
+
ctx.fill();
|
|
492
|
+
ctx.stroke();
|
|
493
|
+
} else {
|
|
494
|
+
// Draw data port - circle with outline
|
|
495
|
+
ctx.fillStyle = theme.port;
|
|
496
|
+
ctx.strokeStyle = "rgba(99, 102, 241, 0.3)";
|
|
497
|
+
ctx.lineWidth = 2 / this.scale;
|
|
498
|
+
ctx.beginPath();
|
|
499
|
+
ctx.arc(cx, cy, 5, 0, Math.PI * 2);
|
|
500
|
+
ctx.fill();
|
|
501
|
+
ctx.stroke();
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
_drawEdge(graph, e) {
|
|
507
|
+
const from = graph.nodes.get(e.fromNode);
|
|
508
|
+
const to = graph.nodes.get(e.toNode);
|
|
509
|
+
if (!from || !to) return;
|
|
510
|
+
const iOut = from.outputs.findIndex((p) => p.id === e.fromPort);
|
|
511
|
+
const iIn = to.inputs.findIndex((p) => p.id === e.toPort);
|
|
512
|
+
const pr1 = portRect(from, null, iOut, "out");
|
|
513
|
+
const pr2 = portRect(to, null, iIn, "in");
|
|
514
|
+
const x1 = pr1.x,
|
|
515
|
+
y1 = pr1.y + 7,
|
|
516
|
+
x2 = pr2.x,
|
|
517
|
+
y2 = pr2.y + 7;
|
|
518
|
+
if (this.edgeStyle === "line") {
|
|
519
|
+
this._drawLine(x1, y1, x2, y2);
|
|
520
|
+
} else if (this.edgeStyle === "orthogonal") {
|
|
521
|
+
this._drawOrthogonal(x1, y1, x2, y2);
|
|
522
|
+
} else {
|
|
523
|
+
this._drawCurve(x1, y1, x2, y2); // bezier (기존)
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
_drawLine(x1, y1, x2, y2) {
|
|
528
|
+
const { ctx } = this;
|
|
529
|
+
ctx.beginPath();
|
|
530
|
+
ctx.moveTo(x1, y1);
|
|
531
|
+
ctx.lineTo(x2, y2);
|
|
532
|
+
ctx.stroke();
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
_drawPolyline(points) {
|
|
536
|
+
const { ctx } = this;
|
|
537
|
+
ctx.beginPath();
|
|
538
|
+
ctx.moveTo(points[0].x, points[0].y);
|
|
539
|
+
for (let i = 1; i < points.length; i++)
|
|
540
|
+
ctx.lineTo(points[i].x, points[i].y);
|
|
541
|
+
ctx.stroke();
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
_drawOrthogonal(x1, y1, x2, y2) {
|
|
545
|
+
const dx = Math.abs(x2 - x1);
|
|
546
|
+
const dy = Math.abs(y2 - y1);
|
|
547
|
+
// 중간 축을 결정 (더 짧은 축을 가운데에 두면 보기 좋음)
|
|
548
|
+
const useHVH = true; // 가로-세로-가로(HVH) vs 세로-가로-세로(VHV)
|
|
549
|
+
const midX = (x1 + x2) / 2;
|
|
550
|
+
const midY = (y1 + y2) / 2;
|
|
551
|
+
|
|
552
|
+
let pts;
|
|
553
|
+
if (useHVH) {
|
|
554
|
+
// x1,y1 → midX,y1 → midX,y2 → x2,y2
|
|
555
|
+
pts = [
|
|
556
|
+
{ x: x1, y: y1 },
|
|
557
|
+
{ x: midX, y: y1 },
|
|
558
|
+
{ x: midX, y: y2 },
|
|
559
|
+
{ x: x2, y: y2 },
|
|
560
|
+
];
|
|
561
|
+
}
|
|
562
|
+
// else {
|
|
563
|
+
// // x1,y1 → x1,midY → x2,midY → x2,y2
|
|
564
|
+
// pts = [
|
|
565
|
+
// { x: x1, y: y1 },
|
|
566
|
+
// { x: x1, y: midY },
|
|
567
|
+
// { x: x2, y: midY },
|
|
568
|
+
// { x: x2, y: y2 },
|
|
569
|
+
// ];
|
|
570
|
+
// }
|
|
571
|
+
|
|
572
|
+
// 라운드 코너
|
|
573
|
+
const { ctx } = this;
|
|
574
|
+
const prevJoin = ctx.lineJoin,
|
|
575
|
+
prevCap = ctx.lineCap;
|
|
576
|
+
ctx.lineJoin = "round";
|
|
577
|
+
ctx.lineCap = "round";
|
|
578
|
+
this._drawPolyline(pts);
|
|
579
|
+
ctx.lineJoin = prevJoin;
|
|
580
|
+
ctx.lineCap = prevCap;
|
|
581
|
+
|
|
582
|
+
return pts; // 화살표 각도 계산에 사용
|
|
583
|
+
}
|
|
584
|
+
_drawCurve(x1, y1, x2, y2) {
|
|
585
|
+
const { ctx } = this;
|
|
586
|
+
const dx = Math.max(40, Math.abs(x2 - x1) * 0.4);
|
|
587
|
+
ctx.beginPath();
|
|
588
|
+
ctx.moveTo(x1, y1);
|
|
589
|
+
ctx.bezierCurveTo(x1 + dx, y1, x2 - dx, y2, x2, y2);
|
|
590
|
+
ctx.stroke();
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
function roundRect(ctx, x, y, w, h, r = 6) {
|
|
594
|
+
if (typeof r === "number") r = { tl: r, tr: r, br: r, bl: r };
|
|
595
|
+
ctx.beginPath();
|
|
596
|
+
ctx.moveTo(x + r.tl, y);
|
|
597
|
+
ctx.lineTo(x + w - r.tr, y);
|
|
598
|
+
ctx.quadraticCurveTo(x + w, y, x + w, y + r.tr);
|
|
599
|
+
ctx.lineTo(x + w, y + h - r.br);
|
|
600
|
+
ctx.quadraticCurveTo(x + w, y + h, x + w - r.br, y + h);
|
|
601
|
+
ctx.lineTo(x + r.bl, y + h);
|
|
602
|
+
ctx.quadraticCurveTo(x, y + h, x, y + h - r.bl);
|
|
603
|
+
ctx.lineTo(x, y + r.tl);
|
|
604
|
+
ctx.quadraticCurveTo(x, y, x + r.tl, y);
|
|
605
|
+
ctx.closePath();
|
|
606
|
+
}
|