html-overlay-node 0.1.6 → 0.1.10

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,604 +1,784 @@
1
- import { 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
- // 중간 축을 결정 (더 짧은 축을 가운데에 두면 보기 좋음)
546
- const useHVH = true; // 가로-세로-가로(HVH) vs 세로-가로-세로(VHV)
547
- const midX = (x1 + x2) / 2;
548
- const midY = (y1 + y2) / 2;
549
-
550
- let pts;
551
- if (useHVH) {
552
- // x1,y1 → midX,y1 → midX,y2 x2,y2
553
- pts = [
554
- { x: x1, y: y1 },
555
- { x: midX, y: y1 },
556
- { x: midX, y: y2 },
557
- { x: x2, y: y2 },
558
- ];
559
- }
560
- // else {
561
- // // x1,y1 x1,midY x2,midY x2,y2
562
- // pts = [
563
- // { x: x1, y: y1 },
564
- // { x: x1, y: midY },
565
- // { x: x2, y: midY },
566
- // { x: x2, y: y2 },
567
- // ];
568
- // }
569
-
570
- // 라운드 코너
571
- const { ctx } = this;
572
- const prevJoin = ctx.lineJoin,
573
- prevCap = ctx.lineCap;
574
- ctx.lineJoin = "round";
575
- ctx.lineCap = "round";
576
- this._drawPolyline(pts);
577
- ctx.lineJoin = prevJoin;
578
- ctx.lineCap = prevCap;
579
-
580
- return pts; // 화살표 각도 계산에 사용
581
- }
582
- _drawCurve(x1, y1, x2, y2) {
583
- const { ctx } = this;
584
- const dx = Math.max(40, Math.abs(x2 - x1) * 0.4);
585
- ctx.beginPath();
586
- ctx.moveTo(x1, y1);
587
- ctx.bezierCurveTo(x1 + dx, y1, x2 - dx, y2, x2, y2);
588
- ctx.stroke();
589
- }
590
- }
591
- function roundRect(ctx, x, y, w, h, r = 6) {
592
- if (typeof r === "number") r = { tl: r, tr: r, br: r, bl: r };
593
- ctx.beginPath();
594
- ctx.moveTo(x + r.tl, y);
595
- ctx.lineTo(x + w - r.tr, y);
596
- ctx.quadraticCurveTo(x + w, y, x + w, y + r.tr);
597
- ctx.lineTo(x + w, y + h - r.br);
598
- ctx.quadraticCurveTo(x + w, y + h, x + w - r.br, y + h);
599
- ctx.lineTo(x + r.bl, y + h);
600
- ctx.quadraticCurveTo(x, y + h, x, y + h - r.bl);
601
- ctx.lineTo(x, y + r.tl);
602
- ctx.quadraticCurveTo(x, y, x + r.tl, y);
603
- ctx.closePath();
604
- }
1
+ import { portRect } from "./hitTest.js";
2
+
3
+ export class CanvasRenderer {
4
+ static FONT_SIZE = 11;
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;
10
+
11
+ this.scale = 1;
12
+ this.minScale = 0.25;
13
+ this.maxScale = 3;
14
+ this.offsetX = 0;
15
+ this.offsetY = 0;
16
+
17
+ // 'bezier' | 'line' | 'orthogonal'
18
+ this.edgeStyle = edgeStyle;
19
+
20
+ this.theme = Object.assign(
21
+ {
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: "#6366f1",
33
+ accent: "#6366f1",
34
+ accentBright: "#818cf8",
35
+ accentGlow: "rgba(99, 102, 241, 0.25)",
36
+ },
37
+ theme
38
+ );
39
+ }
40
+
41
+ setEdgeStyle(style) {
42
+ this.edgeStyle = 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({ scale = this.scale, offsetX = this.offsetX, offsetY = this.offsetY } = {}) {
52
+ this.scale = Math.min(this.maxScale, Math.max(this.minScale, scale));
53
+ this.offsetX = offsetX;
54
+ this.offsetY = offsetY;
55
+ this._onTransformChange?.();
56
+ }
57
+ setTransformChangeCallback(callback) {
58
+ this._onTransformChange = callback;
59
+ }
60
+ panBy(dx, dy) {
61
+ this.offsetX += dx;
62
+ this.offsetY += dy;
63
+ this._onTransformChange?.();
64
+ }
65
+ zoomAt(factor, cx, cy) {
66
+ const prev = this.scale;
67
+ const next = Math.min(this.maxScale, Math.max(this.minScale, prev * factor));
68
+ if (next === prev) return;
69
+ const wx = (cx - this.offsetX) / prev;
70
+ const wy = (cy - this.offsetY) / prev;
71
+ this.offsetX = cx - wx * next;
72
+ this.offsetY = cy - wy * next;
73
+ this.scale = next;
74
+ this._onTransformChange?.();
75
+ }
76
+
77
+ screenToWorld(x, y) {
78
+ return {
79
+ x: (x - this.offsetX) / this.scale,
80
+ y: (y - this.offsetY) / this.scale,
81
+ };
82
+ }
83
+ worldToScreen(x, y) {
84
+ return {
85
+ x: x * this.scale + this.offsetX,
86
+ y: y * this.scale + this.offsetY,
87
+ };
88
+ }
89
+ _applyTransform() {
90
+ const { ctx } = this;
91
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
92
+ ctx.translate(this.offsetX, this.offsetY);
93
+ ctx.scale(this.scale, this.scale);
94
+ }
95
+ _resetTransform() {
96
+ this.ctx.setTransform(1, 0, 0, 1, 0, 0);
97
+ }
98
+
99
+ // ── Drawing ────────────────────────────────────────────────────────────────
100
+ _drawArrowhead(x1, y1, x2, y2, size = 8) {
101
+ const { ctx } = this;
102
+ const s = size / this.scale;
103
+ const ang = Math.atan2(y2 - y1, x2 - x1);
104
+
105
+ ctx.beginPath();
106
+ ctx.moveTo(x2, y2);
107
+ ctx.lineTo(x2 - s * Math.cos(ang - Math.PI / 6), y2 - s * Math.sin(ang - Math.PI / 6));
108
+ ctx.lineTo(x2 - s * Math.cos(ang + Math.PI / 6), y2 - s * Math.sin(ang + Math.PI / 6));
109
+ ctx.closePath();
110
+ ctx.fill();
111
+ }
112
+
113
+ _drawScreenText(
114
+ text,
115
+ lx,
116
+ ly,
117
+ {
118
+ fontPx = 11,
119
+ color = this.theme.text,
120
+ align = "left",
121
+ baseline = "alphabetic",
122
+ } = {}
123
+ ) {
124
+ const { ctx } = this;
125
+ const { x: sx, y: sy } = this.worldToScreen(lx, ly);
126
+
127
+ ctx.save();
128
+ this._resetTransform();
129
+
130
+ const px = Math.round(sx) + 0.5;
131
+ const py = Math.round(sy) + 0.5;
132
+
133
+ ctx.font = `${fontPx * this.scale}px "Inter", system-ui, sans-serif`;
134
+ ctx.fillStyle = color;
135
+ ctx.textAlign = align;
136
+ ctx.textBaseline = baseline;
137
+ ctx.fillText(text, px, py);
138
+ ctx.restore();
139
+ }
140
+
141
+ drawGrid() {
142
+ const { ctx, canvas, theme, scale, offsetX, offsetY } = this;
143
+
144
+ this._resetTransform();
145
+ ctx.fillStyle = theme.bg;
146
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
147
+
148
+ this._applyTransform();
149
+
150
+ const x0 = -offsetX / scale;
151
+ const y0 = -offsetY / scale;
152
+ const x1 = (canvas.width - offsetX) / scale;
153
+ const y1 = (canvas.height - offsetY) / scale;
154
+
155
+ // Minor dots (24px grid)
156
+ const minorStep = 24;
157
+ const majorStep = 120;
158
+ const minorR = 1 / scale;
159
+ const majorR = 1.5 / scale;
160
+
161
+ const startX = Math.floor(x0 / minorStep) * minorStep;
162
+ const startY = Math.floor(y0 / minorStep) * minorStep;
163
+
164
+ ctx.fillStyle = this._rgba(theme.grid, 0.7);
165
+ for (let gx = startX; gx <= x1; gx += minorStep) {
166
+ for (let gy = startY; gy <= y1; gy += minorStep) {
167
+ const isMajorX = Math.round(gx / majorStep) * majorStep === Math.round(gx);
168
+ const isMajorY = Math.round(gy / majorStep) * majorStep === Math.round(gy);
169
+ if (isMajorX && isMajorY) continue;
170
+ ctx.beginPath();
171
+ ctx.arc(gx, gy, minorR, 0, Math.PI * 2);
172
+ ctx.fill();
173
+ }
174
+ }
175
+
176
+ // Major intersection dots
177
+ const majorStartX = Math.floor(x0 / majorStep) * majorStep;
178
+ const majorStartY = Math.floor(y0 / majorStep) * majorStep;
179
+ ctx.fillStyle = this._rgba(theme.grid, 1.0);
180
+ for (let gx = majorStartX; gx <= x1; gx += majorStep) {
181
+ for (let gy = majorStartY; gy <= y1; gy += majorStep) {
182
+ ctx.beginPath();
183
+ ctx.arc(gx, gy, majorR, 0, Math.PI * 2);
184
+ ctx.fill();
185
+ }
186
+ }
187
+
188
+ this._resetTransform();
189
+ }
190
+
191
+ draw(
192
+ graph,
193
+ {
194
+ selection = new Set(),
195
+ tempEdge = null,
196
+ time = performance.now(),
197
+ activeEdges = new Set(),
198
+ drawEdges = true,
199
+ } = {}
200
+ ) {
201
+ graph.updateWorldTransforms();
202
+
203
+ this.drawGrid();
204
+ const { ctx, theme } = this;
205
+ this._applyTransform();
206
+
207
+ ctx.save();
208
+
209
+ // 1. Draw Groups
210
+ for (const n of graph.nodes.values()) {
211
+ if (n.type === "core/Group") {
212
+ const sel = selection.has(n.id);
213
+ const def = this.registry?.types?.get(n.type);
214
+ if (def?.onDraw) def.onDraw(n, { ctx, theme, renderer: this });
215
+ else this._drawNode(n, sel);
216
+ }
217
+ }
218
+
219
+ // 2. Draw Edges
220
+ if (drawEdges) {
221
+ ctx.lineWidth = 1.5 / this.scale;
222
+
223
+ for (const e of graph.edges.values()) {
224
+ const isActive = activeEdges && activeEdges.has(e.id);
225
+
226
+ if (isActive) {
227
+ // Glow pass
228
+ ctx.save();
229
+ ctx.shadowColor = this.theme.edgeActive;
230
+ ctx.shadowBlur = 8 / this.scale;
231
+ ctx.strokeStyle = this.theme.edgeActive;
232
+ ctx.lineWidth = 2 / this.scale;
233
+ ctx.setLineDash([]);
234
+ this._drawEdge(graph, e);
235
+ ctx.restore();
236
+
237
+ // Animated flowing dot
238
+ const dotT = ((time / 1000) * 1.2) % 1;
239
+ const dotPos = this._getEdgeDotPosition(graph, e, dotT);
240
+ if (dotPos) {
241
+ ctx.save();
242
+ ctx.fillStyle = "#ffffff";
243
+ ctx.shadowColor = this.theme.edgeActive;
244
+ ctx.shadowBlur = 10 / this.scale;
245
+ ctx.beginPath();
246
+ ctx.arc(dotPos.x, dotPos.y, 3 / this.scale, 0, Math.PI * 2);
247
+ ctx.fill();
248
+ ctx.restore();
249
+ }
250
+ } else {
251
+ ctx.setLineDash([]);
252
+ ctx.strokeStyle = theme.edge;
253
+ ctx.lineWidth = 1.5 / this.scale;
254
+ this._drawEdge(graph, e);
255
+ }
256
+ }
257
+ }
258
+
259
+ // temp edge preview
260
+ if (tempEdge) {
261
+ const a = this.screenToWorld(tempEdge.x1, tempEdge.y1);
262
+ const b = this.screenToWorld(tempEdge.x2, tempEdge.y2);
263
+
264
+ const prevDash = this.ctx.getLineDash();
265
+ this.ctx.setLineDash([5 / this.scale, 5 / this.scale]);
266
+ this.ctx.strokeStyle = this._rgba(this.theme.accentBright, 0.7);
267
+ this.ctx.lineWidth = 1.5 / this.scale;
268
+
269
+ let ptsForArrow = null;
270
+ if (this.edgeStyle === "line") {
271
+ this._drawLine(a.x, a.y, b.x, b.y);
272
+ ptsForArrow = [{ x: a.x, y: a.y }, { x: b.x, y: b.y }];
273
+ } else if (this.edgeStyle === "orthogonal") {
274
+ ptsForArrow = this._drawOrthogonal(a.x, a.y, b.x, b.y);
275
+ } else {
276
+ this._drawCurve(a.x, a.y, b.x, b.y);
277
+ ptsForArrow = [{ x: a.x, y: a.y }, { x: b.x, y: b.y }];
278
+ }
279
+
280
+ this.ctx.setLineDash(prevDash);
281
+
282
+ if (ptsForArrow && ptsForArrow.length >= 2) {
283
+ const p1 = ptsForArrow[ptsForArrow.length - 2];
284
+ const p2 = ptsForArrow[ptsForArrow.length - 1];
285
+ this.ctx.fillStyle = this.theme.accentBright;
286
+ this._drawArrowhead(p1.x, p1.y, p2.x, p2.y, 10);
287
+ }
288
+ }
289
+
290
+ // 3. Draw Non-Group Nodes
291
+ for (const n of graph.nodes.values()) {
292
+ if (n.type !== "core/Group") {
293
+ const sel = selection.has(n.id);
294
+ const def = this.registry?.types?.get(n.type);
295
+ const hasHtmlOverlay = !!def?.html;
296
+
297
+ // Draw node's base aesthetics (headers, colors, rounding) always on canvas.
298
+ // Transparent HTML overlay sits on top for interactive elements.
299
+ this._drawNode(n, sel, !hasHtmlOverlay ? true : false);
300
+
301
+ if (def?.onDraw) {
302
+ def.onDraw(n, { ctx, theme, renderer: this });
303
+ }
304
+
305
+ // Ensure ports are visible
306
+ if (hasHtmlOverlay) {
307
+ this._drawPorts(n);
308
+ }
309
+ }
310
+ }
311
+
312
+ this._resetTransform();
313
+ }
314
+
315
+ _rgba(hex, a) {
316
+ const c = hex.replace("#", "");
317
+ const n = parseInt(
318
+ c.length === 3
319
+ ? c.split("").map((x) => x + x).join("")
320
+ : c,
321
+ 16
322
+ );
323
+ const r = (n >> 16) & 255,
324
+ g = (n >> 8) & 255,
325
+ b = n & 255;
326
+ return `rgba(${r},${g},${b},${a})`;
327
+ }
328
+
329
+ _drawNode(node, selected, skipPorts = false) {
330
+ const { ctx, theme } = this;
331
+ const r = 2; // Sharp 2px rounding
332
+ const { x, y, w, h } = node.computed;
333
+ const headerH = 26; // Slightly taller header for premium feel
334
+
335
+ // Get color from node or registry
336
+ const typeDef = this.registry?.types?.get(node.type);
337
+ const categoryColor = node.color || typeDef?.color || theme.accent;
338
+
339
+ // Selection glow — same radius as node, offset 2px outside
340
+ if (selected) {
341
+ ctx.save();
342
+ ctx.shadowColor = theme.accentGlow;
343
+ ctx.shadowBlur = 10 / this.scale;
344
+ ctx.strokeStyle = theme.accentBright;
345
+ ctx.lineWidth = 2 / this.scale;
346
+ const pad = 1.5 / this.scale;
347
+ roundRect(ctx, x - pad, y - pad, w + pad * 2, h + pad * 2, r + pad);
348
+ ctx.stroke();
349
+ ctx.restore();
350
+ }
351
+
352
+ // Drop shadow
353
+ ctx.save();
354
+ ctx.shadowColor = "rgba(0,0,0,0.7)";
355
+ ctx.shadowBlur = 20 / this.scale;
356
+ ctx.shadowOffsetY = 6 / this.scale;
357
+ ctx.fillStyle = theme.node;
358
+ roundRect(ctx, x, y, w, h, r);
359
+ ctx.fill();
360
+ ctx.restore();
361
+
362
+ // Node body
363
+ ctx.fillStyle = theme.node;
364
+ ctx.strokeStyle = selected ? theme.accentBright : theme.nodeBorder;
365
+ ctx.lineWidth = 1 / this.scale;
366
+ roundRect(ctx, x, y, w, h, r);
367
+ ctx.fill();
368
+ ctx.stroke();
369
+
370
+ // Header
371
+ ctx.fillStyle = theme.title;
372
+ roundRect(ctx, x, y, w, headerH, { tl: r, tr: r, br: 0, bl: 0 });
373
+ ctx.fill();
374
+
375
+ // Subtle category-based header background
376
+ ctx.save();
377
+ ctx.globalCompositeOperation = "source-atop";
378
+ ctx.fillStyle = categoryColor;
379
+ ctx.globalAlpha = 0.25; // Increased from 0.12 for better visibility
380
+ ctx.fillRect(x, y, w, headerH);
381
+ ctx.restore();
382
+
383
+ // Header bottom separator
384
+ ctx.strokeStyle = selected
385
+ ? this._rgba(theme.accentBright, 0.3)
386
+ : this._rgba(theme.nodeBorder, 0.6);
387
+ ctx.lineWidth = 1 / this.scale;
388
+ ctx.beginPath();
389
+ ctx.moveTo(x, y + headerH);
390
+ ctx.lineTo(x + w, y + headerH);
391
+ ctx.stroke();
392
+
393
+ // Accent strip at top
394
+ ctx.fillStyle = categoryColor;
395
+ ctx.beginPath();
396
+ ctx.roundRect(x, y, w, 2.5 / this.scale, { tl: r, tr: r, br: 0, bl: 0 });
397
+ ctx.fill();
398
+
399
+ // Title
400
+ this._drawScreenText(node.title, x + 10, y + headerH / 2, {
401
+ fontPx: CanvasRenderer.FONT_SIZE,
402
+ color: theme.text,
403
+ baseline: "middle",
404
+ align: "left",
405
+ });
406
+
407
+ if (skipPorts) return;
408
+
409
+ // Input ports + labels
410
+ node.inputs.forEach((p, i) => {
411
+ const rct = portRect(node, p, i, "in");
412
+ const cx = rct.x + rct.w / 2;
413
+ const cy = rct.y + rct.h / 2;
414
+ this._drawPortShape(cx, cy, p.portType);
415
+ if (p.name) {
416
+ this._drawScreenText(p.name, cx + 10, cy, {
417
+ fontPx: 10,
418
+ color: theme.textMuted,
419
+ baseline: "middle",
420
+ align: "left",
421
+ });
422
+ }
423
+ });
424
+
425
+ // Output ports + labels
426
+ node.outputs.forEach((p, i) => {
427
+ const rct = portRect(node, p, i, "out");
428
+ const cx = rct.x + rct.w / 2;
429
+ const cy = rct.y + rct.h / 2;
430
+ this._drawPortShape(cx, cy, p.portType);
431
+ if (p.name) {
432
+ this._drawScreenText(p.name, cx - 10, cy, {
433
+ fontPx: 10,
434
+ color: theme.textMuted,
435
+ baseline: "middle",
436
+ align: "right",
437
+ });
438
+ }
439
+ });
440
+ }
441
+
442
+ _drawPortShape(cx, cy, portType) {
443
+ const { ctx, theme } = this;
444
+
445
+ if (portType === "exec") {
446
+ // Diamond shape for exec ports
447
+ const s = 5 / this.scale;
448
+ ctx.save();
449
+ ctx.fillStyle = theme.portExec;
450
+ ctx.strokeStyle = this._rgba(theme.portExec, 0.4);
451
+ ctx.lineWidth = 2 / this.scale;
452
+ ctx.beginPath();
453
+ ctx.moveTo(cx, cy - s);
454
+ ctx.lineTo(cx + s, cy);
455
+ ctx.lineTo(cx, cy + s);
456
+ ctx.lineTo(cx - s, cy);
457
+ ctx.closePath();
458
+ ctx.fill();
459
+ ctx.stroke();
460
+ ctx.restore();
461
+ } else {
462
+ // Circle for data ports
463
+ ctx.save();
464
+ // Outer ring
465
+ ctx.strokeStyle = this._rgba(theme.port, 0.35);
466
+ ctx.lineWidth = 3 / this.scale;
467
+ ctx.beginPath();
468
+ ctx.arc(cx, cy, 5 / this.scale, 0, Math.PI * 2);
469
+ ctx.stroke();
470
+ // Inner fill
471
+ ctx.fillStyle = theme.port;
472
+ ctx.beginPath();
473
+ ctx.arc(cx, cy, 3.5 / this.scale, 0, Math.PI * 2);
474
+ ctx.fill();
475
+ ctx.restore();
476
+ }
477
+ }
478
+
479
+ _drawPorts(node) {
480
+ node.inputs.forEach((p, i) => {
481
+ const rct = portRect(node, p, i, "in");
482
+ const cx = rct.x + rct.w / 2;
483
+ const cy = rct.y + rct.h / 2;
484
+ this._drawPortShape(cx, cy, p.portType);
485
+ });
486
+
487
+ node.outputs.forEach((p, i) => {
488
+ const rct = portRect(node, p, i, "out");
489
+ const cx = rct.x + rct.w / 2;
490
+ const cy = rct.y + rct.h / 2;
491
+ this._drawPortShape(cx, cy, p.portType);
492
+ });
493
+ }
494
+
495
+ /** Selection border for HTML overlay nodes, drawn on the edge canvas */
496
+ _drawHtmlSelectionBorder(node) {
497
+ const { ctx, theme } = this;
498
+ const { x, y, w, h } = node.computed;
499
+ const r = 2; // Sharp 2px rounding
500
+ const pad = 1.5 / this.scale;
501
+
502
+ ctx.save();
503
+ ctx.shadowColor = theme.accentGlow;
504
+ ctx.shadowBlur = 14 / this.scale;
505
+ ctx.strokeStyle = theme.accentBright;
506
+ ctx.lineWidth = 1.5 / this.scale;
507
+ roundRect(ctx, x - pad, y - pad, w + pad * 2, h + pad * 2, r);
508
+ ctx.stroke();
509
+ ctx.restore();
510
+ }
511
+
512
+ /** Rotating dashed border drawn on the edge canvas for executing nodes */
513
+ _drawActiveNodeBorder(node, time) {
514
+ const { ctx, theme } = this;
515
+ const { x, y, w, h } = node.computed;
516
+ const r = 2;
517
+ const pad = 2.5 / this.scale;
518
+
519
+ const dashLen = 8 / this.scale;
520
+ const gapLen = 6 / this.scale;
521
+ // Slow clockwise rotation: positive lineDashOffset moves the pattern forward along path
522
+ const offset = -(time / 1000) * (50 / this.scale);
523
+
524
+ ctx.save();
525
+ ctx.setLineDash([dashLen, gapLen]);
526
+ ctx.lineDashOffset = offset;
527
+ ctx.strokeStyle = this._rgba(theme.portExec, 0.9);
528
+ ctx.lineWidth = 1.5 / this.scale;
529
+ ctx.shadowColor = theme.portExec;
530
+ ctx.shadowBlur = 4 / this.scale;
531
+ roundRect(ctx, x - pad, y - pad, w + pad * 2, h + pad * 2, r + pad);
532
+ ctx.stroke();
533
+ ctx.restore();
534
+ }
535
+
536
+ _drawEdge(graph, e) {
537
+ const from = graph.nodes.get(e.fromNode);
538
+ const to = graph.nodes.get(e.toNode);
539
+ if (!from || !to) return;
540
+ const iOut = from.outputs.findIndex((p) => p.id === e.fromPort);
541
+ const iIn = to.inputs.findIndex((p) => p.id === e.toPort);
542
+ const pr1 = portRect(from, null, iOut, "out");
543
+ const pr2 = portRect(to, null, iIn, "in");
544
+ const x1 = pr1.x + pr1.w / 2, y1 = pr1.y + pr1.h / 2;
545
+ const x2 = pr2.x + pr2.w / 2, y2 = pr2.y + pr2.h / 2;
546
+
547
+ if (this.edgeStyle === "line") {
548
+ this._drawLine(x1, y1, x2, y2);
549
+ } else if (this.edgeStyle === "orthogonal") {
550
+ this._drawOrthogonal(x1, y1, x2, y2);
551
+ } else {
552
+ this._drawCurve(x1, y1, x2, y2);
553
+ }
554
+ }
555
+
556
+ _getEdgeDotPosition(graph, e, t) {
557
+ const from = graph.nodes.get(e.fromNode);
558
+ const to = graph.nodes.get(e.toNode);
559
+ if (!from || !to) return null;
560
+
561
+ const iOut = from.outputs.findIndex((p) => p.id === e.fromPort);
562
+ const iIn = to.inputs.findIndex((p) => p.id === e.toPort);
563
+ const pr1 = portRect(from, null, iOut, "out");
564
+ const pr2 = portRect(to, null, iIn, "in");
565
+ const x1 = pr1.x + pr1.w / 2, y1 = pr1.y + pr1.h / 2;
566
+ const x2 = pr2.x + pr2.w / 2, y2 = pr2.y + pr2.h / 2;
567
+
568
+ if (this.edgeStyle === "bezier") {
569
+ const dx = Math.max(40, Math.abs(x2 - x1) * 0.4);
570
+ return cubicBezierPoint(x1, y1, x1 + dx, y1, x2 - dx, y2, x2, y2, t);
571
+ } else if (this.edgeStyle === "orthogonal") {
572
+ const midX = (x1 + x2) / 2;
573
+ const pts = [
574
+ { x: x1, y: y1 },
575
+ { x: midX, y: y1 },
576
+ { x: midX, y: y2 },
577
+ { x: x2, y: y2 },
578
+ ];
579
+ return polylinePoint(pts, t);
580
+ } else {
581
+ return { x: x1 + (x2 - x1) * t, y: y1 + (y2 - y1) * t };
582
+ }
583
+ }
584
+
585
+ _drawLine(x1, y1, x2, y2) {
586
+ const { ctx } = this;
587
+ ctx.beginPath();
588
+ ctx.moveTo(x1, y1);
589
+ ctx.lineTo(x2, y2);
590
+ ctx.stroke();
591
+ }
592
+
593
+ _drawPolyline(points) {
594
+ const { ctx } = this;
595
+ ctx.beginPath();
596
+ ctx.moveTo(points[0].x, points[0].y);
597
+ for (let i = 1; i < points.length; i++) ctx.lineTo(points[i].x, points[i].y);
598
+ ctx.stroke();
599
+ }
600
+
601
+ _drawOrthogonal(x1, y1, x2, y2) {
602
+ const midX = (x1 + x2) / 2;
603
+ const pts = [
604
+ { x: x1, y: y1 },
605
+ { x: midX, y: y1 },
606
+ { x: midX, y: y2 },
607
+ { x: x2, y: y2 },
608
+ ];
609
+
610
+ const { ctx } = this;
611
+ const prevJoin = ctx.lineJoin, prevCap = ctx.lineCap;
612
+ ctx.lineJoin = "round";
613
+ ctx.lineCap = "round";
614
+ this._drawPolyline(pts);
615
+ ctx.lineJoin = prevJoin;
616
+ ctx.lineCap = prevCap;
617
+
618
+ return pts;
619
+ }
620
+
621
+ _drawCurve(x1, y1, x2, y2) {
622
+ const { ctx } = this;
623
+ const dx = Math.max(40, Math.abs(x2 - x1) * 0.4);
624
+ ctx.beginPath();
625
+ ctx.moveTo(x1, y1);
626
+ ctx.bezierCurveTo(x1 + dx, y1, x2 - dx, y2, x2, y2);
627
+ ctx.stroke();
628
+ }
629
+
630
+ drawEdgesOnly(
631
+ graph,
632
+ {
633
+ activeEdges = new Set(),
634
+ activeEdgeTimes = new Map(),
635
+ activeNodes = new Set(),
636
+ selection = new Set(),
637
+ time = performance.now(),
638
+ tempEdge = null,
639
+ } = {}
640
+ ) {
641
+ this._resetTransform();
642
+ this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
643
+
644
+ this._applyTransform();
645
+
646
+ const { ctx, theme } = this;
647
+
648
+ // Draw all edges
649
+ for (const e of graph.edges.values()) {
650
+ const isActive = activeEdges.has(e.id);
651
+
652
+ if (isActive) {
653
+ // Glow pass
654
+ ctx.save();
655
+ ctx.shadowColor = theme.edgeActive;
656
+ ctx.shadowBlur = 6 / this.scale;
657
+ ctx.strokeStyle = theme.edgeActive;
658
+ ctx.lineWidth = 2 / this.scale;
659
+ ctx.setLineDash([]);
660
+ this._drawEdge(graph, e);
661
+ ctx.restore();
662
+
663
+ // Dot position: 0→1 over STEP_DURATION from activation time
664
+ const activationTime = activeEdgeTimes.get(e.id) ?? time;
665
+ const dotT = Math.min(1, (time - activationTime) / 620);
666
+ const dotPos = this._getEdgeDotPosition(graph, e, dotT);
667
+ if (dotPos) {
668
+ ctx.save();
669
+ ctx.fillStyle = this._rgba(theme.edgeActive, 0.9);
670
+ ctx.shadowColor = theme.edgeActive;
671
+ ctx.shadowBlur = 8 / this.scale;
672
+ ctx.beginPath();
673
+ ctx.arc(dotPos.x, dotPos.y, 2.5 / this.scale, 0, Math.PI * 2);
674
+ ctx.fill();
675
+ ctx.restore();
676
+ }
677
+ } else {
678
+ ctx.setLineDash([]);
679
+ ctx.strokeStyle = theme.edge;
680
+ ctx.lineWidth = 1.5 / this.scale;
681
+ this._drawEdge(graph, e);
682
+ }
683
+ }
684
+
685
+ // Selection borders for HTML overlay nodes (drawn above the HTML layer)
686
+ for (const nodeId of selection) {
687
+ const node = graph.nodes.get(nodeId);
688
+ if (!node) continue;
689
+ const def = this.registry?.types?.get(node.type);
690
+ if (def?.html) this._drawHtmlSelectionBorder(node);
691
+ }
692
+
693
+ // Rotating dashed border for executing nodes
694
+ if (activeNodes.size > 0) {
695
+ for (const nodeId of activeNodes) {
696
+ const node = graph.nodes.get(nodeId);
697
+ if (node) this._drawActiveNodeBorder(node, time);
698
+ }
699
+ }
700
+
701
+ // temp edge preview
702
+ if (tempEdge) {
703
+ const a = this.screenToWorld(tempEdge.x1, tempEdge.y1);
704
+ const b = this.screenToWorld(tempEdge.x2, tempEdge.y2);
705
+
706
+ const prevDash = this.ctx.getLineDash();
707
+ this.ctx.setLineDash([5 / this.scale, 5 / this.scale]);
708
+ this.ctx.strokeStyle = this._rgba(this.theme.accentBright, 0.7);
709
+ this.ctx.lineWidth = 1.5 / this.scale;
710
+
711
+ let ptsForArrow = null;
712
+ if (this.edgeStyle === "line") {
713
+ this._drawLine(a.x, a.y, b.x, b.y);
714
+ ptsForArrow = [{ x: a.x, y: a.y }, { x: b.x, y: b.y }];
715
+ } else if (this.edgeStyle === "orthogonal") {
716
+ ptsForArrow = this._drawOrthogonal(a.x, a.y, b.x, b.y);
717
+ } else {
718
+ this._drawCurve(a.x, a.y, b.x, b.y);
719
+ ptsForArrow = [{ x: a.x, y: a.y }, { x: b.x, y: b.y }];
720
+ }
721
+
722
+ this.ctx.setLineDash(prevDash);
723
+
724
+ if (ptsForArrow && ptsForArrow.length >= 2) {
725
+ const p1 = ptsForArrow[ptsForArrow.length - 2];
726
+ const p2 = ptsForArrow[ptsForArrow.length - 1];
727
+ this.ctx.fillStyle = this.theme.accentBright;
728
+ this._drawArrowhead(p1.x, p1.y, p2.x, p2.y, 10);
729
+ }
730
+ }
731
+
732
+ this._resetTransform();
733
+ }
734
+ }
735
+
736
+ function roundRect(ctx, x, y, w, h, r = 6) {
737
+ if (typeof r === "number") r = { tl: r, tr: r, br: r, bl: r };
738
+ ctx.beginPath();
739
+ ctx.moveTo(x + r.tl, y);
740
+ ctx.lineTo(x + w - r.tr, y);
741
+ ctx.quadraticCurveTo(x + w, y, x + w, y + r.tr);
742
+ ctx.lineTo(x + w, y + h - r.br);
743
+ ctx.quadraticCurveTo(x + w, y + h, x + w - r.br, y + h);
744
+ ctx.lineTo(x + r.bl, y + h);
745
+ ctx.quadraticCurveTo(x, y + h, x, y + h - r.bl);
746
+ ctx.lineTo(x, y + r.tl);
747
+ ctx.quadraticCurveTo(x, y, x + r.tl, y);
748
+ ctx.closePath();
749
+ }
750
+
751
+ function cubicBezierPoint(x0, y0, x1, y1, x2, y2, x3, y3, t) {
752
+ const mt = 1 - t;
753
+ return {
754
+ x: mt * mt * mt * x0 + 3 * mt * mt * t * x1 + 3 * mt * t * t * x2 + t * t * t * x3,
755
+ y: mt * mt * mt * y0 + 3 * mt * mt * t * y1 + 3 * mt * t * t * y2 + t * t * t * y3,
756
+ };
757
+ }
758
+
759
+ function polylinePoint(pts, t) {
760
+ let totalLen = 0;
761
+ const lens = [];
762
+ for (let i = 0; i < pts.length - 1; i++) {
763
+ const dx = pts[i + 1].x - pts[i].x;
764
+ const dy = pts[i + 1].y - pts[i].y;
765
+ const len = Math.sqrt(dx * dx + dy * dy);
766
+ lens.push(len);
767
+ totalLen += len;
768
+ }
769
+ if (totalLen === 0) return pts[0];
770
+
771
+ let target = t * totalLen;
772
+ let accum = 0;
773
+ for (let i = 0; i < lens.length; i++) {
774
+ if (accum + lens[i] >= target) {
775
+ const segT = lens[i] > 0 ? (target - accum) / lens[i] : 0;
776
+ return {
777
+ x: pts[i].x + (pts[i + 1].x - pts[i].x) * segT,
778
+ y: pts[i].y + (pts[i + 1].y - pts[i].y) * segT,
779
+ };
780
+ }
781
+ accum += lens[i];
782
+ }
783
+ return pts[pts.length - 1];
784
+ }