html-overlay-node 0.1.10 → 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,784 +1,884 @@
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
- }
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: "#34c38f", // green for active edge animation
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
+ { fontPx = 11, color = this.theme.text, align = "left", baseline = "alphabetic" } = {}
118
+ ) {
119
+ const { ctx } = this;
120
+ const { x: sx, y: sy } = this.worldToScreen(lx, ly);
121
+
122
+ ctx.save();
123
+ this._resetTransform();
124
+
125
+ const px = Math.round(sx) + 0.5;
126
+ const py = Math.round(sy) + 0.5;
127
+
128
+ ctx.font = `${fontPx * this.scale}px "Inter", system-ui, sans-serif`;
129
+ ctx.fillStyle = color;
130
+ ctx.textAlign = align;
131
+ ctx.textBaseline = baseline;
132
+ ctx.fillText(text, px, py);
133
+ ctx.restore();
134
+ }
135
+
136
+ drawGrid() {
137
+ const { ctx, canvas, theme, scale, offsetX, offsetY } = this;
138
+
139
+ this._resetTransform();
140
+ ctx.fillStyle = theme.bg;
141
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
142
+
143
+ this._applyTransform();
144
+
145
+ const x0 = -offsetX / scale;
146
+ const y0 = -offsetY / scale;
147
+ const x1 = (canvas.width - offsetX) / scale;
148
+ const y1 = (canvas.height - offsetY) / scale;
149
+
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
+ }
169
+ }
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
+ }
181
+ }
182
+
183
+ this._resetTransform();
184
+ }
185
+
186
+ draw(
187
+ graph,
188
+ {
189
+ selection = new Set(),
190
+ tempEdge = null,
191
+ time = performance.now(),
192
+ activeNodes = new Set(), // Now explicitly passing active nodes
193
+ activeEdges = new Set(),
194
+ activeEdgeTimes = new Map(),
195
+ drawEdges = true,
196
+ loopActiveEdges = false,
197
+ } = {}
198
+ ) {
199
+ graph.updateWorldTransforms();
200
+
201
+ this.drawGrid();
202
+ const { ctx, theme } = this;
203
+ this._applyTransform();
204
+
205
+ ctx.save();
206
+
207
+ // 1. Draw Groups
208
+ for (const n of graph.nodes.values()) {
209
+ if (n.type === "core/Group") {
210
+ const sel = selection.has(n.id);
211
+ const def = this.registry?.types?.get(n.type);
212
+ if (def?.onDraw) def.onDraw(n, { ctx, theme, renderer: this });
213
+ else this._drawNode(n, sel);
214
+ }
215
+ }
216
+
217
+ // 2. Draw Edges
218
+ if (drawEdges) {
219
+ ctx.lineWidth = 2.5 / this.scale;
220
+
221
+ for (const e of graph.edges.values()) {
222
+ const isActive = activeEdges && activeEdges.has(e.id);
223
+
224
+ if (isActive) {
225
+ // Glow pass
226
+ ctx.save();
227
+ ctx.shadowColor = this.theme.edgeActive;
228
+ ctx.shadowBlur = 8 / this.scale;
229
+ ctx.strokeStyle = this.theme.edgeActive;
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
+ }
253
+ } else {
254
+ ctx.setLineDash([]);
255
+ ctx.strokeStyle = theme.edge;
256
+ ctx.lineWidth = 2.5 / this.scale;
257
+ this._drawEdge(graph, e);
258
+ }
259
+ }
260
+ }
261
+
262
+ // temp edge preview
263
+ if (tempEdge) {
264
+ const a = this.screenToWorld(tempEdge.x1, tempEdge.y1);
265
+ const b = this.screenToWorld(tempEdge.x2, tempEdge.y2);
266
+
267
+ const prevDash = this.ctx.getLineDash();
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;
271
+
272
+ let ptsForArrow = null;
273
+ if (this.edgeStyle === "line") {
274
+ this._drawLine(a.x, a.y, b.x, b.y);
275
+ ptsForArrow = [
276
+ { x: a.x, y: a.y },
277
+ { x: b.x, y: b.y },
278
+ ];
279
+ } else if (this.edgeStyle === "orthogonal") {
280
+ ptsForArrow = this._drawOrthogonal(a.x, a.y, b.x, b.y);
281
+ } else {
282
+ this._drawCurve(a.x, a.y, b.x, b.y);
283
+ ptsForArrow = [
284
+ { x: a.x, y: a.y },
285
+ { x: b.x, y: b.y },
286
+ ];
287
+ }
288
+
289
+ this.ctx.setLineDash(prevDash);
290
+
291
+ if (ptsForArrow && ptsForArrow.length >= 2) {
292
+ const p1 = ptsForArrow[ptsForArrow.length - 2];
293
+ const p2 = ptsForArrow[ptsForArrow.length - 1];
294
+ this.ctx.fillStyle = this.theme.accentBright;
295
+ this._drawArrowhead(p1.x, p1.y, p2.x, p2.y, 10);
296
+ }
297
+ }
298
+
299
+ // 3. Draw Non-Group Nodes
300
+ for (const n of graph.nodes.values()) {
301
+ if (n.type !== "core/Group") {
302
+ const sel = selection.has(n.id);
303
+ const def = this.registry?.types?.get(n.type);
304
+ const hasHtmlOverlay = !!def?.html;
305
+
306
+ this._drawNode(n, sel, !hasHtmlOverlay ? true : false);
307
+
308
+ if (def?.onDraw) {
309
+ def.onDraw(n, { ctx, theme, renderer: this });
310
+ }
311
+
312
+ if (hasHtmlOverlay) {
313
+ this._drawPorts(n);
314
+ }
315
+ }
316
+ }
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
+
326
+ this._resetTransform();
327
+ }
328
+
329
+ _rgba(hex, a) {
330
+ const c = hex.replace("#", "");
331
+ const n = parseInt(
332
+ c.length === 3
333
+ ? c
334
+ .split("")
335
+ .map((x) => x + x)
336
+ .join("")
337
+ : c,
338
+ 16
339
+ );
340
+ const r = (n >> 16) & 255,
341
+ g = (n >> 8) & 255,
342
+ b = n & 255;
343
+ return `rgba(${r},${g},${b},${a})`;
344
+ }
345
+
346
+ _drawNode(node, selected, skipPorts = false) {
347
+ const { ctx, theme } = this;
348
+ const r = 2; // Sharp 2px rounding
349
+ const { x, y, w, h } = node.computed;
350
+ const headerH = 26; // Slightly taller header for premium feel
351
+
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) {
358
+ ctx.save();
359
+ ctx.shadowColor = "rgba(255,255,255,0.3)";
360
+ ctx.shadowBlur = 8 / this.scale;
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();
366
+ ctx.restore();
367
+ }
368
+
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
380
+ ctx.fillStyle = theme.node;
381
+ ctx.strokeStyle = selected ? "rgba(255,255,255,0.4)" : theme.nodeBorder;
382
+ ctx.lineWidth = 1 / this.scale;
383
+ roundRect(ctx, x, y, w, h, r);
384
+ ctx.fill();
385
+ ctx.stroke();
386
+
387
+ // Header
388
+ ctx.fillStyle = theme.title;
389
+ roundRect(ctx, x, y, w, headerH, { tl: r, tr: r, br: 0, bl: 0 });
390
+ ctx.fill();
391
+
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;
403
+ ctx.beginPath();
404
+ ctx.moveTo(x, y + headerH);
405
+ ctx.lineTo(x + w, y + headerH);
406
+ ctx.stroke();
407
+
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, {
416
+ fontPx: CanvasRenderer.FONT_SIZE,
417
+ color: theme.text,
418
+ baseline: "middle",
419
+ align: "left",
420
+ });
421
+
422
+ if (skipPorts) return;
423
+
424
+ // Input ports + labels
425
+ node.inputs.forEach((p, i) => {
426
+ const rct = portRect(node, p, i, "in");
427
+ const cx = rct.x + rct.w / 2;
428
+ const cy = rct.y + rct.h / 2;
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
+ });
437
+ }
438
+ });
439
+
440
+ // Output ports + labels
441
+ node.outputs.forEach((p, i) => {
442
+ const rct = portRect(node, p, i, "out");
443
+ const cx = rct.x + rct.w / 2;
444
+ const cy = rct.y + rct.h / 2;
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
+ });
453
+ }
454
+ });
455
+ }
456
+
457
+ _drawActiveNodeBorder(node, time) {
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;
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) {
542
+ node.inputs.forEach((p, i) => {
543
+ const rct = portRect(node, p, i, "in");
544
+ const cx = rct.x + rct.w / 2;
545
+ const cy = rct.y + rct.h / 2;
546
+ this._drawPortShape(cx, cy, p.portType);
547
+ });
548
+
549
+ node.outputs.forEach((p, i) => {
550
+ const rct = portRect(node, p, i, "out");
551
+ const cx = rct.x + rct.w / 2;
552
+ const cy = rct.y + rct.h / 2;
553
+ this._drawPortShape(cx, cy, p.portType);
554
+ });
555
+ }
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
+
617
+ _drawEdge(graph, e) {
618
+ const from = graph.nodes.get(e.fromNode);
619
+ const to = graph.nodes.get(e.toNode);
620
+ if (!from || !to) return;
621
+ const iOut = from.outputs.findIndex((p) => p.id === e.fromPort);
622
+ const iIn = to.inputs.findIndex((p) => p.id === e.toPort);
623
+ const pr1 = portRect(from, null, iOut, "out");
624
+ const pr2 = portRect(to, null, iIn, "in");
625
+ const x1 = pr1.x + pr1.w / 2,
626
+ y1 = pr1.y + pr1.h / 2;
627
+ const x2 = pr2.x + pr2.w / 2,
628
+ y2 = pr2.y + pr2.h / 2;
629
+
630
+ if (this.edgeStyle === "line") {
631
+ this._drawLine(x1, y1, x2, y2);
632
+ } else if (this.edgeStyle === "orthogonal") {
633
+ this._drawOrthogonal(x1, y1, x2, y2);
634
+ } else {
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 };
667
+ }
668
+ }
669
+
670
+ _drawLine(x1, y1, x2, y2) {
671
+ const { ctx } = this;
672
+ ctx.beginPath();
673
+ ctx.moveTo(x1, y1);
674
+ ctx.lineTo(x2, y2);
675
+ ctx.stroke();
676
+ }
677
+
678
+ _drawPolyline(points) {
679
+ const { ctx } = this;
680
+ ctx.beginPath();
681
+ ctx.moveTo(points[0].x, points[0].y);
682
+ for (let i = 1; i < points.length; i++) ctx.lineTo(points[i].x, points[i].y);
683
+ ctx.stroke();
684
+ }
685
+
686
+ _drawOrthogonal(x1, y1, x2, y2) {
687
+ const midX = (x1 + x2) / 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
+ ];
694
+
695
+ const { ctx } = this;
696
+ const prevJoin = ctx.lineJoin,
697
+ prevCap = ctx.lineCap;
698
+ ctx.lineJoin = "round";
699
+ ctx.lineCap = "round";
700
+ this._drawPolyline(pts);
701
+ ctx.lineJoin = prevJoin;
702
+ ctx.lineCap = prevCap;
703
+
704
+ return pts;
705
+ }
706
+
707
+ _drawCurve(x1, y1, x2, y2) {
708
+ const { ctx } = this;
709
+ const dx = Math.max(40, Math.abs(x2 - x1) * 0.4);
710
+ ctx.beginPath();
711
+ ctx.moveTo(x1, y1);
712
+ ctx.bezierCurveTo(x1 + dx, y1, x2 - dx, y2, x2, y2);
713
+ ctx.stroke();
714
+ }
715
+
716
+ drawEdgesOnly(
717
+ graph,
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
+ } = {}
727
+ ) {
728
+ this._resetTransform();
729
+ this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
730
+
731
+ this._applyTransform();
732
+
733
+ const { ctx, theme } = this;
734
+
735
+ // Draw all edges
736
+ for (const e of graph.edges.values()) {
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;
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
+ }
771
+ } else {
772
+ ctx.setLineDash([]);
773
+ ctx.strokeStyle = theme.edge;
774
+ ctx.lineWidth = 2.5 / this.scale;
775
+ this._drawEdge(graph, e);
776
+ }
777
+ }
778
+
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
+ }
793
+ }
794
+
795
+ // temp edge preview
796
+ if (tempEdge) {
797
+ const a = this.screenToWorld(tempEdge.x1, tempEdge.y1);
798
+ const b = this.screenToWorld(tempEdge.x2, tempEdge.y2);
799
+
800
+ const prevDash = this.ctx.getLineDash();
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;
804
+
805
+ let ptsForArrow = null;
806
+ if (this.edgeStyle === "line") {
807
+ this._drawLine(a.x, a.y, b.x, b.y);
808
+ ptsForArrow = [
809
+ { x: a.x, y: a.y },
810
+ { x: b.x, y: b.y },
811
+ ];
812
+ } else if (this.edgeStyle === "orthogonal") {
813
+ ptsForArrow = this._drawOrthogonal(a.x, a.y, b.x, b.y);
814
+ } else {
815
+ this._drawCurve(a.x, a.y, b.x, b.y);
816
+ ptsForArrow = [
817
+ { x: a.x, y: a.y },
818
+ { x: b.x, y: b.y },
819
+ ];
820
+ }
821
+
822
+ this.ctx.setLineDash(prevDash);
823
+
824
+ if (ptsForArrow && ptsForArrow.length >= 2) {
825
+ const p1 = ptsForArrow[ptsForArrow.length - 2];
826
+ const p2 = ptsForArrow[ptsForArrow.length - 1];
827
+ this.ctx.fillStyle = this.theme.accentBright;
828
+ this._drawArrowhead(p1.x, p1.y, p2.x, p2.y, 10);
829
+ }
830
+ }
831
+
832
+ this._resetTransform();
833
+ }
834
+ }
835
+
836
+ function roundRect(ctx, x, y, w, h, r = 6) {
837
+ if (typeof r === "number") r = { tl: r, tr: r, br: r, bl: r };
838
+ ctx.beginPath();
839
+ ctx.moveTo(x + r.tl, y);
840
+ ctx.lineTo(x + w - r.tr, y);
841
+ ctx.quadraticCurveTo(x + w, y, x + w, y + r.tr);
842
+ ctx.lineTo(x + w, y + h - r.br);
843
+ ctx.quadraticCurveTo(x + w, y + h, x + w - r.br, y + h);
844
+ ctx.lineTo(x + r.bl, y + h);
845
+ ctx.quadraticCurveTo(x, y + h, x, y + h - r.bl);
846
+ ctx.lineTo(x, y + r.tl);
847
+ ctx.quadraticCurveTo(x, y, x + r.tl, y);
848
+ ctx.closePath();
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
+ }