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.
@@ -1,14 +1,13 @@
1
1
  import { portRect } from "./hitTest.js";
2
2
 
3
3
  export class CanvasRenderer {
4
- static FONT_SIZE = 12;
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; // to call per-node onDraw
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: "#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
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
- // CRITICAL: Must match HTMLOverlay transformation order (translate then scale)
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 = 10) {
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(); // 선 색상과 동일한 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
- const startX = Math.floor(x0 / step) * step;
178
- const startY = Math.floor(y0 / step) * step;
179
-
180
- ctx.beginPath();
181
- for (let x = startX; x <= x1; x += step) {
182
- ctx.moveTo(x, y0);
183
- ctx.lineTo(x, y1);
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
- for (let y = startY; y <= y1; y += step) {
186
- ctx.moveTo(x0, y);
187
- ctx.lineTo(x1, y);
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
- dt = 0,
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 (Backgrounds)
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 (conditionally - can be skipped for port canvas rendering)
217
+ // 2. Draw Edges
227
218
  if (drawEdges) {
228
- ctx.lineWidth = 1.5 / this.scale;
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
- ctx.strokeStyle = "#00ffff";
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 = 1.5 / this.scale;
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([6 / this.scale, 6 / this.scale]);
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.edge;
294
- this.ctx.strokeStyle = this.theme.edge; // Ensure color is set
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 Other Nodes (AFTER edges)
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
- // Only draw node body on canvas if it DOESN'T have HTML overlay
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
- // 4. Draw ports for HTML overlay nodes LAST (so they appear above HTML)
316
- for (const n of graph.nodes.values()) {
317
- if (n.type !== "core/Group") {
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 = 8;
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
- // Draw subtle shadow
353
- if (!selected) {
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(0, 0, 0, 0.3)";
359
+ ctx.shadowColor = "rgba(255,255,255,0.3)";
356
360
  ctx.shadowBlur = 8 / this.scale;
357
- ctx.shadowOffsetY = 2 / this.scale;
358
- ctx.fillStyle = "rgba(0, 0, 0, 0.2)";
359
- roundRect(ctx, x, y, w, h, r);
360
- ctx.fill();
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
- // Draw main body
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 ? theme.accentBright : theme.nodeBorder;
367
- ctx.lineWidth = (selected ? 1.5 : 1) / this.scale;
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
- // Draw header
387
+ // Header
373
388
  ctx.fillStyle = theme.title;
374
- roundRect(ctx, x, y, w, 24, { tl: r, tr: r, br: 0, bl: 0 });
389
+ roundRect(ctx, x, y, w, headerH, { tl: r, tr: r, br: 0, bl: 0 });
375
390
  ctx.fill();
376
391
 
377
- // Header border (only top and sides)
378
- ctx.strokeStyle = selected ? theme.accentBright : theme.nodeBorder;
379
- ctx.lineWidth = (selected ? 1.5 : 1) / this.scale;
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
- // Top-left corner to top-right corner
382
- ctx.moveTo(x + r, y);
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
- this._drawScreenText(node.title, x + 8, y + CanvasRenderer.FONT_SIZE, {
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
- // Draw input ports
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.portType === "exec") {
411
- // Draw exec port - rounded square
412
- const portSize = 8;
413
- ctx.fillStyle = theme.portExec;
414
- ctx.strokeStyle = "rgba(16, 185, 129, 0.3)";
415
- ctx.lineWidth = 2 / this.scale;
416
- ctx.beginPath();
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
- // Draw output ports
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.portType === "exec") {
439
- // Draw exec port - rounded square
440
- const portSize = 8;
441
- ctx.fillStyle = theme.portExec;
442
- ctx.strokeStyle = "rgba(16, 185, 129, 0.3)";
443
- ctx.lineWidth = 2 / this.scale;
444
- ctx.beginPath();
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
- _drawPorts(node) {
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
- // Draw input ports
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, // Center of port
530
- x2 = pr2.x + pr2.w / 2,
531
- y2 = pr2.y + pr2.h / 2; // Center of port
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); // bezier (기존)
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 midY = (y1 + y2) / 2;
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
- { activeEdges = new Set(), running = false, time = performance.now(), tempEdge = null } = {}
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 && activeEdges.has(e.id);
637
-
638
- if (isActive && dashArray) {
639
- ctx.setLineDash(dashArray);
640
- ctx.lineDashOffset = dashOffset;
641
- ctx.strokeStyle = "#00ffff";
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 = 1.5 / this.scale;
774
+ ctx.lineWidth = 2.5 / this.scale;
775
+ this._drawEdge(graph, e);
647
776
  }
777
+ }
648
778
 
649
- this._drawEdge(graph, e);
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([6 / this.scale, 6 / this.scale]);
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.edge;
683
- this.ctx.strokeStyle = this.theme.edge; // Ensure color is set
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
+ }