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