@synergenius/flow-weaver 0.9.4 → 0.10.0

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,21 +1,32 @@
1
1
  /**
2
2
  * HTML Viewer — wraps an SVG diagram in a self-contained interactive HTML page.
3
3
  *
4
- * Provides zoom/pan, fit-to-view, hover effects, click-to-inspect, and connection tracing.
4
+ * The SVG element itself is the canvas: zoom and pan are driven by viewBox
5
+ * manipulation, so nodes can be dragged anywhere without hitting a boundary.
5
6
  * No external dependencies — works standalone or inside an iframe.
6
7
  */
7
- /** Strip the SVG background rects so the HTML page controls the background. */
8
- function stripSvgBackground(svg) {
9
- let result = svg.replace(/<pattern\s+id="dot-grid"[^>]*>[\s\S]*?<\/pattern>/g, '');
10
- result = result.replace(/<rect[^>]*fill="url\(#dot-grid\)"[^>]*\/>/g, '');
11
- // Remove the solid background rect (first rect after </defs>)
12
- result = result.replace(/(<\/defs>\n)<rect[^>]*\/>\n/, '$1');
13
- return result;
8
+ /**
9
+ * Extract inner SVG content and viewBox from the rendered SVG string.
10
+ * Strips backgrounds, dot grid, and watermark (the viewer provides its own).
11
+ */
12
+ function prepareSvgContent(svg) {
13
+ const vbMatch = svg.match(/viewBox="([^"]+)"/);
14
+ const viewBox = vbMatch ? vbMatch[1] : '0 0 800 600';
15
+ // Strip the outer <svg> wrapper — we inline content into the canvas SVG
16
+ let inner = svg.replace(/<svg[^>]*>\n?/, '').replace(/<\/svg>\s*$/, '');
17
+ // Remove dot-grid pattern and its fill rect (viewer uses its own)
18
+ inner = inner.replace(/<pattern\s+id="dot-grid"[^>]*>[\s\S]*?<\/pattern>/g, '');
19
+ inner = inner.replace(/<rect[^>]*fill="url\(#dot-grid\)"[^>]*\/>/g, '');
20
+ // Remove solid background rect (first rect after </defs>)
21
+ inner = inner.replace(/(<\/defs>\n)<rect[^>]*\/>\n/, '$1');
22
+ // Remove watermark (HTML viewer has its own branding badge)
23
+ inner = inner.replace(/<g opacity="0\.5">[\s\S]*?Flow Weaver<\/text>\s*<\/g>/, '');
24
+ return { inner, viewBox };
14
25
  }
