html-overlay-node 0.1.9 → 0.1.11
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/dist/example.json +9 -9
- package/dist/html-overlay-node.es.js +1000 -321
- package/dist/html-overlay-node.es.js.map +1 -1
- package/dist/html-overlay-node.umd.js +1 -1
- package/dist/html-overlay-node.umd.js.map +1 -1
- package/dist/img/favicon.svg +1 -0
- package/index.css +436 -232
- package/package.json +1 -1
- package/readme.md +143 -440
- package/src/core/Graph.js +34 -5
- package/src/core/Runner.js +188 -54
- package/src/index.js +29 -26
- package/src/interact/Controller.js +35 -6
- package/src/nodes/core.js +55 -77
- package/src/nodes/logic.js +51 -48
- package/src/nodes/math.js +23 -8
- package/src/nodes/util.js +238 -131
- package/src/nodes/value.js +87 -102
- package/src/render/CanvasRenderer.js +465 -285
- package/src/render/HtmlOverlay.js +65 -3
- package/src/render/hitTest.js +5 -2
- package/src/ui/HelpOverlay.js +158 -0
- package/src/ui/PropertyPanel.css +58 -27
- package/src/ui/PropertyPanel.js +441 -268
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
import { portRect } from "./hitTest.js";
|
|
2
2
|
|
|
3
3
|
export class CanvasRenderer {
|
|
4
|
-
static FONT_SIZE =
|
|
4
|
+
static FONT_SIZE = 11;
|
|
5
5
|
static SELECTED_NODE_COLOR = "#6cf";
|
|
6
6
|
constructor(canvas, { theme = {}, registry, edgeStyle = "orthogonal" } = {}) {
|
|
7
7
|
this.canvas = canvas;
|
|
8
8
|
this.ctx = canvas.getContext("2d");
|
|
9
|
-
this.registry = registry;
|
|
9
|
+
this.registry = registry;
|
|
10
10
|
|
|
11
|
-
// viewport transform
|
|
12
11
|
this.scale = 1;
|
|
13
12
|
this.minScale = 0.25;
|
|
14
13
|
this.maxScale = 3;
|
|
@@ -20,23 +19,25 @@ export class CanvasRenderer {
|
|
|
20
19
|
|
|
21
20
|
this.theme = Object.assign(
|
|
22
21
|
{
|
|
23
|
-
bg: "#
|
|
24
|
-
grid: "#
|
|
25
|
-
node: "
|
|
26
|
-
nodeBorder: "
|
|
27
|
-
title: "
|
|
28
|
-
text: "#
|
|
29
|
-
textMuted: "#
|
|
30
|
-
port: "#
|
|
31
|
-
portExec: "#10b981",
|
|
32
|
-
edge: "
|
|
33
|
-
edgeActive: "#
|
|
34
|
-
accent: "#6366f1",
|
|
35
|
-
accentBright: "#818cf8",
|
|
22
|
+
bg: "#0e0e16",
|
|
23
|
+
grid: "#1c1c2c",
|
|
24
|
+
node: "rgba(22, 22, 34, 0.9)",
|
|
25
|
+
nodeBorder: "rgba(255, 255, 255, 0.08)",
|
|
26
|
+
title: "rgba(28, 28, 42, 0.95)",
|
|
27
|
+
text: "#f5f5f7",
|
|
28
|
+
textMuted: "#8e8eaf",
|
|
29
|
+
port: "#4f46e5",
|
|
30
|
+
portExec: "#10b981",
|
|
31
|
+
edge: "rgba(255, 255, 255, 0.12)",
|
|
32
|
+
edgeActive: "#34c38f", // green for active edge animation
|
|
33
|
+
accent: "#6366f1",
|
|
34
|
+
accentBright: "#818cf8",
|
|
35
|
+
accentGlow: "rgba(99, 102, 241, 0.25)",
|
|
36
36
|
},
|
|
37
37
|
theme
|
|
38
38
|
);
|
|
39
39
|
}
|
|
40
|
+
|
|
40
41
|
setEdgeStyle(style) {
|
|
41
42
|
this.edgeStyle = style === "line" || style === "orthogonal" ? style : "bezier";
|
|
42
43
|
}
|
|
@@ -51,35 +52,25 @@ export class CanvasRenderer {
|
|
|
51
52
|
this.scale = Math.min(this.maxScale, Math.max(this.minScale, scale));
|
|
52
53
|
this.offsetX = offsetX;
|
|
53
54
|
this.offsetY = offsetY;
|
|
54
|
-
// Trigger callback to sync HTML overlay transform
|
|
55
55
|
this._onTransformChange?.();
|
|
56
56
|
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Set callback to be called when transform changes (zoom/pan)
|
|
60
|
-
* @param {Function} callback - Function to call on transform change
|
|
61
|
-
*/
|
|
62
57
|
setTransformChangeCallback(callback) {
|
|
63
58
|
this._onTransformChange = callback;
|
|
64
59
|
}
|
|
65
60
|
panBy(dx, dy) {
|
|
66
61
|
this.offsetX += dx;
|
|
67
62
|
this.offsetY += dy;
|
|
68
|
-
// Trigger callback to sync HTML overlay transform
|
|
69
63
|
this._onTransformChange?.();
|
|
70
64
|
}
|
|
71
65
|
zoomAt(factor, cx, cy) {
|
|
72
|
-
// factor > 1 zoom in, < 1 zoom out, centered at screen point (cx, cy)
|
|
73
66
|
const prev = this.scale;
|
|
74
67
|
const next = Math.min(this.maxScale, Math.max(this.minScale, prev * factor));
|
|
75
68
|
if (next === prev) return;
|
|
76
|
-
// keep the world point under cursor fixed: adjust offset
|
|
77
69
|
const wx = (cx - this.offsetX) / prev;
|
|
78
70
|
const wy = (cy - this.offsetY) / prev;
|
|
79
71
|
this.offsetX = cx - wx * next;
|
|
80
72
|
this.offsetY = cy - wy * next;
|
|
81
73
|
this.scale = next;
|
|
82
|
-
// Trigger callback to sync HTML overlay transform
|
|
83
74
|
this._onTransformChange?.();
|
|
84
75
|
}
|
|
85
76
|
|
|
@@ -97,8 +88,7 @@ export class CanvasRenderer {
|
|
|
97
88
|
}
|
|
98
89
|
_applyTransform() {
|
|
99
90
|
const { ctx } = this;
|
|
100
|
-
|
|
101
|
-
ctx.setTransform(1, 0, 0, 1, 0, 0); // Reset first
|
|
91
|
+
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
102
92
|
ctx.translate(this.offsetX, this.offsetY);
|
|
103
93
|
ctx.scale(this.scale, this.scale);
|
|
104
94
|
}
|
|
@@ -107,9 +97,9 @@ export class CanvasRenderer {
|
|
|
107
97
|
}
|
|
108
98
|
|
|
109
99
|
// ── Drawing ────────────────────────────────────────────────────────────────
|
|
110
|
-
_drawArrowhead(x1, y1, x2, y2, size =
|
|
100
|
+
_drawArrowhead(x1, y1, x2, y2, size = 8) {
|
|
111
101
|
const { ctx } = this;
|
|
112
|
-
const s = size / this.scale;
|
|
102
|
+
const s = size / this.scale;
|
|
113
103
|
const ang = Math.atan2(y2 - y1, x2 - x1);
|
|
114
104
|
|
|
115
105
|
ctx.beginPath();
|
|
@@ -117,33 +107,25 @@ export class CanvasRenderer {
|
|
|
117
107
|
ctx.lineTo(x2 - s * Math.cos(ang - Math.PI / 6), y2 - s * Math.sin(ang - Math.PI / 6));
|
|
118
108
|
ctx.lineTo(x2 - s * Math.cos(ang + Math.PI / 6), y2 - s * Math.sin(ang + Math.PI / 6));
|
|
119
109
|
ctx.closePath();
|
|
120
|
-
ctx.fill();
|
|
110
|
+
ctx.fill();
|
|
121
111
|
}
|
|
122
112
|
|
|
123
113
|
_drawScreenText(
|
|
124
114
|
text,
|
|
125
115
|
lx,
|
|
126
116
|
ly,
|
|
127
|
-
{
|
|
128
|
-
fontPx = 12,
|
|
129
|
-
color = this.theme.text,
|
|
130
|
-
align = "left",
|
|
131
|
-
baseline = "alphabetic",
|
|
132
|
-
dpr = 1, // 추후 devicePixelRatio 도입
|
|
133
|
-
} = {}
|
|
117
|
+
{ fontPx = 11, color = this.theme.text, align = "left", baseline = "alphabetic" } = {}
|
|
134
118
|
) {
|
|
135
119
|
const { ctx } = this;
|
|
136
120
|
const { x: sx, y: sy } = this.worldToScreen(lx, ly);
|
|
137
121
|
|
|
138
122
|
ctx.save();
|
|
139
|
-
// 화면 좌표계(스케일=1)로 리셋
|
|
140
123
|
this._resetTransform();
|
|
141
124
|
|
|
142
|
-
// 픽셀 스냅(번짐 방지)
|
|
143
125
|
const px = Math.round(sx) + 0.5;
|
|
144
126
|
const py = Math.round(sy) + 0.5;
|
|
145
127
|
|
|
146
|
-
ctx.font = `${fontPx * this.scale}px system-ui`;
|
|
128
|
+
ctx.font = `${fontPx * this.scale}px "Inter", system-ui, sans-serif`;
|
|
147
129
|
ctx.fillStyle = color;
|
|
148
130
|
ctx.textAlign = align;
|
|
149
131
|
ctx.textBaseline = baseline;
|
|
@@ -153,40 +135,50 @@ export class CanvasRenderer {
|
|
|
153
135
|
|
|
154
136
|
drawGrid() {
|
|
155
137
|
const { ctx, canvas, theme, scale, offsetX, offsetY } = this;
|
|
156
|
-
// clear screen in screen space
|
|
157
138
|
|
|
158
139
|
this._resetTransform();
|
|
159
140
|
ctx.fillStyle = theme.bg;
|
|
160
141
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
161
142
|
|
|
162
|
-
// draw grid in world space so it pans/zooms
|
|
163
143
|
this._applyTransform();
|
|
164
|
-
// Make grid subtle but visible
|
|
165
|
-
ctx.strokeStyle = this._rgba(theme.grid, 0.35); // Subtle but visible
|
|
166
|
-
ctx.lineWidth = 1 / scale; // keep 1px apparent
|
|
167
|
-
|
|
168
|
-
const base = 20; // world units
|
|
169
|
-
const step = base;
|
|
170
144
|
|
|
171
|
-
// visible world bounds
|
|
172
145
|
const x0 = -offsetX / scale;
|
|
173
146
|
const y0 = -offsetY / scale;
|
|
174
147
|
const x1 = (canvas.width - offsetX) / scale;
|
|
175
148
|
const y1 = (canvas.height - offsetY) / scale;
|
|
176
149
|
|
|
177
|
-
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
150
|
+
// Minor dots (24px grid)
|
|
151
|
+
const minorStep = 24;
|
|
152
|
+
const majorStep = 120;
|
|
153
|
+
const minorR = 1 / scale;
|
|
154
|
+
const majorR = 1.5 / scale;
|
|
155
|
+
|
|
156
|
+
const startX = Math.floor(x0 / minorStep) * minorStep;
|
|
157
|
+
const startY = Math.floor(y0 / minorStep) * minorStep;
|
|
158
|
+
|
|
159
|
+
ctx.fillStyle = this._rgba(theme.grid, 0.7);
|
|
160
|
+
for (let gx = startX; gx <= x1; gx += minorStep) {
|
|
161
|
+
for (let gy = startY; gy <= y1; gy += minorStep) {
|
|
162
|
+
const isMajorX = Math.round(gx / majorStep) * majorStep === Math.round(gx);
|
|
163
|
+
const isMajorY = Math.round(gy / majorStep) * majorStep === Math.round(gy);
|
|
164
|
+
if (isMajorX && isMajorY) continue;
|
|
165
|
+
ctx.beginPath();
|
|
166
|
+
ctx.arc(gx, gy, minorR, 0, Math.PI * 2);
|
|
167
|
+
ctx.fill();
|
|
168
|
+
}
|
|
184
169
|
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
170
|
+
|
|
171
|
+
// Major intersection dots
|
|
172
|
+
const majorStartX = Math.floor(x0 / majorStep) * majorStep;
|
|
173
|
+
const majorStartY = Math.floor(y0 / majorStep) * majorStep;
|
|
174
|
+
ctx.fillStyle = this._rgba(theme.grid, 1.0);
|
|
175
|
+
for (let gx = majorStartX; gx <= x1; gx += majorStep) {
|
|
176
|
+
for (let gy = majorStartY; gy <= y1; gy += majorStep) {
|
|
177
|
+
ctx.beginPath();
|
|
178
|
+
ctx.arc(gx, gy, majorR, 0, Math.PI * 2);
|
|
179
|
+
ctx.fill();
|
|
180
|
+
}
|
|
188
181
|
}
|
|
189
|
-
ctx.stroke();
|
|
190
182
|
|
|
191
183
|
this._resetTransform();
|
|
192
184
|
}
|
|
@@ -196,15 +188,14 @@ export class CanvasRenderer {
|
|
|
196
188
|
{
|
|
197
189
|
selection = new Set(),
|
|
198
190
|
tempEdge = null,
|
|
199
|
-
running = false,
|
|
200
191
|
time = performance.now(),
|
|
201
|
-
|
|
202
|
-
groups = null,
|
|
192
|
+
activeNodes = new Set(), // Now explicitly passing active nodes
|
|
203
193
|
activeEdges = new Set(),
|
|
194
|
+
activeEdgeTimes = new Map(),
|
|
204
195
|
drawEdges = true,
|
|
196
|
+
loopActiveEdges = false,
|
|
205
197
|
} = {}
|
|
206
198
|
) {
|
|
207
|
-
// Update transforms first
|
|
208
199
|
graph.updateWorldTransforms();
|
|
209
200
|
|
|
210
201
|
this.drawGrid();
|
|
@@ -213,7 +204,7 @@ export class CanvasRenderer {
|
|
|
213
204
|
|
|
214
205
|
ctx.save();
|
|
215
206
|
|
|
216
|
-
// 1. Draw Groups
|
|
207
|
+
// 1. Draw Groups
|
|
217
208
|
for (const n of graph.nodes.values()) {
|
|
218
209
|
if (n.type === "core/Group") {
|
|
219
210
|
const sel = selection.has(n.id);
|
|
@@ -223,40 +214,48 @@ export class CanvasRenderer {
|
|
|
223
214
|
}
|
|
224
215
|
}
|
|
225
216
|
|
|
226
|
-
// 2. Draw Edges
|
|
217
|
+
// 2. Draw Edges
|
|
227
218
|
if (drawEdges) {
|
|
228
|
-
ctx.lineWidth =
|
|
229
|
-
|
|
230
|
-
// Calculate animation values if running
|
|
231
|
-
let dashArray = null;
|
|
232
|
-
let dashOffset = 0;
|
|
233
|
-
if (running) {
|
|
234
|
-
const speed = 120;
|
|
235
|
-
const phase = (((time / 1000) * speed) / this.scale) % CanvasRenderer.FONT_SIZE;
|
|
236
|
-
dashArray = [6 / this.scale, 6 / this.scale];
|
|
237
|
-
dashOffset = -phase;
|
|
238
|
-
}
|
|
219
|
+
ctx.lineWidth = 2.5 / this.scale;
|
|
239
220
|
|
|
240
221
|
for (const e of graph.edges.values()) {
|
|
241
|
-
const shouldAnimate = activeEdges && activeEdges.size > 0 && activeEdges.has(e.id);
|
|
242
|
-
|
|
243
|
-
if (running && shouldAnimate && dashArray) {
|
|
244
|
-
ctx.setLineDash(dashArray);
|
|
245
|
-
ctx.lineDashOffset = dashOffset;
|
|
246
|
-
} else {
|
|
247
|
-
ctx.setLineDash([]);
|
|
248
|
-
ctx.lineDashOffset = 0;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
222
|
const isActive = activeEdges && activeEdges.has(e.id);
|
|
223
|
+
|
|
252
224
|
if (isActive) {
|
|
253
|
-
|
|
225
|
+
// Glow pass
|
|
226
|
+
ctx.save();
|
|
227
|
+
ctx.shadowColor = this.theme.edgeActive;
|
|
228
|
+
ctx.shadowBlur = 8 / this.scale;
|
|
229
|
+
ctx.strokeStyle = this.theme.edgeActive;
|
|
254
230
|
ctx.lineWidth = 3 / this.scale;
|
|
231
|
+
ctx.setLineDash([]);
|
|
232
|
+
this._drawEdge(graph, e);
|
|
233
|
+
ctx.restore();
|
|
234
|
+
|
|
235
|
+
// Animated flowing dot
|
|
236
|
+
const activationTime = activeEdgeTimes?.get(e.id) ?? time;
|
|
237
|
+
const flowSpeed = this.theme.flowSpeed || 150;
|
|
238
|
+
const duration = (edgeLen / flowSpeed) * 1000;
|
|
239
|
+
const rawT = (time - activationTime) / duration;
|
|
240
|
+
const dotT = loopActiveEdges ? ((time / 1000) * (flowSpeed / edgeLen)) % 1 : ((time / 1000) * 1.2) % 1;
|
|
241
|
+
|
|
242
|
+
const dotPos = this._getEdgeDotPosition(graph, e, dotT);
|
|
243
|
+
if (dotPos) {
|
|
244
|
+
ctx.save();
|
|
245
|
+
ctx.fillStyle = "#ffffff";
|
|
246
|
+
ctx.shadowColor = this.theme.edgeActive;
|
|
247
|
+
ctx.shadowBlur = 10 / this.scale;
|
|
248
|
+
ctx.beginPath();
|
|
249
|
+
ctx.arc(dotPos.x, dotPos.y, 3 / this.scale, 0, Math.PI * 2);
|
|
250
|
+
ctx.fill();
|
|
251
|
+
ctx.restore();
|
|
252
|
+
}
|
|
255
253
|
} else {
|
|
254
|
+
ctx.setLineDash([]);
|
|
256
255
|
ctx.strokeStyle = theme.edge;
|
|
257
|
-
ctx.lineWidth =
|
|
256
|
+
ctx.lineWidth = 2.5 / this.scale;
|
|
257
|
+
this._drawEdge(graph, e);
|
|
258
258
|
}
|
|
259
|
-
this._drawEdge(graph, e);
|
|
260
259
|
}
|
|
261
260
|
}
|
|
262
261
|
|
|
@@ -266,7 +265,9 @@ export class CanvasRenderer {
|
|
|
266
265
|
const b = this.screenToWorld(tempEdge.x2, tempEdge.y2);
|
|
267
266
|
|
|
268
267
|
const prevDash = this.ctx.getLineDash();
|
|
269
|
-
this.ctx.setLineDash([
|
|
268
|
+
this.ctx.setLineDash([5 / this.scale, 5 / this.scale]);
|
|
269
|
+
this.ctx.strokeStyle = this._rgba(this.theme.accentBright, 0.7);
|
|
270
|
+
this.ctx.lineWidth = 2.5 / this.scale;
|
|
270
271
|
|
|
271
272
|
let ptsForArrow = null;
|
|
272
273
|
if (this.edgeStyle === "line") {
|
|
@@ -290,33 +291,23 @@ export class CanvasRenderer {
|
|
|
290
291
|
if (ptsForArrow && ptsForArrow.length >= 2) {
|
|
291
292
|
const p1 = ptsForArrow[ptsForArrow.length - 2];
|
|
292
293
|
const p2 = ptsForArrow[ptsForArrow.length - 1];
|
|
293
|
-
this.ctx.fillStyle = this.theme.
|
|
294
|
-
this.
|
|
295
|
-
this._drawArrowhead(p1.x, p1.y, p2.x, p2.y, 12);
|
|
294
|
+
this.ctx.fillStyle = this.theme.accentBright;
|
|
295
|
+
this._drawArrowhead(p1.x, p1.y, p2.x, p2.y, 10);
|
|
296
296
|
}
|
|
297
297
|
}
|
|
298
298
|
|
|
299
|
-
// 3. Draw
|
|
300
|
-
// For nodes with HTML overlays, SKIP canvas rendering entirely
|
|
299
|
+
// 3. Draw Non-Group Nodes
|
|
301
300
|
for (const n of graph.nodes.values()) {
|
|
302
301
|
if (n.type !== "core/Group") {
|
|
303
302
|
const sel = selection.has(n.id);
|
|
304
303
|
const def = this.registry?.types?.get(n.type);
|
|
305
304
|
const hasHtmlOverlay = !!def?.html;
|
|
306
305
|
|
|
307
|
-
|
|
308
|
-
if (!hasHtmlOverlay) {
|
|
309
|
-
this._drawNode(n, sel, true); // Draw WITHOUT ports (drawn on port canvas)
|
|
310
|
-
if (def?.onDraw) def.onDraw(n, { ctx, theme, renderer: this });
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
}
|
|
306
|
+
this._drawNode(n, sel, !hasHtmlOverlay ? true : false);
|
|
314
307
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
const def = this.registry?.types?.get(n.type);
|
|
319
|
-
const hasHtmlOverlay = !!def?.html;
|
|
308
|
+
if (def?.onDraw) {
|
|
309
|
+
def.onDraw(n, { ctx, theme, renderer: this });
|
|
310
|
+
}
|
|
320
311
|
|
|
321
312
|
if (hasHtmlOverlay) {
|
|
322
313
|
this._drawPorts(n);
|
|
@@ -324,6 +315,14 @@ export class CanvasRenderer {
|
|
|
324
315
|
}
|
|
325
316
|
}
|
|
326
317
|
|
|
318
|
+
// 4. Highlight Active Nodes (Marching Ants)
|
|
319
|
+
if (activeNodes.size > 0) {
|
|
320
|
+
for (const nodeId of activeNodes) {
|
|
321
|
+
const node = graph.nodes.get(nodeId);
|
|
322
|
+
if (node) this._drawActiveNodeBorder(node, time);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
327
326
|
this._resetTransform();
|
|
328
327
|
}
|
|
329
328
|
|
|
@@ -346,177 +345,275 @@ export class CanvasRenderer {
|
|
|
346
345
|
|
|
347
346
|
_drawNode(node, selected, skipPorts = false) {
|
|
348
347
|
const { ctx, theme } = this;
|
|
349
|
-
const r =
|
|
348
|
+
const r = 2; // Sharp 2px rounding
|
|
350
349
|
const { x, y, w, h } = node.computed;
|
|
350
|
+
const headerH = 26; // Slightly taller header for premium feel
|
|
351
351
|
|
|
352
|
-
//
|
|
353
|
-
|
|
352
|
+
// Get color from node or registry
|
|
353
|
+
const typeDef = this.registry?.types?.get(node.type);
|
|
354
|
+
const categoryColor = node.color || typeDef?.color || theme.accent;
|
|
355
|
+
|
|
356
|
+
// Selection glow — white, slightly offset outside node
|
|
357
|
+
if (selected) {
|
|
354
358
|
ctx.save();
|
|
355
|
-
ctx.shadowColor = "rgba(
|
|
359
|
+
ctx.shadowColor = "rgba(255,255,255,0.3)";
|
|
356
360
|
ctx.shadowBlur = 8 / this.scale;
|
|
357
|
-
ctx.
|
|
358
|
-
ctx.
|
|
359
|
-
|
|
360
|
-
ctx
|
|
361
|
+
ctx.strokeStyle = "#ffffff";
|
|
362
|
+
ctx.lineWidth = 1.5 / this.scale;
|
|
363
|
+
const pad = 8 / this.scale;
|
|
364
|
+
roundRect(ctx, x - pad, y - pad, w + pad * 2, h + pad * 2, r + pad);
|
|
365
|
+
ctx.stroke();
|
|
361
366
|
ctx.restore();
|
|
362
367
|
}
|
|
363
368
|
|
|
364
|
-
//
|
|
369
|
+
// Drop shadow
|
|
370
|
+
ctx.save();
|
|
371
|
+
ctx.shadowColor = "rgba(0,0,0,0.7)";
|
|
372
|
+
ctx.shadowBlur = 20 / this.scale;
|
|
373
|
+
ctx.shadowOffsetY = 6 / this.scale;
|
|
374
|
+
ctx.fillStyle = theme.node;
|
|
375
|
+
roundRect(ctx, x, y, w, h, r);
|
|
376
|
+
ctx.fill();
|
|
377
|
+
ctx.restore();
|
|
378
|
+
|
|
379
|
+
// Node body
|
|
365
380
|
ctx.fillStyle = theme.node;
|
|
366
|
-
ctx.strokeStyle = selected ?
|
|
367
|
-
ctx.lineWidth =
|
|
381
|
+
ctx.strokeStyle = selected ? "rgba(255,255,255,0.4)" : theme.nodeBorder;
|
|
382
|
+
ctx.lineWidth = 1 / this.scale;
|
|
368
383
|
roundRect(ctx, x, y, w, h, r);
|
|
369
384
|
ctx.fill();
|
|
370
385
|
ctx.stroke();
|
|
371
386
|
|
|
372
|
-
//
|
|
387
|
+
// Header
|
|
373
388
|
ctx.fillStyle = theme.title;
|
|
374
|
-
roundRect(ctx, x, y, w,
|
|
389
|
+
roundRect(ctx, x, y, w, headerH, { tl: r, tr: r, br: 0, bl: 0 });
|
|
375
390
|
ctx.fill();
|
|
376
391
|
|
|
377
|
-
//
|
|
378
|
-
ctx.
|
|
379
|
-
ctx.
|
|
392
|
+
// Subtle category-based header background
|
|
393
|
+
ctx.save();
|
|
394
|
+
ctx.globalCompositeOperation = "source-atop";
|
|
395
|
+
ctx.fillStyle = categoryColor;
|
|
396
|
+
ctx.globalAlpha = 0.25; // Increased from 0.12 for better visibility
|
|
397
|
+
ctx.fillRect(x, y, w, headerH);
|
|
398
|
+
ctx.restore();
|
|
399
|
+
|
|
400
|
+
// Header bottom separator
|
|
401
|
+
ctx.strokeStyle = selected ? "rgba(255,255,255,0.2)" : this._rgba(theme.nodeBorder, 0.6);
|
|
402
|
+
ctx.lineWidth = 1 / this.scale;
|
|
380
403
|
ctx.beginPath();
|
|
381
|
-
|
|
382
|
-
ctx.
|
|
383
|
-
ctx.lineTo(x + w - r, y);
|
|
384
|
-
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
|
|
385
|
-
// Right side down to header bottom
|
|
386
|
-
ctx.lineTo(x + w, y + 24);
|
|
387
|
-
// Move to left side header bottom
|
|
388
|
-
ctx.moveTo(x, y + 24);
|
|
389
|
-
// Left side up to top-left corner
|
|
390
|
-
ctx.lineTo(x, y + r);
|
|
391
|
-
ctx.quadraticCurveTo(x, y, x + r, y);
|
|
404
|
+
ctx.moveTo(x, y + headerH);
|
|
405
|
+
ctx.lineTo(x + w, y + headerH);
|
|
392
406
|
ctx.stroke();
|
|
393
407
|
|
|
394
|
-
|
|
408
|
+
// Accent strip at top
|
|
409
|
+
ctx.fillStyle = categoryColor;
|
|
410
|
+
ctx.beginPath();
|
|
411
|
+
ctx.roundRect(x, y, w, 2.5 / this.scale, { tl: r, tr: r, br: 0, bl: 0 });
|
|
412
|
+
ctx.fill();
|
|
413
|
+
|
|
414
|
+
// Title
|
|
415
|
+
this._drawScreenText(node.title, x + 10, y + headerH / 2, {
|
|
395
416
|
fontPx: CanvasRenderer.FONT_SIZE,
|
|
396
417
|
color: theme.text,
|
|
397
418
|
baseline: "middle",
|
|
398
419
|
align: "left",
|
|
399
420
|
});
|
|
400
421
|
|
|
401
|
-
// Skip port drawing if requested (for HTML overlay nodes)
|
|
402
422
|
if (skipPorts) return;
|
|
403
423
|
|
|
404
|
-
//
|
|
424
|
+
// Input ports + labels
|
|
405
425
|
node.inputs.forEach((p, i) => {
|
|
406
426
|
const rct = portRect(node, p, i, "in");
|
|
407
427
|
const cx = rct.x + rct.w / 2;
|
|
408
428
|
const cy = rct.y + rct.h / 2;
|
|
409
|
-
|
|
410
|
-
if (p.
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
ctx.roundRect(cx - portSize / 2, cy - portSize / 2, portSize, portSize, 2);
|
|
418
|
-
ctx.fill();
|
|
419
|
-
ctx.stroke();
|
|
420
|
-
} else {
|
|
421
|
-
// Draw data port - circle with outline
|
|
422
|
-
ctx.fillStyle = theme.port;
|
|
423
|
-
ctx.strokeStyle = "rgba(99, 102, 241, 0.3)";
|
|
424
|
-
ctx.lineWidth = 2 / this.scale;
|
|
425
|
-
ctx.beginPath();
|
|
426
|
-
ctx.arc(cx, cy, 5, 0, Math.PI * 2);
|
|
427
|
-
ctx.fill();
|
|
428
|
-
ctx.stroke();
|
|
429
|
+
this._drawPortShape(cx, cy, p.portType);
|
|
430
|
+
if (p.name) {
|
|
431
|
+
this._drawScreenText(p.name, cx + 10, cy, {
|
|
432
|
+
fontPx: 10,
|
|
433
|
+
color: theme.textMuted,
|
|
434
|
+
baseline: "middle",
|
|
435
|
+
align: "left",
|
|
436
|
+
});
|
|
429
437
|
}
|
|
430
438
|
});
|
|
431
439
|
|
|
432
|
-
//
|
|
440
|
+
// Output ports + labels
|
|
433
441
|
node.outputs.forEach((p, i) => {
|
|
434
442
|
const rct = portRect(node, p, i, "out");
|
|
435
443
|
const cx = rct.x + rct.w / 2;
|
|
436
444
|
const cy = rct.y + rct.h / 2;
|
|
437
|
-
|
|
438
|
-
if (p.
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
ctx.roundRect(cx - portSize / 2, cy - portSize / 2, portSize, portSize, 2);
|
|
446
|
-
ctx.fill();
|
|
447
|
-
ctx.stroke();
|
|
448
|
-
} else {
|
|
449
|
-
// Draw data port - circle with outline
|
|
450
|
-
ctx.fillStyle = theme.port;
|
|
451
|
-
ctx.strokeStyle = "rgba(99, 102, 241, 0.3)";
|
|
452
|
-
ctx.lineWidth = 2 / this.scale;
|
|
453
|
-
ctx.beginPath();
|
|
454
|
-
ctx.arc(cx, cy, 5, 0, Math.PI * 2);
|
|
455
|
-
ctx.fill();
|
|
456
|
-
ctx.stroke();
|
|
445
|
+
this._drawPortShape(cx, cy, p.portType);
|
|
446
|
+
if (p.name) {
|
|
447
|
+
this._drawScreenText(p.name, cx - 10, cy, {
|
|
448
|
+
fontPx: 10,
|
|
449
|
+
color: theme.textMuted,
|
|
450
|
+
baseline: "middle",
|
|
451
|
+
align: "right",
|
|
452
|
+
});
|
|
457
453
|
}
|
|
458
454
|
});
|
|
459
455
|
}
|
|
460
456
|
|
|
461
|
-
|
|
457
|
+
_drawActiveNodeBorder(node, time) {
|
|
462
458
|
const { ctx, theme } = this;
|
|
459
|
+
const { x, y, w, h } = node.computed;
|
|
460
|
+
const r = node.radius || 12;
|
|
461
|
+
const speed = 30; // pixels per second
|
|
462
|
+
const dashLen = 6;
|
|
463
|
+
const gapLen = 6;
|
|
463
464
|
|
|
464
|
-
|
|
465
|
+
ctx.save();
|
|
466
|
+
// 1. Exterior Glow
|
|
467
|
+
ctx.shadowColor = theme.accentBright || "#7080d8";
|
|
468
|
+
ctx.shadowBlur = (12 + Math.sin(time / 200) * 4) / this.scale; // Subtle pulse
|
|
469
|
+
|
|
470
|
+
// 2. Marching Ants Border
|
|
471
|
+
ctx.strokeStyle = theme.accentBright || "#7080d8";
|
|
472
|
+
ctx.lineWidth = 3 / this.scale;
|
|
473
|
+
// Set a dashed line that moves
|
|
474
|
+
ctx.setLineDash([dashLen / this.scale, gapLen / this.scale]);
|
|
475
|
+
// Offset increases over time to move clockwise
|
|
476
|
+
ctx.lineDashOffset = -(time / 1000) * speed / this.scale;
|
|
477
|
+
|
|
478
|
+
// Use a custom roundRect for the border
|
|
479
|
+
ctx.beginPath();
|
|
480
|
+
const pad = 2 / this.scale;
|
|
481
|
+
this._roundRect(ctx, x - pad, y - pad, w + pad * 2, h + pad * 2, r + pad);
|
|
482
|
+
ctx.stroke();
|
|
483
|
+
ctx.restore();
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Internal helper for rounded rectangles if not using the browser's native one
|
|
487
|
+
_roundRect(ctx, x, y, w, h, r) {
|
|
488
|
+
if (typeof r === "number") {
|
|
489
|
+
r = { tl: r, tr: r, br: r, bl: r };
|
|
490
|
+
}
|
|
491
|
+
ctx.beginPath();
|
|
492
|
+
ctx.moveTo(x + r.tl, y);
|
|
493
|
+
ctx.lineTo(x + w - r.tr, y);
|
|
494
|
+
ctx.quadraticCurveTo(x + w, y, x + w, y + r.tr);
|
|
495
|
+
ctx.lineTo(x + w, y + h - r.br);
|
|
496
|
+
ctx.quadraticCurveTo(x + w, y + h, x + w - r.br, y + h);
|
|
497
|
+
ctx.lineTo(x + r.bl, y + h);
|
|
498
|
+
ctx.quadraticCurveTo(x, y + h, x, y + h - r.bl);
|
|
499
|
+
ctx.lineTo(x, y + r.tl);
|
|
500
|
+
ctx.quadraticCurveTo(x, y, x + r.tl, y);
|
|
501
|
+
ctx.closePath();
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
_drawPortShape(cx, cy, portType) {
|
|
505
|
+
const { ctx, theme } = this;
|
|
506
|
+
|
|
507
|
+
if (portType === "exec") {
|
|
508
|
+
// Diamond shape for exec ports
|
|
509
|
+
const s = 5 / this.scale;
|
|
510
|
+
ctx.save();
|
|
511
|
+
ctx.fillStyle = theme.portExec;
|
|
512
|
+
ctx.strokeStyle = this._rgba(theme.portExec, 0.4);
|
|
513
|
+
ctx.lineWidth = 2 / this.scale;
|
|
514
|
+
ctx.beginPath();
|
|
515
|
+
ctx.moveTo(cx, cy - s);
|
|
516
|
+
ctx.lineTo(cx + s, cy);
|
|
517
|
+
ctx.lineTo(cx, cy + s);
|
|
518
|
+
ctx.lineTo(cx - s, cy);
|
|
519
|
+
ctx.closePath();
|
|
520
|
+
ctx.fill();
|
|
521
|
+
ctx.stroke();
|
|
522
|
+
ctx.restore();
|
|
523
|
+
} else {
|
|
524
|
+
// Circle for data ports
|
|
525
|
+
ctx.save();
|
|
526
|
+
// Outer ring
|
|
527
|
+
ctx.strokeStyle = this._rgba(theme.port, 0.35);
|
|
528
|
+
ctx.lineWidth = 3 / this.scale;
|
|
529
|
+
ctx.beginPath();
|
|
530
|
+
ctx.arc(cx, cy, 5 / this.scale, 0, Math.PI * 2);
|
|
531
|
+
ctx.stroke();
|
|
532
|
+
// Inner fill
|
|
533
|
+
ctx.fillStyle = theme.port;
|
|
534
|
+
ctx.beginPath();
|
|
535
|
+
ctx.arc(cx, cy, 3.5 / this.scale, 0, Math.PI * 2);
|
|
536
|
+
ctx.fill();
|
|
537
|
+
ctx.restore();
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
_drawPorts(node) {
|
|
465
542
|
node.inputs.forEach((p, i) => {
|
|
466
543
|
const rct = portRect(node, p, i, "in");
|
|
467
544
|
const cx = rct.x + rct.w / 2;
|
|
468
545
|
const cy = rct.y + rct.h / 2;
|
|
469
|
-
|
|
470
|
-
if (p.portType === "exec") {
|
|
471
|
-
// Draw exec port - rounded square with subtle glow
|
|
472
|
-
const portSize = 8;
|
|
473
|
-
ctx.fillStyle = theme.portExec;
|
|
474
|
-
ctx.strokeStyle = "rgba(16, 185, 129, 0.3)";
|
|
475
|
-
ctx.lineWidth = 2 / this.scale;
|
|
476
|
-
ctx.beginPath();
|
|
477
|
-
ctx.roundRect(cx - portSize / 2, cy - portSize / 2, portSize, portSize, 2);
|
|
478
|
-
ctx.fill();
|
|
479
|
-
ctx.stroke();
|
|
480
|
-
} else {
|
|
481
|
-
// Draw data port - circle with subtle outline
|
|
482
|
-
ctx.fillStyle = theme.port;
|
|
483
|
-
ctx.strokeStyle = "rgba(99, 102, 241, 0.3)";
|
|
484
|
-
ctx.lineWidth = 2 / this.scale;
|
|
485
|
-
ctx.beginPath();
|
|
486
|
-
ctx.arc(cx, cy, 5, 0, Math.PI * 2);
|
|
487
|
-
ctx.fill();
|
|
488
|
-
}
|
|
546
|
+
this._drawPortShape(cx, cy, p.portType);
|
|
489
547
|
});
|
|
490
548
|
|
|
491
|
-
// Draw output ports
|
|
492
549
|
node.outputs.forEach((p, i) => {
|
|
493
550
|
const rct = portRect(node, p, i, "out");
|
|
494
551
|
const cx = rct.x + rct.w / 2;
|
|
495
552
|
const cy = rct.y + rct.h / 2;
|
|
496
|
-
|
|
497
|
-
if (p.portType === "exec") {
|
|
498
|
-
// Draw exec port - rounded square
|
|
499
|
-
const portSize = 8;
|
|
500
|
-
ctx.fillStyle = theme.portExec;
|
|
501
|
-
ctx.strokeStyle = "rgba(16, 185, 129, 0.3)";
|
|
502
|
-
ctx.lineWidth = 2 / this.scale;
|
|
503
|
-
ctx.beginPath();
|
|
504
|
-
ctx.roundRect(cx - portSize / 2, cy - portSize / 2, portSize, portSize, 2);
|
|
505
|
-
ctx.fill();
|
|
506
|
-
ctx.stroke();
|
|
507
|
-
} else {
|
|
508
|
-
// Draw data port - circle with outline
|
|
509
|
-
ctx.fillStyle = theme.port;
|
|
510
|
-
ctx.strokeStyle = "rgba(99, 102, 241, 0.3)";
|
|
511
|
-
ctx.lineWidth = 2 / this.scale;
|
|
512
|
-
ctx.beginPath();
|
|
513
|
-
ctx.arc(cx, cy, 5, 0, Math.PI * 2);
|
|
514
|
-
ctx.fill();
|
|
515
|
-
ctx.stroke();
|
|
516
|
-
}
|
|
553
|
+
this._drawPortShape(cx, cy, p.portType);
|
|
517
554
|
});
|
|
518
555
|
}
|
|
519
556
|
|
|
557
|
+
/** Selection border for HTML overlay nodes, drawn on the edge canvas */
|
|
558
|
+
_drawHtmlSelectionBorder(node) {
|
|
559
|
+
const { ctx } = this;
|
|
560
|
+
const { x, y, w, h } = node.computed;
|
|
561
|
+
const r = 2;
|
|
562
|
+
const pad = 2.5 / this.scale;
|
|
563
|
+
|
|
564
|
+
ctx.save();
|
|
565
|
+
ctx.shadowColor = "rgba(255,255,255,0.3)";
|
|
566
|
+
ctx.shadowBlur = 8 / this.scale;
|
|
567
|
+
ctx.strokeStyle = "#ffffff";
|
|
568
|
+
ctx.lineWidth = 1.5 / this.scale;
|
|
569
|
+
roundRect(ctx, x - pad, y - pad, w + pad * 2, h + pad * 2, r);
|
|
570
|
+
ctx.stroke();
|
|
571
|
+
ctx.restore();
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** Rotating dashed border drawn on the edge canvas for executing nodes */
|
|
575
|
+
_drawActiveNodeBorder(node, time) {
|
|
576
|
+
const { ctx } = this;
|
|
577
|
+
const { x, y, w, h } = node.computed;
|
|
578
|
+
const r = 2;
|
|
579
|
+
const pad = 8 / this.scale; // Same gap as selection border
|
|
580
|
+
|
|
581
|
+
const dashLen = 8 / this.scale;
|
|
582
|
+
const gapLen = 6 / this.scale;
|
|
583
|
+
const offset = -(time / 1000) * (50 / this.scale);
|
|
584
|
+
|
|
585
|
+
ctx.save();
|
|
586
|
+
ctx.setLineDash([dashLen, gapLen]);
|
|
587
|
+
ctx.lineDashOffset = offset;
|
|
588
|
+
ctx.strokeStyle = "rgba(74,176,217,0.9)"; // Dark sky blue
|
|
589
|
+
ctx.lineWidth = 2.0 / this.scale;
|
|
590
|
+
ctx.shadowColor = "#4ab0d9";
|
|
591
|
+
ctx.shadowBlur = 6 / this.scale;
|
|
592
|
+
roundRect(ctx, x - pad, y - pad, w + pad * 2, h + pad * 2, r + pad);
|
|
593
|
+
ctx.stroke();
|
|
594
|
+
ctx.restore();
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** Approximate arc length of an edge in world coordinates */
|
|
598
|
+
_getEdgeLength(graph, e) {
|
|
599
|
+
const from = graph.nodes.get(e.fromNode);
|
|
600
|
+
const to = graph.nodes.get(e.toNode);
|
|
601
|
+
if (!from || !to) return 200;
|
|
602
|
+
const iOut = from.outputs.findIndex((p) => p.id === e.fromPort);
|
|
603
|
+
const iIn = to.inputs.findIndex((p) => p.id === e.toPort);
|
|
604
|
+
const pr1 = portRect(from, null, iOut, "out");
|
|
605
|
+
const pr2 = portRect(to, null, iIn, "in");
|
|
606
|
+
const x1 = pr1.x + pr1.w / 2,
|
|
607
|
+
y1 = pr1.y + pr1.h / 2;
|
|
608
|
+
const x2 = pr2.x + pr2.w / 2,
|
|
609
|
+
y2 = pr2.y + pr2.h / 2;
|
|
610
|
+
if (this.edgeStyle === "orthogonal") {
|
|
611
|
+
return Math.abs(x2 - x1) + Math.abs(y2 - y1);
|
|
612
|
+
}
|
|
613
|
+
const chord = Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
|
|
614
|
+
return this.edgeStyle === "bezier" ? chord * 1.4 : chord;
|
|
615
|
+
}
|
|
616
|
+
|
|
520
617
|
_drawEdge(graph, e) {
|
|
521
618
|
const from = graph.nodes.get(e.fromNode);
|
|
522
619
|
const to = graph.nodes.get(e.toNode);
|
|
@@ -526,15 +623,47 @@ export class CanvasRenderer {
|
|
|
526
623
|
const pr1 = portRect(from, null, iOut, "out");
|
|
527
624
|
const pr2 = portRect(to, null, iIn, "in");
|
|
528
625
|
const x1 = pr1.x + pr1.w / 2,
|
|
529
|
-
y1 = pr1.y + pr1.h / 2
|
|
530
|
-
|
|
531
|
-
y2 = pr2.y + pr2.h / 2;
|
|
626
|
+
y1 = pr1.y + pr1.h / 2;
|
|
627
|
+
const x2 = pr2.x + pr2.w / 2,
|
|
628
|
+
y2 = pr2.y + pr2.h / 2;
|
|
629
|
+
|
|
532
630
|
if (this.edgeStyle === "line") {
|
|
533
631
|
this._drawLine(x1, y1, x2, y2);
|
|
534
632
|
} else if (this.edgeStyle === "orthogonal") {
|
|
535
633
|
this._drawOrthogonal(x1, y1, x2, y2);
|
|
536
634
|
} else {
|
|
537
|
-
this._drawCurve(x1, y1, x2, y2);
|
|
635
|
+
this._drawCurve(x1, y1, x2, y2);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
_getEdgeDotPosition(graph, e, t) {
|
|
640
|
+
const from = graph.nodes.get(e.fromNode);
|
|
641
|
+
const to = graph.nodes.get(e.toNode);
|
|
642
|
+
if (!from || !to) return null;
|
|
643
|
+
|
|
644
|
+
const iOut = from.outputs.findIndex((p) => p.id === e.fromPort);
|
|
645
|
+
const iIn = to.inputs.findIndex((p) => p.id === e.toPort);
|
|
646
|
+
const pr1 = portRect(from, null, iOut, "out");
|
|
647
|
+
const pr2 = portRect(to, null, iIn, "in");
|
|
648
|
+
const x1 = pr1.x + pr1.w / 2,
|
|
649
|
+
y1 = pr1.y + pr1.h / 2;
|
|
650
|
+
const x2 = pr2.x + pr2.w / 2,
|
|
651
|
+
y2 = pr2.y + pr2.h / 2;
|
|
652
|
+
|
|
653
|
+
if (this.edgeStyle === "bezier") {
|
|
654
|
+
const dx = Math.max(40, Math.abs(x2 - x1) * 0.4);
|
|
655
|
+
return cubicBezierPoint(x1, y1, x1 + dx, y1, x2 - dx, y2, x2, y2, t);
|
|
656
|
+
} else if (this.edgeStyle === "orthogonal") {
|
|
657
|
+
const midX = (x1 + x2) / 2;
|
|
658
|
+
const pts = [
|
|
659
|
+
{ x: x1, y: y1 },
|
|
660
|
+
{ x: midX, y: y1 },
|
|
661
|
+
{ x: midX, y: y2 },
|
|
662
|
+
{ x: x2, y: y2 },
|
|
663
|
+
];
|
|
664
|
+
return polylinePoint(pts, t);
|
|
665
|
+
} else {
|
|
666
|
+
return { x: x1 + (x2 - x1) * t, y: y1 + (y2 - y1) * t };
|
|
538
667
|
}
|
|
539
668
|
}
|
|
540
669
|
|
|
@@ -555,32 +684,14 @@ export class CanvasRenderer {
|
|
|
555
684
|
}
|
|
556
685
|
|
|
557
686
|
_drawOrthogonal(x1, y1, x2, y2) {
|
|
558
|
-
// 중간 축을 결정 (더 짧은 축을 가운데에 두면 보기 좋음)
|
|
559
|
-
const useHVH = true; // 가로-세로-가로(HVH) vs 세로-가로-세로(VHV)
|
|
560
687
|
const midX = (x1 + x2) / 2;
|
|
561
|
-
const
|
|
688
|
+
const pts = [
|
|
689
|
+
{ x: x1, y: y1 },
|
|
690
|
+
{ x: midX, y: y1 },
|
|
691
|
+
{ x: midX, y: y2 },
|
|
692
|
+
{ x: x2, y: y2 },
|
|
693
|
+
];
|
|
562
694
|
|
|
563
|
-
let pts;
|
|
564
|
-
if (useHVH) {
|
|
565
|
-
// x1,y1 → midX,y1 → midX,y2 → x2,y2
|
|
566
|
-
pts = [
|
|
567
|
-
{ x: x1, y: y1 },
|
|
568
|
-
{ x: midX, y: y1 },
|
|
569
|
-
{ x: midX, y: y2 },
|
|
570
|
-
{ x: x2, y: y2 },
|
|
571
|
-
];
|
|
572
|
-
}
|
|
573
|
-
// else {
|
|
574
|
-
// // x1,y1 → x1,midY → x2,midY → x2,y2
|
|
575
|
-
// pts = [
|
|
576
|
-
// { x: x1, y: y1 },
|
|
577
|
-
// { x: x1, y: midY },
|
|
578
|
-
// { x: x2, y: midY },
|
|
579
|
-
// { x: x2, y: y2 },
|
|
580
|
-
// ];
|
|
581
|
-
// }
|
|
582
|
-
|
|
583
|
-
// 라운드 코너
|
|
584
695
|
const { ctx } = this;
|
|
585
696
|
const prevJoin = ctx.lineJoin,
|
|
586
697
|
prevCap = ctx.lineCap;
|
|
@@ -590,8 +701,9 @@ export class CanvasRenderer {
|
|
|
590
701
|
ctx.lineJoin = prevJoin;
|
|
591
702
|
ctx.lineCap = prevCap;
|
|
592
703
|
|
|
593
|
-
return pts;
|
|
704
|
+
return pts;
|
|
594
705
|
}
|
|
706
|
+
|
|
595
707
|
_drawCurve(x1, y1, x2, y2) {
|
|
596
708
|
const { ctx } = this;
|
|
597
709
|
const dx = Math.max(40, Math.abs(x2 - x1) * 0.4);
|
|
@@ -601,16 +713,18 @@ export class CanvasRenderer {
|
|
|
601
713
|
ctx.stroke();
|
|
602
714
|
}
|
|
603
715
|
|
|
604
|
-
/**
|
|
605
|
-
* Draw only edges on a separate canvas (for layering above HTML overlay)
|
|
606
|
-
* @param {Graph} graph - The graph
|
|
607
|
-
* @param {Object} options - Rendering options
|
|
608
|
-
*/
|
|
609
716
|
drawEdgesOnly(
|
|
610
717
|
graph,
|
|
611
|
-
{
|
|
718
|
+
{
|
|
719
|
+
activeEdges = new Set(),
|
|
720
|
+
activeEdgeTimes = new Map(),
|
|
721
|
+
activeNodes = new Set(),
|
|
722
|
+
selection = new Set(),
|
|
723
|
+
time = performance.now(),
|
|
724
|
+
tempEdge = null,
|
|
725
|
+
loopActiveEdges = false,
|
|
726
|
+
} = {}
|
|
612
727
|
) {
|
|
613
|
-
// Clear canvas
|
|
614
728
|
this._resetTransform();
|
|
615
729
|
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
|
616
730
|
|
|
@@ -618,35 +732,64 @@ export class CanvasRenderer {
|
|
|
618
732
|
|
|
619
733
|
const { ctx, theme } = this;
|
|
620
734
|
|
|
621
|
-
// Calculate animation values
|
|
622
|
-
let dashArray = null;
|
|
623
|
-
let dashOffset = 0;
|
|
624
|
-
if (running || activeEdges.size > 0) {
|
|
625
|
-
const speed = 120;
|
|
626
|
-
const phase = (((time / 1000) * speed) / this.scale) % 12;
|
|
627
|
-
dashArray = [6 / this.scale, 6 / this.scale];
|
|
628
|
-
dashOffset = -phase;
|
|
629
|
-
}
|
|
630
|
-
|
|
631
735
|
// Draw all edges
|
|
632
|
-
ctx.lineWidth = 1.5 / this.scale;
|
|
633
|
-
// Set default edge style
|
|
634
|
-
ctx.strokeStyle = theme.edge;
|
|
635
736
|
for (const e of graph.edges.values()) {
|
|
636
|
-
const isActive = activeEdges
|
|
637
|
-
|
|
638
|
-
if (isActive
|
|
639
|
-
|
|
640
|
-
ctx.
|
|
641
|
-
ctx.
|
|
737
|
+
const isActive = activeEdges.has(e.id);
|
|
738
|
+
|
|
739
|
+
if (isActive) {
|
|
740
|
+
// Glow pass
|
|
741
|
+
ctx.save();
|
|
742
|
+
ctx.shadowColor = theme.edgeActive;
|
|
743
|
+
ctx.shadowBlur = 6 / this.scale;
|
|
744
|
+
ctx.strokeStyle = theme.edgeActive;
|
|
642
745
|
ctx.lineWidth = 3 / this.scale;
|
|
746
|
+
ctx.setLineDash([]);
|
|
747
|
+
this._drawEdge(graph, e);
|
|
748
|
+
ctx.restore();
|
|
749
|
+
|
|
750
|
+
// Dot position: use configurable flow speed (default 150 px/sec)
|
|
751
|
+
const flowSpeed = this.theme.flowSpeed || 150;
|
|
752
|
+
const activationTime = activeEdgeTimes.get(e.id) ?? time;
|
|
753
|
+
const edgeLen = Math.max(50, this._getEdgeLength(graph, e));
|
|
754
|
+
const duration = (edgeLen / flowSpeed) * 1000; // ms for this edge
|
|
755
|
+
|
|
756
|
+
// Loop animation if requested (e.g. in Step Mode)
|
|
757
|
+
const rawT = (time - activationTime) / duration;
|
|
758
|
+
const dotT = loopActiveEdges ? ((time / 1000) * (flowSpeed / edgeLen)) % 1 : Math.min(1, rawT);
|
|
759
|
+
|
|
760
|
+
const dotPos = this._getEdgeDotPosition(graph, e, dotT);
|
|
761
|
+
if (dotPos) {
|
|
762
|
+
ctx.save();
|
|
763
|
+
ctx.fillStyle = this._rgba(theme.edgeActive, 0.9);
|
|
764
|
+
ctx.shadowColor = theme.edgeActive;
|
|
765
|
+
ctx.shadowBlur = 8 / this.scale;
|
|
766
|
+
ctx.beginPath();
|
|
767
|
+
ctx.arc(dotPos.x, dotPos.y, 2.5 / this.scale, 0, Math.PI * 2);
|
|
768
|
+
ctx.fill();
|
|
769
|
+
ctx.restore();
|
|
770
|
+
}
|
|
643
771
|
} else {
|
|
644
772
|
ctx.setLineDash([]);
|
|
645
773
|
ctx.strokeStyle = theme.edge;
|
|
646
|
-
ctx.lineWidth =
|
|
774
|
+
ctx.lineWidth = 2.5 / this.scale;
|
|
775
|
+
this._drawEdge(graph, e);
|
|
647
776
|
}
|
|
777
|
+
}
|
|
648
778
|
|
|
649
|
-
|
|
779
|
+
// Selection borders for HTML overlay nodes (drawn above the HTML layer)
|
|
780
|
+
for (const nodeId of selection) {
|
|
781
|
+
const node = graph.nodes.get(nodeId);
|
|
782
|
+
if (!node) continue;
|
|
783
|
+
const def = this.registry?.types?.get(node.type);
|
|
784
|
+
if (def?.html) this._drawHtmlSelectionBorder(node);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// Rotating dashed border for executing nodes
|
|
788
|
+
if (activeNodes.size > 0) {
|
|
789
|
+
for (const nodeId of activeNodes) {
|
|
790
|
+
const node = graph.nodes.get(nodeId);
|
|
791
|
+
if (node) this._drawActiveNodeBorder(node, time);
|
|
792
|
+
}
|
|
650
793
|
}
|
|
651
794
|
|
|
652
795
|
// temp edge preview
|
|
@@ -655,7 +798,9 @@ export class CanvasRenderer {
|
|
|
655
798
|
const b = this.screenToWorld(tempEdge.x2, tempEdge.y2);
|
|
656
799
|
|
|
657
800
|
const prevDash = this.ctx.getLineDash();
|
|
658
|
-
this.ctx.setLineDash([
|
|
801
|
+
this.ctx.setLineDash([5 / this.scale, 5 / this.scale]);
|
|
802
|
+
this.ctx.strokeStyle = this._rgba(this.theme.accentBright, 0.7);
|
|
803
|
+
this.ctx.lineWidth = 2.5 / this.scale;
|
|
659
804
|
|
|
660
805
|
let ptsForArrow = null;
|
|
661
806
|
if (this.edgeStyle === "line") {
|
|
@@ -679,15 +824,15 @@ export class CanvasRenderer {
|
|
|
679
824
|
if (ptsForArrow && ptsForArrow.length >= 2) {
|
|
680
825
|
const p1 = ptsForArrow[ptsForArrow.length - 2];
|
|
681
826
|
const p2 = ptsForArrow[ptsForArrow.length - 1];
|
|
682
|
-
this.ctx.fillStyle = this.theme.
|
|
683
|
-
this.
|
|
684
|
-
this._drawArrowhead(p1.x, p1.y, p2.x, p2.y, 12);
|
|
827
|
+
this.ctx.fillStyle = this.theme.accentBright;
|
|
828
|
+
this._drawArrowhead(p1.x, p1.y, p2.x, p2.y, 10);
|
|
685
829
|
}
|
|
686
830
|
}
|
|
687
831
|
|
|
688
832
|
this._resetTransform();
|
|
689
833
|
}
|
|
690
834
|
}
|
|
835
|
+
|
|
691
836
|
function roundRect(ctx, x, y, w, h, r = 6) {
|
|
692
837
|
if (typeof r === "number") r = { tl: r, tr: r, br: r, bl: r };
|
|
693
838
|
ctx.beginPath();
|
|
@@ -702,3 +847,38 @@ function roundRect(ctx, x, y, w, h, r = 6) {
|
|
|
702
847
|
ctx.quadraticCurveTo(x, y, x + r.tl, y);
|
|
703
848
|
ctx.closePath();
|
|
704
849
|
}
|
|
850
|
+
|
|
851
|
+
function cubicBezierPoint(x0, y0, x1, y1, x2, y2, x3, y3, t) {
|
|
852
|
+
const mt = 1 - t;
|
|
853
|
+
return {
|
|
854
|
+
x: mt * mt * mt * x0 + 3 * mt * mt * t * x1 + 3 * mt * t * t * x2 + t * t * t * x3,
|
|
855
|
+
y: mt * mt * mt * y0 + 3 * mt * mt * t * y1 + 3 * mt * t * t * y2 + t * t * t * y3,
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function polylinePoint(pts, t) {
|
|
860
|
+
let totalLen = 0;
|
|
861
|
+
const lens = [];
|
|
862
|
+
for (let i = 0; i < pts.length - 1; i++) {
|
|
863
|
+
const dx = pts[i + 1].x - pts[i].x;
|
|
864
|
+
const dy = pts[i + 1].y - pts[i].y;
|
|
865
|
+
const len = Math.sqrt(dx * dx + dy * dy);
|
|
866
|
+
lens.push(len);
|
|
867
|
+
totalLen += len;
|
|
868
|
+
}
|
|
869
|
+
if (totalLen === 0) return pts[0];
|
|
870
|
+
|
|
871
|
+
let target = t * totalLen;
|
|
872
|
+
let accum = 0;
|
|
873
|
+
for (let i = 0; i < lens.length; i++) {
|
|
874
|
+
if (accum + lens[i] >= target) {
|
|
875
|
+
const segT = lens[i] > 0 ? (target - accum) / lens[i] : 0;
|
|
876
|
+
return {
|
|
877
|
+
x: pts[i].x + (pts[i + 1].x - pts[i].x) * segT,
|
|
878
|
+
y: pts[i].y + (pts[i + 1].y - pts[i].y) * segT,
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
accum += lens[i];
|
|
882
|
+
}
|
|
883
|
+
return pts[pts.length - 1];
|
|
884
|
+
}
|