15
26
  export function wrapSVGInHTML(svgContent, options = {}) {
16
27
  const title = options.title ?? 'Workflow Diagram';
17
28
  const theme = options.theme ?? 'dark';
18
- const svg = stripSvgBackground(svgContent);
29
+ const { inner, viewBox } = prepareSvgContent(svgContent);
19
30
  const isDark = theme === 'dark';
20
31
  const bg = isDark ? '#202139' : '#f6f7ff';
21
32
  const dotColor = isDark ? 'rgba(142, 158, 255, 0.6)' : 'rgba(84, 104, 255, 0.6)';
@@ -25,6 +36,7 @@ export function wrapSVGInHTML(svgContent, options = {}) {
25
36
  const textMed = isDark ? '#babac0' : '#606060';
26
37
  const textLow = isDark ? '#767682' : '#999999';
27
38
  const surfaceHigh = isDark ? '#313143' : '#f0f0f5';
39
+ const brandAccent = isDark ? '#8e9eff' : '#5468ff';
28
40
  return `<!DOCTYPE html>
29
41
  <html lang="en">
30
42
  <head>
@@ -36,25 +48,16 @@ export function wrapSVGInHTML(svgContent, options = {}) {
36
48
 
37
49
  body {
38
50
  width: 100vw; height: 100vh; overflow: hidden;
39
- background: ${bg};
40
51
  font-family: Montserrat, 'Segoe UI', Roboto, sans-serif;
41
52
  color: ${textHigh};
42
53
  }
43
54
 
44
- #viewport {
45
- width: 100%; height: 100%;
46
- overflow: hidden; cursor: grab;
55
+ #canvas {
56
+ display: block; width: 100%; height: 100%;
57
+ cursor: grab;
47
58
  touch-action: none; user-select: none;
48
- background-image: radial-gradient(circle, ${dotColor} 7.5%, transparent 7.5%);
49
- background-size: 20px 20px;
50
- }
51
- #viewport.dragging { cursor: grabbing; }
52
-
53
- #content {
54
- transform-origin: 0 0;
55
- will-change: transform;
56
59
  }
57
- #content svg { display: block; width: auto; height: auto; }
60
+ #canvas.dragging { cursor: grabbing; }
58
61
 
59
62
  /* Port labels: hidden by default, shown on node hover */
60
63
  .nodes > g .port-label,
@@ -63,16 +66,36 @@ body {
63
66
  opacity: 0; pointer-events: none;
64
67
  transition: opacity 0.15s ease-in-out;
65
68
  }
66
- /* Show port labels for hovered node */
67
- .nodes > g:hover ~ .show-port-labels .port-label,
68
- .nodes > g:hover ~ .show-port-labels .port-type-label { opacity: 1; }
69
69
 
70
70
  /* Connection hover & dimming (attribute selector covers both main and scope connections) */
71
71
  path[data-source] { transition: opacity 0.2s ease, stroke-width 0.15s ease; }
72
72
  path[data-source]:hover { stroke-width: 4; cursor: pointer; }
73
- body.node-active path[data-source].dimmed { opacity: 0.15; }
73
+ body.node-active path[data-source].dimmed,
74
+ body.port-active path[data-source].dimmed { opacity: 0.1; }
75
+ body.port-hovered path[data-source].dimmed { opacity: 0.25; }
76
+
77
+ /* Port circles are interactive */
78
+ circle[data-port-id] { cursor: pointer; }
79
+ circle[data-port-id]:hover { stroke-width: 3; filter: brightness(1.3); }
80
+
81
+ /* Port-click highlighting */
82
+ path[data-source].highlighted { opacity: 1; }
83
+ circle[data-port-id].port-selected { filter: drop-shadow(0 0 6px currentColor); stroke-width: 4; }
84
+
85
+ /* Node selection glow */
86
+ @keyframes select-pop {
87
+ 0% { opacity: 0; stroke-width: 0; }
88
+ 50% { opacity: 0.6; stroke-width: 12; }
89
+ 100% { opacity: 0.35; stroke-width: 8; }
90
+ }
91
+ .node-glow { fill: none; pointer-events: none; animation: select-pop 0.3s ease-out forwards; }
92
+
93
+ /* Port hover path highlight */
94
+ path[data-source].port-hover { opacity: 1; }
74
95
 
75
- /* Node hover glow */
96
+ /* Node hover glow + draggable cursor */
97
+ .nodes g[data-node-id] { cursor: grab; }
98
+ .nodes g[data-node-id]:active { cursor: grabbing; }
76
99
  .nodes g[data-node-id]:hover > rect:first-of-type { filter: brightness(1.08); }
77
100
 
78
101
  /* Zoom controls */
@@ -98,7 +121,7 @@ body.node-active path[data-source].dimmed { opacity: 0.15; }
98
121
 
99
122
  /* Info panel */
100
123
  #info-panel {
101
- position: fixed; bottom: 16px; left: 16px;
124
+ position: fixed; bottom: 52px; left: 16px;
102
125
  max-width: 320px; min-width: 200px;
103
126
  background: ${surfaceMain}; border: 1px solid ${borderSubtle};
104
127
  border-radius: 8px; padding: 12px 16px;
@@ -118,6 +141,19 @@ body.node-active path[data-source].dimmed { opacity: 0.15; }
118
141
  #info-panel .port-list li { padding: 1px 0; }
119
142
  #info-panel .port-list li::before { content: '\\2022'; margin-right: 6px; color: ${textLow}; }
120
143
 
144
+ /* Branding badge */
145
+ #branding {
146
+ position: fixed; bottom: 16px; left: 16px;
147
+ display: flex; align-items: center; gap: 6px;
148
+ background: ${surfaceMain}; border: 1px solid ${borderSubtle};
149
+ border-radius: 8px; padding: 6px 12px;
150
+ font-size: 12px; font-weight: 600; color: ${textMed};
151
+ text-decoration: none; z-index: 9;
152
+ box-shadow: 0 2px 8px rgba(0,0,0,0.2);
153
+ transition: color 0.15s, border-color 0.15s;
154
+ }
155
+ #branding:hover { color: ${textHigh}; border-color: ${textLow}; }
156
+
121
157
  /* Scroll hint */
122
158
  #scroll-hint {
123
159
  position: fixed; top: 50%; left: 50%;
@@ -137,9 +173,16 @@ body.node-active path[data-source].dimmed { opacity: 0.15; }
137
173
  </style>
138
174
  </head>
139
175
  <body>
140
- <div id="viewport">
141
- <div id="content">${svg}</div>
142
- </div>
176
+ <svg id="canvas" xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox}">
177
+ <defs>
178
+ <pattern id="viewer-dots" width="20" height="20" patternUnits="userSpaceOnUse">
179
+ <circle cx="10" cy="10" r="1.5" fill="${dotColor}" opacity="0.6"/>
180
+ </pattern>
181
+ </defs>
182
+ <rect x="-100000" y="-100000" width="200000" height="200000" fill="${bg}"/>
183
+ <rect x="-100000" y="-100000" width="200000" height="200000" fill="url(#viewer-dots)"/>
184
+ <g id="diagram">${inner}</g>
185
+ </svg>
143
186
  <div id="controls">
144
187
  <button class="ctrl-btn" id="btn-in" title="Zoom in" aria-label="Zoom in">+</button>
145
188
  <span id="zoom-label">100%</span>
@@ -155,23 +198,33 @@ body.node-active path[data-source].dimmed { opacity: 0.15; }
155
198
  <h3 id="info-title"></h3>
156
199
  <div id="info-body"></div>
157
200
  </div>
201
+ <a id="branding" href="https://flowweaver.ai" target="_blank" rel="noopener">
202
+ <svg width="16" height="16" viewBox="0 0 256 256" fill="none"><path d="M80 128C134 128 122 49 176 49" stroke="${brandAccent}" stroke-width="14" stroke-linecap="round"/><path d="M80 128C134 128 122 207 176 207" stroke="${brandAccent}" stroke-width="14" stroke-linecap="round"/><rect x="28" y="102" width="52" height="52" rx="10" stroke="${brandAccent}" stroke-width="14"/><rect x="176" y="23" width="52" height="52" rx="10" stroke="${brandAccent}" stroke-width="14"/><rect x="176" y="181" width="52" height="52" rx="10" stroke="${brandAccent}" stroke-width="14"/></svg>
203
+ <span>Flow Weaver</span>
204
+ </a>
158
205
  <div id="scroll-hint">Use <kbd id="mod-key">Ctrl</kbd> + scroll to zoom</div>
159
206
  <script>
160
207
  (function() {
161
208
  'use strict';
162
209
 
163
- var MIN_ZOOM = 0.25, MAX_ZOOM = 3, GRID_SIZE = 20;
164
- var viewport = document.getElementById('viewport');
165
- var content = document.getElementById('content');
210
+ var MIN_ZOOM = 0.25, MAX_ZOOM = 3;
211
+ var canvas = document.getElementById('canvas');
212
+ var content = document.getElementById('diagram');
166
213
  var zoomLabel = document.getElementById('zoom-label');
167
214
  var infoPanel = document.getElementById('info-panel');
168
215
  var infoTitle = document.getElementById('info-title');
169
216
  var infoBody = document.getElementById('info-body');
170
217
  var scrollHint = document.getElementById('scroll-hint');
171
218
 
172
- var scale = 1, tx = 0, ty = 0;
173
- var dragging = false, dragLast = { x: 0, y: 0 };
219
+ // Parse the original viewBox (diagram bounding box)
220
+ var vbParts = '${viewBox}'.split(/\\s+/).map(Number);
221
+ var origX = vbParts[0], origY = vbParts[1], origW = vbParts[2], origH = vbParts[3];
222
+ var vbX = origX, vbY = origY, vbW = origW, vbH = origH;
223
+ var baseW = origW; // reference width for 100% zoom
224
+
225
+ var pointerDown = false, didDrag = false, dragLast = { x: 0, y: 0 };
174
226
  var selectedNodeId = null;
227
+ var selectedPortId = null;
175
228
  var hintTimer = null;
176
229
 
177
230
  // Detect Mac for modifier key
@@ -180,29 +233,34 @@ body.node-active path[data-source].dimmed { opacity: 0.15; }
180
233
 
181
234
  function clamp(v, min, max) { return Math.min(max, Math.max(min, v)); }
182
235
 
183
- function applyTransform() {
184
- content.style.transform = 'translate(' + tx + 'px,' + ty + 'px) scale(' + scale + ')';
185
- viewport.style.backgroundSize = (GRID_SIZE * scale) + 'px ' + (GRID_SIZE * scale) + 'px';
186
- viewport.style.backgroundPosition = tx + 'px ' + ty + 'px';
187
- zoomLabel.textContent = Math.round(scale * 100) + '%';
236
+ function applyViewBox() {
237
+ canvas.setAttribute('viewBox', vbX + ' ' + vbY + ' ' + vbW + ' ' + vbH);
238
+ zoomLabel.textContent = Math.round(baseW / vbW * 100) + '%';
188
239
  }
189
240
 
190
241
  function fitToView() {
191
- var svgEl = content.querySelector('svg');
192
- if (!svgEl) { scale = 1; tx = 0; ty = 0; applyTransform(); return; }
193
- var ww = viewport.clientWidth, wh = viewport.clientHeight;
194
- var sw = svgEl.width.baseVal.value || svgEl.getBoundingClientRect().width;
195
- var sh = svgEl.height.baseVal.value || svgEl.getBoundingClientRect().height;
196
- var padding = 60;
197
- var fitScale = Math.min((ww - padding) / sw, (wh - padding) / sh, 1);
198
- scale = fitScale;
199
- tx = (ww - sw * fitScale) / 2;
200
- ty = (wh - sh * fitScale) / 2;
201
- applyTransform();
242
+ var pad = 60;
243
+ var cw = canvas.clientWidth, ch = canvas.clientHeight;
244
+ if (!cw || !ch) return;
245
+ var dw = origW + pad * 2, dh = origH + pad * 2;
246
+ var vpRatio = cw / ch;
247
+ var dRatio = dw / dh;
248
+ if (vpRatio > dRatio) {
249
+ vbH = dh; vbW = dh * vpRatio;
250
+ } else {
251
+ vbW = dw; vbH = dw / vpRatio;
252
+ }
253
+ vbX = origX - pad - (vbW - dw) / 2;
254
+ vbY = origY - pad - (vbH - dh) / 2;
255
+ baseW = vbW;
256
+ applyViewBox();
202
257
  }
203
258
 
259
+ // Convert pixel delta to SVG coordinate delta
260
+ function pxToSvg() { return vbW / canvas.clientWidth; }
261
+
204
262
  // ---- Zoom (Ctrl/Cmd + scroll) ----
205
- viewport.addEventListener('wheel', function(e) {
263
+ canvas.addEventListener('wheel', function(e) {
206
264
  if (!e.ctrlKey && !e.metaKey) {
207
265
  scrollHint.classList.add('visible');
208
266
  clearTimeout(hintTimer);
@@ -210,48 +268,100 @@ body.node-active path[data-source].dimmed { opacity: 0.15; }
210
268
  return;
211
269
  }
212
270
  e.preventDefault();
213
- var rect = viewport.getBoundingClientRect();
214
- var cx = e.clientX - rect.left, cy = e.clientY - rect.top;
215
- var oldScale = scale;
271
+ var rect = canvas.getBoundingClientRect();
272
+ var mx = (e.clientX - rect.left) / rect.width;
273
+ var my = (e.clientY - rect.top) / rect.height;
274
+ var pivotX = vbX + mx * vbW;
275
+ var pivotY = vbY + my * vbH;
216
276
  var delta = clamp(e.deltaY, -10, 10);
217
- var newScale = clamp(oldScale - delta * 0.005, MIN_ZOOM, MAX_ZOOM);
218
- var contentX = (cx - tx) / oldScale, contentY = (cy - ty) / oldScale;
219
- tx = cx - contentX * newScale;
220
- ty = cy - contentY * newScale;
221
- scale = newScale;
222
- applyTransform();
277
+ var factor = 1 + delta * 0.005;
278
+ var newW = clamp(vbW * factor, baseW / MAX_ZOOM, baseW / MIN_ZOOM);
279
+ var ratio = vbH / vbW;
280
+ var newH = newW * ratio;
281
+ vbX = pivotX - mx * newW;
282
+ vbY = pivotY - my * newH;
283
+ vbW = newW; vbH = newH;
284
+ applyViewBox();
223
285
  }, { passive: false });
224
286
 
225
- // ---- Pan (drag) ----
226
- viewport.addEventListener('pointerdown', function(e) {
287
+ // ---- Pan (drag) + Node drag ----
288
+ var draggedNodeId = null, dragNodeStart = null, didDragNode = false;
289
+
290
+ canvas.addEventListener('pointerdown', function(e) {
227
291
  if (e.button !== 0) return;
228
- dragging = true;
292
+ // Check if clicking on a node body (walk up to detect data-node-id)
293
+ var t = e.target;
294
+ while (t && t !== canvas) {
295
+ if (t.hasAttribute && t.hasAttribute('data-node-id')) {
296
+ // Don't start node drag if clicking on a port circle
297
+ if (e.target.hasAttribute && e.target.hasAttribute('data-port-id')) break;
298
+ draggedNodeId = t.getAttribute('data-node-id');
299
+ dragNodeStart = { x: e.clientX, y: e.clientY };
300
+ didDragNode = false;
301
+ canvas.setPointerCapture(e.pointerId);
302
+ return;
303
+ }
304
+ t = t.parentElement;
305
+ }
306
+ // Canvas pan
307
+ pointerDown = true;
308
+ didDrag = false;
229
309
  dragLast = { x: e.clientX, y: e.clientY };
230
- viewport.setPointerCapture(e.pointerId);
231
- viewport.classList.add('dragging');
310
+ canvas.setPointerCapture(e.pointerId);
232
311
  });
233
- viewport.addEventListener('pointermove', function(e) {
234
- if (!dragging) return;
235
- tx += e.clientX - dragLast.x;
236
- ty += e.clientY - dragLast.y;
312
+
313
+ canvas.addEventListener('pointermove', function(e) {
314
+ var ratio = pxToSvg();
315
+
316
+ // Node drag
317
+ if (draggedNodeId) {
318
+ var dx = (e.clientX - dragNodeStart.x) * ratio;
319
+ var dy = (e.clientY - dragNodeStart.y) * ratio;
320
+ if (!didDragNode && (Math.abs(dx) > 3 || Math.abs(dy) > 3)) {
321
+ didDragNode = true;
322
+ canvas.classList.add('dragging');
323
+ }
324
+ if (didDragNode) {
325
+ moveNode(draggedNodeId, dx, dy);
326
+ }
327
+ dragNodeStart = { x: e.clientX, y: e.clientY };
328
+ return;
329
+ }
330
+
331
+ // Canvas pan — shift the viewBox origin
332
+ if (!pointerDown) return;
333
+ var dxPx = e.clientX - dragLast.x, dyPx = e.clientY - dragLast.y;
334
+ if (!didDrag && (Math.abs(dxPx) > 3 || Math.abs(dyPx) > 3)) {
335
+ didDrag = true;
336
+ canvas.classList.add('dragging');
337
+ }
338
+ if (didDrag) {
339
+ vbX -= dxPx * ratio;
340
+ vbY -= dyPx * ratio;
341
+ applyViewBox();
342
+ }
237
343
  dragLast = { x: e.clientX, y: e.clientY };
238
- applyTransform();
239
344
  });
240
- function endDrag() { dragging = false; viewport.classList.remove('dragging'); }
241
- viewport.addEventListener('pointerup', endDrag);
242
- viewport.addEventListener('pointercancel', endDrag);
345
+
346
+ function endDrag() {
347
+ pointerDown = false;
348
+ draggedNodeId = null;
349
+ canvas.classList.remove('dragging');
350
+ }
351
+ canvas.addEventListener('pointerup', endDrag);
352
+ canvas.addEventListener('pointercancel', endDrag);
243
353
 
244
354
  // ---- Zoom buttons ----
245
355
  function zoomBy(dir) {
246
- var rect = viewport.getBoundingClientRect();
247
- var cx = rect.width / 2, cy = rect.height / 2;
248
- var oldScale = scale;
249
- var newScale = clamp(oldScale + dir * 0.15 * oldScale, MIN_ZOOM, MAX_ZOOM);
250
- var contentX = (cx - tx) / oldScale, contentY = (cy - ty) / oldScale;
251
- tx = cx - contentX * newScale;
252
- ty = cy - contentY * newScale;
253
- scale = newScale;
254
- applyTransform();
356
+ var cx = vbX + vbW / 2, cy = vbY + vbH / 2;
357
+ var factor = dir > 0 ? 0.85 : 1.18;
358
+ var newW = clamp(vbW * factor, baseW / MAX_ZOOM, baseW / MIN_ZOOM);
359
+ var ratio = vbH / vbW;
360
+ var newH = newW * ratio;
361
+ vbX = cx - newW / 2;
362
+ vbY = cy - newH / 2;
363
+ vbW = newW; vbH = newH;
364
+ applyViewBox();
255
365
  }
256
366
  document.getElementById('btn-in').addEventListener('click', function() { zoomBy(1); });
257
367
  document.getElementById('btn-out').addEventListener('click', function() { zoomBy(-1); });
@@ -263,7 +373,7 @@ body.node-active path[data-source].dimmed { opacity: 0.15; }
263
373
  if (e.key === '+' || e.key === '=') zoomBy(1);
264
374
  else if (e.key === '-') zoomBy(-1);
265
375
  else if (e.key === '0') fitToView();
266
- else if (e.key === 'Escape') deselectNode();
376
+ else if (e.key === 'Escape') { deselectPort(); deselectNode(); }
267
377
  });
268
378
 
269
379
  // ---- Port label visibility ----
@@ -272,7 +382,7 @@ body.node-active path[data-source].dimmed { opacity: 0.15; }
272
382
  labelMap[lbl.getAttribute('data-port-label')] = lbl;
273
383
  });
274
384
 
275
- // Build adjacency: portId array of connected portIds
385
+ // Build adjacency: portId -> array of connected portIds
276
386
  var portConnections = {};
277
387
  content.querySelectorAll('path[data-source]').forEach(function(p) {
278
388
  var src = p.getAttribute('data-source');
@@ -284,6 +394,125 @@ body.node-active path[data-source].dimmed { opacity: 0.15; }
284
394
  portConnections[tgt].push(src);
285
395
  });
286
396
 
397
+ // ---- Connection path computation (from geometry.ts) ----
398
+ function quadCurveControl(ax, ay, bx, by, ux, uy) {
399
+ var dn = Math.abs(ay - by);
400
+ return [bx + (ux * dn) / Math.abs(uy), ay];
401
+ }
402
+
403
+ function computeConnectionPath(sx, sy, tx, ty) {
404
+ var e = 0.0001;
405
+ var ax = sx + e, ay = sy + e, hx = tx - e, hy = ty - e;
406
+ var ramp = Math.min(20, (hx - ax) / 10);
407
+ var bx = ax + ramp, by = ay + e, gx = hx - ramp, gy = hy - e;
408
+ var curveSizeX = Math.min(60, Math.abs(ax - hx) / 4);
409
+ var curveSizeY = Math.min(60, Math.abs(ay - hy) / 4);
410
+ var curveMag = Math.sqrt(curveSizeX * curveSizeX + curveSizeY * curveSizeY);
411
+ var bgX = gx - bx, bgY = gy - by;
412
+ var bgLen = Math.sqrt(bgX * bgX + bgY * bgY);
413
+ var bgUx = bgX / bgLen, bgUy = bgY / bgLen;
414
+ var dx = bx + bgUx * curveMag, dy = by + (bgUy * curveMag) / 2;
415
+ var ex = gx - bgUx * curveMag, ey = gy - (bgUy * curveMag) / 2;
416
+ var deX = ex - dx, deY = ey - dy;
417
+ var deLen = Math.sqrt(deX * deX + deY * deY);
418
+ var deUx = deX / deLen, deUy = deY / deLen;
419
+ var c = quadCurveControl(bx, by, dx, dy, -deUx, -deUy);
420
+ var f = quadCurveControl(gx, gy, ex, ey, deUx, deUy);
421
+ return 'M ' + c[0] + ',' + c[1] + ' M ' + ax + ',' + ay +
422
+ ' L ' + bx + ',' + by + ' Q ' + c[0] + ',' + c[1] + ' ' + dx + ',' + dy +
423
+ ' L ' + ex + ',' + ey + ' Q ' + f[0] + ',' + f[1] + ' ' + gx + ',' + gy +
424
+ ' L ' + hx + ',' + hy;
425
+ }
426
+
427
+ // ---- Port position + connection path indexes ----
428
+ var portPositions = {};
429
+ content.querySelectorAll('[data-port-id]').forEach(function(el) {
430
+ var id = el.getAttribute('data-port-id');
431
+ portPositions[id] = { cx: parseFloat(el.getAttribute('cx')), cy: parseFloat(el.getAttribute('cy')) };
432
+ });
433
+
434
+ var nodeOffsets = {};
435
+ var connIndex = [];
436
+ content.querySelectorAll('path[data-source]').forEach(function(p) {
437
+ var src = p.getAttribute('data-source'), tgt = p.getAttribute('data-target');
438
+ connIndex.push({ el: p, src: src, tgt: tgt, srcNode: src.split('.')[0], tgtNode: tgt.split('.')[0] });
439
+ });
440
+
441
+ // ---- Node drag: moveNode ----
442
+ function moveNode(nodeId, dx, dy) {
443
+ if (!nodeOffsets[nodeId]) nodeOffsets[nodeId] = { dx: 0, dy: 0 };
444
+ var off = nodeOffsets[nodeId];
445
+ off.dx += dx; off.dy += dy;
446
+ var tr = 'translate(' + off.dx + ',' + off.dy + ')';
447
+
448
+ // Move node group
449
+ var nodeG = content.querySelector('.nodes [data-node-id="' + CSS.escape(nodeId) + '"]');
450
+ if (nodeG) nodeG.setAttribute('transform', tr);
451
+
452
+ // Move label
453
+ var labelG = content.querySelector('[data-label-for="' + CSS.escape(nodeId) + '"]');
454
+ if (labelG) labelG.setAttribute('transform', tr);
455
+
456
+ // Move port labels
457
+ allLabelIds.forEach(function(id) {
458
+ if (id.indexOf(nodeId + '.') === 0) {
459
+ var el = labelMap[id];
460
+ if (el) el.setAttribute('transform', tr);
461
+ }
462
+ });
463
+
464
+ // Update port positions
465
+ for (var pid in portPositions) {
466
+ if (pid.indexOf(nodeId + '.') === 0) {
467
+ portPositions[pid].cx += dx;
468
+ portPositions[pid].cy += dy;
469
+ }
470
+ }
471
+
472
+ // Move child nodes inside scoped parents
473
+ if (nodeG) {
474
+ var children = nodeG.querySelectorAll(':scope > g[data-node-id]');
475
+ children.forEach(function(childG) {
476
+ var childId = childG.getAttribute('data-node-id');
477
+ if (!nodeOffsets[childId]) nodeOffsets[childId] = { dx: 0, dy: 0 };
478
+ nodeOffsets[childId].dx += dx;
479
+ nodeOffsets[childId].dy += dy;
480
+ for (var pid in portPositions) {
481
+ if (pid.indexOf(childId + '.') === 0) {
482
+ portPositions[pid].cx += dx;
483
+ portPositions[pid].cy += dy;
484
+ }
485
+ }
486
+ var childLabel = content.querySelector('[data-label-for="' + CSS.escape(childId) + '"]');
487
+ if (childLabel) childLabel.setAttribute('transform', 'translate(' + nodeOffsets[childId].dx + ',' + nodeOffsets[childId].dy + ')');
488
+ allLabelIds.forEach(function(id) {
489
+ if (id.indexOf(childId + '.') === 0) {
490
+ var el = labelMap[id];
491
+ if (el) el.setAttribute('transform', 'translate(' + nodeOffsets[childId].dx + ',' + nodeOffsets[childId].dy + ')');
492
+ }
493
+ });
494
+ });
495
+ }
496
+
497
+ // Recalculate affected connection paths
498
+ connIndex.forEach(function(c) {
499
+ if (c.srcNode === nodeId || c.tgtNode === nodeId) {
500
+ var sp = portPositions[c.src], tp = portPositions[c.tgt];
501
+ if (sp && tp) c.el.setAttribute('d', computeConnectionPath(sp.cx, sp.cy, tp.cx, tp.cy));
502
+ }
503
+ if (nodeG) {
504
+ var children = nodeG.querySelectorAll(':scope > g[data-node-id]');
505
+ children.forEach(function(childG) {
506
+ var childId = childG.getAttribute('data-node-id');
507
+ if (c.srcNode === childId || c.tgtNode === childId) {
508
+ var sp = portPositions[c.src], tp = portPositions[c.tgt];
509
+ if (sp && tp) c.el.setAttribute('d', computeConnectionPath(sp.cx, sp.cy, tp.cx, tp.cy));
510
+ }
511
+ });
512
+ }
513
+ });
514
+ }
515
+
287
516
  var allLabelIds = Object.keys(labelMap);
288
517
  var hoveredPort = null;
289
518
 
@@ -308,7 +537,7 @@ body.node-active path[data-source].dimmed { opacity: 0.15; }
308
537
  var parentNodeG = nodeG.parentElement ? nodeG.parentElement.closest('g[data-node-id]') : null;
309
538
  var parentId = parentNodeG ? parentNodeG.getAttribute('data-node-id') : null;
310
539
  nodeG.addEventListener('mouseenter', function() {
311
- if (hoveredPort) return; // port hover takes priority
540
+ if (hoveredPort) return;
312
541
  if (parentId) hideLabelsFor(parentId);
313
542
  showLabelsFor(nodeId);
314
543
  });
@@ -327,24 +556,93 @@ body.node-active path[data-source].dimmed { opacity: 0.15; }
327
556
 
328
557
  portEl.addEventListener('mouseenter', function() {
329
558
  hoveredPort = portId;
330
- // Hide all labels for this node first, then show only the relevant ones
331
559
  hideLabelsFor(nodeId);
332
560
  peers.forEach(showLabel);
561
+ document.body.classList.add('port-hovered');
562
+ content.querySelectorAll('path[data-source]').forEach(function(p) {
563
+ if (p.getAttribute('data-source') === portId || p.getAttribute('data-target') === portId) {
564
+ p.classList.remove('dimmed');
565
+ } else {
566
+ p.classList.add('dimmed');
567
+ }
568
+ });
333
569
  });
334
570
  portEl.addEventListener('mouseleave', function() {
335
571
  hoveredPort = null;
336
572
  peers.forEach(hideLabel);
337
- // Restore all labels for the node since we're still inside it
338
573
  showLabelsFor(nodeId);
574
+ document.body.classList.remove('port-hovered');
575
+ content.querySelectorAll('path[data-source].dimmed').forEach(function(p) {
576
+ p.classList.remove('dimmed');
577
+ });
339
578
  });
340
579
  });
341
580
 
581
+ // ---- Node glow helpers ----
582
+ function removeNodeGlow() {
583
+ var glow = content.querySelector('.node-glow');
584
+ if (glow) glow.remove();
585
+ }
586
+
587
+ function addNodeGlow(nodeG) {
588
+ removeNodeGlow();
589
+ var rect = nodeG.querySelector('rect');
590
+ if (!rect) return;
591
+ var ns = 'http://www.w3.org/2000/svg';
592
+ var glow = document.createElementNS(ns, 'rect');
593
+ glow.setAttribute('x', rect.getAttribute('x'));
594
+ glow.setAttribute('y', rect.getAttribute('y'));
595
+ glow.setAttribute('width', rect.getAttribute('width'));
596
+ glow.setAttribute('height', rect.getAttribute('height'));
597
+ glow.setAttribute('rx', rect.getAttribute('rx') || '0');
598
+ glow.setAttribute('stroke', rect.getAttribute('stroke') || '#5468ff');
599
+ glow.setAttribute('class', 'node-glow');
600
+ nodeG.insertBefore(glow, rect);
601
+ }
602
+
603
+ // ---- Port selection ----
604
+ function deselectPort() {
605
+ if (!selectedPortId) return;
606
+ selectedPortId = null;
607
+ document.body.classList.remove('port-active');
608
+ content.querySelectorAll('circle.port-selected').forEach(function(c) {
609
+ c.classList.remove('port-selected');
610
+ });
611
+ content.querySelectorAll('path[data-source].dimmed, path[data-source].highlighted').forEach(function(p) {
612
+ p.classList.remove('dimmed');
613
+ p.classList.remove('highlighted');
614
+ });
615
+ }
616
+
617
+ function selectPort(portId) {
618
+ if (selectedPortId === portId) { deselectPort(); return; }
619
+ if (selectedNodeId) deselectNode();
620
+ deselectPort();
621
+ selectedPortId = portId;
622
+ document.body.classList.add('port-active');
623
+
624
+ var portEl = content.querySelector('[data-port-id="' + CSS.escape(portId) + '"]');
625
+ if (portEl) portEl.classList.add('port-selected');
626
+
627
+ content.querySelectorAll('path[data-source]').forEach(function(p) {
628
+ if (p.getAttribute('data-source') === portId || p.getAttribute('data-target') === portId) {
629
+ p.classList.add('highlighted');
630
+ } else {
631
+ p.classList.add('dimmed');
632
+ }
633
+ });
634
+
635
+ var peers = (portConnections[portId] || []).concat(portId);
636
+ peers.forEach(showLabel);
637
+ }
638
+
342
639
  // ---- Click to inspect node ----
343
640
  function deselectNode() {
344
641
  selectedNodeId = null;
345
642
  document.body.classList.remove('node-active');
346
643
  infoPanel.classList.remove('visible');
347
- content.querySelectorAll('.connections path.dimmed').forEach(function(p) {
644
+ removeNodeGlow();
645
+ content.querySelectorAll('path[data-source].dimmed').forEach(function(p) {
348
646
  p.classList.remove('dimmed');
349
647
  });
350
648
  }
@@ -354,8 +652,9 @@ body.node-active path[data-source].dimmed { opacity: 0.15; }
354
652
  selectedNodeId = nodeId;
355
653
  document.body.classList.add('node-active');
356
654
 
357
- // Gather info
358
655
  var nodeG = content.querySelector('[data-node-id="' + CSS.escape(nodeId) + '"]');
656
+ addNodeGlow(nodeG);
657
+
359
658
  var labelG = content.querySelector('[data-label-for="' + CSS.escape(nodeId) + '"]');
360
659
  var labelText = labelG ? (labelG.querySelector('.node-label') || {}).textContent || nodeId : nodeId;
361
660
 
@@ -372,7 +671,6 @@ body.node-active path[data-source].dimmed { opacity: 0.15; }
372
671
 
373
672
  // Connected paths
374
673
  var allPaths = content.querySelectorAll('path[data-source]');
375
- var connectedPaths = [];
376
674
  var connectedNodes = new Set();
377
675
  allPaths.forEach(function(p) {
378
676
  var src = p.getAttribute('data-source') || '';
@@ -380,7 +678,6 @@ body.node-active path[data-source].dimmed { opacity: 0.15; }
380
678
  var srcNode = src.split('.')[0];
381
679
  var tgtNode = tgt.split('.')[0];
382
680
  if (srcNode === nodeId || tgtNode === nodeId) {
383
- connectedPaths.push(p);
384
681
  if (srcNode !== nodeId) connectedNodes.add(srcNode);
385
682
  if (tgtNode !== nodeId) connectedNodes.add(tgtNode);
386
683
  p.classList.remove('dimmed');
@@ -415,20 +712,25 @@ body.node-active path[data-source].dimmed { opacity: 0.15; }
415
712
  return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
416
713
  }
417
714
 
418
- // Delegate click on node groups
419
- viewport.addEventListener('click', function(e) {
420
- if (dragging) return;
715
+ // Delegate click: port click > node click > background
716
+ canvas.addEventListener('click', function(e) {
717
+ if (didDrag || didDragNode) { didDragNode = false; return; }
421
718
  var target = e.target;
422
- // Walk up to find a [data-node-id] ancestor within #content
423
- while (target && target !== viewport) {
719
+ while (target && target !== canvas) {
720
+ if (target.hasAttribute && target.hasAttribute('data-port-id')) {
721
+ e.stopPropagation();
722
+ selectPort(target.getAttribute('data-port-id'));
723
+ return;
724
+ }
424
725
  if (target.hasAttribute && target.hasAttribute('data-node-id')) {
425
726
  e.stopPropagation();
727
+ deselectPort();
426
728
  selectNode(target.getAttribute('data-node-id'));
427
729
  return;
428
730
  }
429
731
  target = target.parentElement;
430
732
  }
431
- // Clicked on background
733
+ deselectPort();
432
734
  deselectNode();
433
735
  });
434
736