agentic-blocks 0.1.18__py3-none-any.whl → 0.1.20__py3-none-any.whl

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.
@@ -0,0 +1,1014 @@
1
+ # %%
2
+
3
+ import json
4
+ import os
5
+ import http.server
6
+ import socketserver
7
+ import threading
8
+ import webbrowser
9
+ import time
10
+ import socket
11
+ import importlib
12
+ import sys
13
+ from pathlib import Path
14
+ from typing import Any, Optional, Tuple, Union
15
+
16
+ from pocketflow import Flow
17
+
18
+
19
+ def build_mermaid(start):
20
+ ids, visited, lines = {}, set(), ["graph LR"]
21
+ ctr = 1
22
+
23
+ def get_id(n):
24
+ nonlocal ctr
25
+ return (
26
+ ids[n] if n in ids else (ids.setdefault(n, f"N{ctr}"), (ctr := ctr + 1))[0]
27
+ )
28
+
29
+ def link(a, b, action=None):
30
+ if action:
31
+ lines.append(f" {a} -->|{action}| {b}")
32
+ else:
33
+ lines.append(f" {a} --> {b}")
34
+
35
+ def walk(node, parent=None, action=None):
36
+ if node in visited:
37
+ return parent and link(parent, get_id(node), action)
38
+ visited.add(node)
39
+ if isinstance(node, Flow):
40
+ node.start_node and parent and link(parent, get_id(node.start_node), action)
41
+ lines.append(
42
+ f"\n subgraph sub_flow_{get_id(node)}[{type(node).__name__}]"
43
+ )
44
+ node.start_node and walk(node.start_node)
45
+ for act, nxt in node.successors.items():
46
+ node.start_node and walk(nxt, get_id(node.start_node), act) or (
47
+ parent and link(parent, get_id(nxt), action)
48
+ ) or walk(nxt, None, act)
49
+ lines.append(" end\n")
50
+ else:
51
+ lines.append(f" {(nid := get_id(node))}['{type(node).__name__}']")
52
+ parent and link(parent, nid, action)
53
+ [walk(nxt, nid, act) for act, nxt in node.successors.items()]
54
+
55
+ walk(start)
56
+ return "\n".join(lines)
57
+
58
+
59
+ def flow_to_json(start):
60
+ """Convert a flow to JSON format suitable for D3.js visualization.
61
+
62
+ This function walks through the flow graph and builds a structure with:
63
+ - nodes: All non-Flow nodes with their group memberships
64
+ - links: Connections between nodes within the same group
65
+ - group_links: Connections between different groups (for inter-flow connections)
66
+ - flows: Flow information for group labeling
67
+
68
+ Returns:
69
+ dict: A JSON-serializable dictionary with 'nodes' and 'links' arrays.
70
+ """
71
+ nodes = []
72
+ links = []
73
+ group_links = [] # For connections between groups (Flow to Flow)
74
+ ids = {}
75
+ node_types = {}
76
+ flow_nodes = {} # Keep track of flow nodes
77
+ ctr = 1
78
+ visited = set()
79
+
80
+ def get_id(n):
81
+ nonlocal ctr
82
+ if n not in ids:
83
+ ids[n] = ctr
84
+ node_types[ctr] = type(n).__name__
85
+ if isinstance(n, Flow):
86
+ flow_nodes[ctr] = n # Store flow reference
87
+ ctr += 1
88
+ return ids[n]
89
+
90
+ def walk(node, parent=None, group=None, parent_group=None, action=None):
91
+ """Recursively walk the flow graph to build the visualization data.
92
+
93
+ Args:
94
+ node: Current node being processed
95
+ parent: ID of the parent node that connects to this node
96
+ group: Group (Flow) ID this node belongs to
97
+ parent_group: Group ID of the parent node
98
+ action: Action label on the edge from parent to this node
99
+ """
100
+ node_id = get_id(node)
101
+ if (node_id, action) in visited:
102
+ return
103
+ visited.add((node_id, action))
104
+
105
+ # Add node if not already in nodes list and not a Flow
106
+ if not any(n["id"] == node_id for n in nodes) and not isinstance(node, Flow):
107
+ node_data = {
108
+ "id": node_id,
109
+ "name": node_types[node_id],
110
+ "group": group or 0, # Default group
111
+ }
112
+ nodes.append(node_data)
113
+
114
+ # Add link from parent if exists
115
+ if parent and not isinstance(node, Flow):
116
+ links.append(
117
+ {"source": parent, "target": node_id, "action": action or "default"}
118
+ )
119
+
120
+ # Process different types of nodes
121
+ if isinstance(node, Flow):
122
+ # This is a Flow node - it becomes a group container
123
+ flow_group = node_id # Use flow's ID as group for contained nodes
124
+
125
+ # Add a group-to-group link if this flow has a parent group
126
+ # This creates connections between nested flows
127
+ if parent_group is not None and parent_group != flow_group:
128
+ # Check if this link already exists
129
+ if not any(
130
+ l["source"] == parent_group and l["target"] == flow_group
131
+ for l in group_links
132
+ ):
133
+ group_links.append(
134
+ {
135
+ "source": parent_group,
136
+ "target": flow_group,
137
+ "action": action or "default",
138
+ }
139
+ )
140
+
141
+ if node.start_node:
142
+ # Process the start node of this flow
143
+ walk(node.start_node, parent, flow_group, parent_group, action)
144
+
145
+ # Process successors of the flow's start node
146
+ for next_action, nxt in node.successors.items():
147
+ walk(
148
+ nxt,
149
+ get_id(node.start_node),
150
+ flow_group,
151
+ parent_group,
152
+ next_action,
153
+ )
154
+ else:
155
+ # Process successors for regular nodes
156
+ for next_action, nxt in node.successors.items():
157
+ if isinstance(nxt, Flow):
158
+ # This node connects to a flow - track the group relationship
159
+ flow_group_id = get_id(nxt)
160
+ walk(nxt, node_id, None, group, next_action)
161
+ else:
162
+ # Regular node-to-node connection
163
+ walk(nxt, node_id, group, parent_group, next_action)
164
+
165
+ # Start the traversal
166
+ walk(start)
167
+
168
+ # Post-processing: Generate group links based on node connections between different groups
169
+ # This ensures that when nodes in different groups are connected, we show a group-to-group
170
+ # link rather than a direct node-to-node link
171
+ node_groups = {n["id"]: n["group"] for n in nodes}
172
+ filtered_links = []
173
+
174
+ for link in links:
175
+ source_id = link["source"]
176
+ target_id = link["target"]
177
+ source_group = node_groups.get(source_id, 0)
178
+ target_group = node_groups.get(target_id, 0)
179
+
180
+ # If source and target are in different groups and both groups are valid
181
+ if source_group != target_group and source_group > 0 and target_group > 0:
182
+ # Add to group links if not already there
183
+ # This creates the dashed lines connecting group boxes
184
+ if not any(
185
+ gl["source"] == source_group and gl["target"] == target_group
186
+ for gl in group_links
187
+ ):
188
+ group_links.append(
189
+ {
190
+ "source": source_group,
191
+ "target": target_group,
192
+ "action": link["action"],
193
+ }
194
+ )
195
+ # Skip adding this link to filtered_links - we don't want direct node connections across groups
196
+ else:
197
+ # Keep links within the same group
198
+ filtered_links.append(link)
199
+
200
+ return {
201
+ "nodes": nodes,
202
+ "links": filtered_links, # Use filtered links instead of all links
203
+ "group_links": group_links,
204
+ "flows": {str(k): v.__class__.__name__ for k, v in flow_nodes.items()},
205
+ }
206
+
207
+
208
+ def create_d3_visualization(
209
+ json_data,
210
+ output_dir="./viz",
211
+ filename="flow_viz",
212
+ html_title="PocketFlow Visualization",
213
+ ):
214
+ """Create a D3.js visualization from JSON data.
215
+
216
+ Args:
217
+ json_data: The JSON data for the visualization
218
+ output_dir: Directory to save the files
219
+ filename: Base filename (without extension)
220
+ html_title: Title for the HTML page
221
+
222
+ Returns:
223
+ str: Path to the HTML file
224
+ """
225
+ # Create output directory if it doesn't exist
226
+ os.makedirs(output_dir, exist_ok=True)
227
+
228
+ # Save JSON data to file
229
+ json_path = os.path.join(output_dir, f"{filename}.json")
230
+ with open(json_path, "w") as f:
231
+ json.dump(json_data, f, indent=2)
232
+
233
+ # Create HTML file with D3.js visualization
234
+ html_content = r"""<!DOCTYPE html>
235
+ <html>
236
+ <head>
237
+ <meta charset="utf-8">
238
+ <title>TITLE_PLACEHOLDER</title>
239
+ <script src="https://d3js.org/d3.v7.min.js"></script>
240
+ <style>
241
+ body {
242
+ font-family: Arial, sans-serif;
243
+ margin: 0;
244
+ padding: 0;
245
+ overflow: hidden;
246
+ }
247
+ svg {
248
+ width: 100vw;
249
+ height: 100vh;
250
+ }
251
+ .links path {
252
+ fill: none;
253
+ stroke: #999;
254
+ stroke-opacity: 0.6;
255
+ stroke-width: 1.5px;
256
+ }
257
+ .group-links path {
258
+ fill: none;
259
+ stroke: #333;
260
+ stroke-opacity: 0.8;
261
+ stroke-width: 2px;
262
+ stroke-dasharray: 5,5;
263
+ }
264
+ .nodes circle {
265
+ stroke: #fff;
266
+ stroke-width: 1.5px;
267
+ }
268
+ .node-labels {
269
+ font-size: 14px;
270
+ pointer-events: none;
271
+ }
272
+ .link-labels {
273
+ font-size: 12px;
274
+ fill: #666;
275
+ pointer-events: none;
276
+ }
277
+ .group-link-labels {
278
+ font-size: 13px;
279
+ font-weight: bold;
280
+ fill: #333;
281
+ pointer-events: none;
282
+ }
283
+ .group-container {
284
+ stroke: #333;
285
+ stroke-width: 1.5px;
286
+ stroke-dasharray: 5,5;
287
+ fill: rgba(200, 200, 200, 0.1);
288
+ rx: 10;
289
+ ry: 10;
290
+ }
291
+ .group-label {
292
+ font-size: 16px;
293
+ font-weight: bold;
294
+ pointer-events: none;
295
+ }
296
+ </style>
297
+ </head>
298
+ <body>
299
+ <svg id="graph"></svg>
300
+ <script>
301
+ // Load data from file
302
+ d3.json("FILENAME_PLACEHOLDER.json").then(data => {
303
+ const svg = d3.select("#graph");
304
+ const width = window.innerWidth;
305
+ const height = window.innerHeight;
306
+
307
+ // Define arrow markers for links
308
+ svg.append("defs").append("marker")
309
+ .attr("id", "arrowhead")
310
+ .attr("viewBox", "0 -5 10 10")
311
+ .attr("refX", 25) // Position the arrow away from the target node
312
+ .attr("refY", 0)
313
+ .attr("orient", "auto")
314
+ .attr("markerWidth", 6)
315
+ .attr("markerHeight", 6)
316
+ .attr("xoverflow", "visible")
317
+ .append("path")
318
+ .attr("d", "M 0,-5 L 10,0 L 0,5")
319
+ .attr("fill", "#999");
320
+
321
+ // Define thicker arrow markers for group links
322
+ svg.append("defs").append("marker")
323
+ .attr("id", "group-arrowhead")
324
+ .attr("viewBox", "0 -5 10 10")
325
+ .attr("refX", 3) // Position at the boundary of the group
326
+ .attr("refY", 0)
327
+ .attr("orient", "auto")
328
+ .attr("markerWidth", 8)
329
+ .attr("markerHeight", 8)
330
+ .attr("xoverflow", "visible")
331
+ .append("path")
332
+ .attr("d", "M 0,-5 L 10,0 L 0,5")
333
+ .attr("fill", "#333");
334
+
335
+ // Color scale for node groups
336
+ const color = d3.scaleOrdinal(d3.schemeCategory10);
337
+
338
+ // Process the data to identify groups
339
+ const groups = {};
340
+ data.nodes.forEach(node => {
341
+ if (node.group > 0) {
342
+ if (!groups[node.group]) {
343
+ // Use the flow name instead of generic "Group X"
344
+ const flowName = data.flows && data.flows[node.group] ? data.flows[node.group] : `Flow ${node.group}`;
345
+ groups[node.group] = {
346
+ id: node.group,
347
+ name: flowName,
348
+ nodes: [],
349
+ x: 0,
350
+ y: 0,
351
+ width: 0,
352
+ height: 0
353
+ };
354
+ }
355
+ groups[node.group].nodes.push(node);
356
+ }
357
+ });
358
+
359
+ // Create a force simulation
360
+ const simulation = d3.forceSimulation(data.nodes)
361
+ // Controls the distance between connected nodes - increased for larger visualization
362
+ .force("link", d3.forceLink(data.links).id(d => d.id).distance(200))
363
+ // Controls how nodes repel each other - increased repulsion for more spread
364
+ .force("charge", d3.forceManyBody().strength(-100))
365
+ // Centers the entire graph in the SVG
366
+ .force("center", d3.forceCenter(width / 2, height / 2))
367
+ // Prevents nodes from overlapping - increased radius for larger spacing
368
+ .force("collide", d3.forceCollide().radius(80));
369
+
370
+ // Group forces - create a force to keep nodes in the same group closer together
371
+ // This creates the effect of nodes clustering within their group boxes
372
+ const groupForce = alpha => {
373
+ for (let i = 0; i < data.nodes.length; i++) {
374
+ const node = data.nodes[i];
375
+ if (node.group > 0) {
376
+ const group = groups[node.group];
377
+ if (group && group.nodes.length > 1) {
378
+ // Calculate center of group
379
+ let centerX = 0, centerY = 0;
380
+ group.nodes.forEach(n => {
381
+ centerX += n.x || 0;
382
+ centerY += n.y || 0;
383
+ });
384
+ centerX /= group.nodes.length;
385
+ centerY /= group.nodes.length;
386
+
387
+ // Move nodes toward center
388
+ const k = alpha * 0.3; // Increased from 0.1 to 0.3
389
+ node.vx += (centerX - node.x) * k;
390
+ node.vy += (centerY - node.y) * k;
391
+ }
392
+ }
393
+ }
394
+ };
395
+
396
+ // Additional force to position groups in a more organized layout (like in the image)
397
+ // This arranges the groups horizontally/vertically based on their connections
398
+ const groupLayoutForce = alpha => {
399
+ // Get group centers
400
+ const groupCenters = Object.values(groups).map(g => {
401
+ return { id: g.id, cx: 0, cy: 0 };
402
+ });
403
+
404
+ // Calculate current center positions
405
+ Object.values(groups).forEach(g => {
406
+ if (g.nodes.length > 0) {
407
+ let cx = 0, cy = 0;
408
+ g.nodes.forEach(n => {
409
+ cx += n.x || 0;
410
+ cy += n.y || 0;
411
+ });
412
+
413
+ const groupCenter = groupCenters.find(gc => gc.id === g.id);
414
+ if (groupCenter) {
415
+ groupCenter.cx = cx / g.nodes.length;
416
+ groupCenter.cy = cy / g.nodes.length;
417
+ }
418
+ }
419
+ });
420
+
421
+ // Apply forces to position groups
422
+ const k = alpha * 0.05;
423
+
424
+ // Try to position groups in a more structured way
425
+ // Adjust these values to change the overall layout
426
+ for (let i = 0; i < data.group_links.length; i++) {
427
+ const link = data.group_links[i];
428
+ const source = groupCenters.find(g => g.id === link.source);
429
+ const target = groupCenters.find(g => g.id === link.target);
430
+
431
+ if (source && target) {
432
+ // Add a horizontal force to align groups
433
+ const desiredDx = 500; // Desired horizontal distance between linked groups - increased for larger layout
434
+ const dx = target.cx - source.cx;
435
+ const diff = desiredDx - Math.abs(dx);
436
+
437
+ // Apply forces to group nodes
438
+ groups[source.id].nodes.forEach(n => {
439
+ if (dx > 0) {
440
+ n.vx -= diff * k;
441
+ } else {
442
+ n.vx += diff * k;
443
+ }
444
+ });
445
+
446
+ groups[target.id].nodes.forEach(n => {
447
+ if (dx > 0) {
448
+ n.vx += diff * k;
449
+ } else {
450
+ n.vx -= diff * k;
451
+ }
452
+ });
453
+ }
454
+ }
455
+ };
456
+
457
+ simulation.force("group", groupForce);
458
+ simulation.force("groupLayout", groupLayoutForce);
459
+
460
+ // Create links with arrow paths instead of lines
461
+ const link = svg.append("g")
462
+ .attr("class", "links")
463
+ .selectAll("path")
464
+ .data(data.links)
465
+ .enter()
466
+ .append("path")
467
+ .attr("stroke-width", 2)
468
+ .attr("stroke", "#999")
469
+ .attr("marker-end", "url(#arrowhead)"); // Add the arrowhead marker
470
+
471
+ // Create group containers (drawn before nodes)
472
+ const groupContainers = svg.append("g")
473
+ .attr("class", "groups")
474
+ .selectAll("rect")
475
+ .data(Object.values(groups))
476
+ .enter()
477
+ .append("rect")
478
+ .attr("class", "group-container")
479
+ .attr("fill", d => d3.color(color(d.id)).copy({opacity: 0.2}));
480
+
481
+ // Create group links between flows
482
+ const groupLink = svg.append("g")
483
+ .attr("class", "group-links")
484
+ .selectAll("path")
485
+ .data(data.group_links || [])
486
+ .enter()
487
+ .append("path")
488
+ .attr("stroke-width", 2)
489
+ .attr("stroke", "#333")
490
+ .attr("marker-end", "url(#group-arrowhead)");
491
+
492
+ // Create group link labels
493
+ const groupLinkLabel = svg.append("g")
494
+ .attr("class", "group-link-labels")
495
+ .selectAll("text")
496
+ .data(data.group_links || [])
497
+ .enter()
498
+ .append("text")
499
+ .text(d => d.action)
500
+ .attr("font-size", "11px")
501
+ .attr("font-weight", "bold")
502
+ .attr("fill", "#333");
503
+
504
+ // Create group labels
505
+ const groupLabels = svg.append("g")
506
+ .attr("class", "group-labels")
507
+ .selectAll("text")
508
+ .data(Object.values(groups))
509
+ .enter()
510
+ .append("text")
511
+ .attr("class", "group-label")
512
+ .text(d => d.name) // Now using the proper flow name
513
+ .attr("fill", d => d3.color(color(d.id)).darker());
514
+
515
+ // Create link labels
516
+ const linkLabel = svg.append("g")
517
+ .attr("class", "link-labels")
518
+ .selectAll("text")
519
+ .data(data.links)
520
+ .enter()
521
+ .append("text")
522
+ .text(d => d.action)
523
+ .attr("font-size", "10px")
524
+ .attr("fill", "#666");
525
+
526
+ // Create nodes - increased size for larger visualization
527
+ const node = svg.append("g")
528
+ .attr("class", "nodes")
529
+ .selectAll("circle")
530
+ .data(data.nodes)
531
+ .enter()
532
+ .append("circle")
533
+ .attr("r", 25)
534
+ .attr("fill", d => color(d.group))
535
+ .call(d3.drag()
536
+ .on("start", dragstarted)
537
+ .on("drag", dragged)
538
+ .on("end", dragended));
539
+
540
+ // Create node labels - increased offset for larger nodes
541
+ const nodeLabel = svg.append("g")
542
+ .attr("class", "node-labels")
543
+ .selectAll("text")
544
+ .data(data.nodes)
545
+ .enter()
546
+ .append("text")
547
+ .text(d => d.name)
548
+ .attr("text-anchor", "middle")
549
+ .attr("dy", 40);
550
+
551
+ // Add tooltip on hover
552
+ node.append("title")
553
+ .text(d => d.name);
554
+
555
+ // Update positions on each tick
556
+ simulation.on("tick", () => {
557
+ // Update links with curved paths for bidirectional connections
558
+ link.attr("d", d => {
559
+ // Handle self-referencing links with a water-drop shape
560
+ if (d.source === d.target) {
561
+ const nodeX = d.source.x;
562
+ const nodeY = d.source.y;
563
+ const offsetX = 40;
564
+ const offsetY = 10;
565
+ const controlOffset = 50;
566
+
567
+ // Create a water-drop shaped path
568
+ return `M ${nodeX},${nodeY - 5}
569
+ C ${nodeX + controlOffset},${nodeY - 30}
570
+ ${nodeX + offsetX},${nodeY + offsetY}
571
+ ${nodeX},${nodeY}`;
572
+ }
573
+
574
+ // Check if there's a reverse connection
575
+ const isReverse = data.links.some(l =>
576
+ l.source === d.target && l.target === d.source
577
+ );
578
+
579
+ // If it's part of a bidirectional connection, curve the path
580
+ if (isReverse) {
581
+ const dx = d.target.x - d.source.x;
582
+ const dy = d.target.y - d.source.y;
583
+ const dr = Math.sqrt(dx * dx + dy * dy) * 0.9;
584
+
585
+ return `M${d.source.x},${d.source.y}A${dr},${dr} 0 0,1 ${d.target.x},${d.target.y}`;
586
+ }
587
+
588
+ // For unidirectional connections, use straight lines
589
+ return `M${d.source.x},${d.source.y} L${d.target.x},${d.target.y}`;
590
+ });
591
+
592
+ // Update nodes
593
+ node
594
+ .attr("cx", d => d.x)
595
+ .attr("cy", d => d.y);
596
+
597
+ // Update node labels
598
+ nodeLabel
599
+ .attr("x", d => d.x)
600
+ .attr("y", d => d.y);
601
+
602
+ // Position link labels with offset for bidirectional connections
603
+ linkLabel.attr("x", d => {
604
+ // Handle self-referencing links
605
+ if (d.source === d.target) {
606
+ return d.source.x + 30;
607
+ }
608
+
609
+ // Check if there's a reverse connection
610
+ const reverseLink = data.links.find(l =>
611
+ l.source === d.target && l.target === d.source
612
+ );
613
+
614
+ // If it's part of a bidirectional connection, offset the label
615
+ if (reverseLink) {
616
+ const dx = d.target.x - d.source.x;
617
+ const dy = d.target.y - d.source.y;
618
+ // Calculate perpendicular offset
619
+ const length = Math.sqrt(dx * dx + dy * dy);
620
+ const offsetX = -dy / length * 10; // Perpendicular offset
621
+
622
+ return (d.source.x + d.target.x) / 2 + offsetX;
623
+ }
624
+
625
+ // For unidirectional connections, use midpoint
626
+ return (d.source.x + d.target.x) / 2;
627
+ })
628
+ .attr("y", d => {
629
+ // Handle self-referencing links
630
+ if (d.source === d.target) {
631
+ return d.source.y;
632
+ }
633
+
634
+ // Check if there's a reverse connection
635
+ const reverseLink = data.links.find(l =>
636
+ l.source === d.target && l.target === d.source
637
+ );
638
+
639
+ // If it's part of a bidirectional connection, offset the label
640
+ if (reverseLink) {
641
+ const dx = d.target.x - d.source.x;
642
+ const dy = d.target.y - d.source.y;
643
+ // Calculate perpendicular offset
644
+ const length = Math.sqrt(dx * dx + dy * dy);
645
+ const offsetY = dx / length * 10; // Perpendicular offset
646
+
647
+ return (d.source.y + d.target.y) / 2 + offsetY;
648
+ }
649
+
650
+ // For unidirectional connections, use midpoint
651
+ return (d.source.y + d.target.y) / 2;
652
+ });
653
+
654
+ // Update group containers
655
+ groupContainers.each(function(d) {
656
+ // If there are nodes in this group
657
+ if (d.nodes.length > 0) {
658
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
659
+
660
+ // Find the bounding box for all nodes in the group - increased for larger nodes
661
+ d.nodes.forEach(n => {
662
+ minX = Math.min(minX, n.x - 40);
663
+ minY = Math.min(minY, n.y - 40);
664
+ maxX = Math.max(maxX, n.x + 40);
665
+ maxY = Math.max(maxY, n.y + 60); // Extra space for labels
666
+ });
667
+
668
+ // Add padding - increased for larger visualization
669
+ const padding = 30;
670
+ minX -= padding;
671
+ minY -= padding;
672
+ maxX += padding;
673
+ maxY += padding;
674
+
675
+ // Save group dimensions
676
+ d.x = minX;
677
+ d.y = minY;
678
+ d.width = maxX - minX;
679
+ d.height = maxY - minY;
680
+ d.centerX = minX + d.width / 2;
681
+ d.centerY = minY + d.height / 2;
682
+
683
+ // Set position and size of the group container
684
+ d3.select(this)
685
+ .attr("x", minX)
686
+ .attr("y", minY)
687
+ .attr("width", d.width)
688
+ .attr("height", d.height);
689
+
690
+ // Update group label position (top-left of group)
691
+ groupLabels.filter(g => g.id === d.id)
692
+ .attr("x", minX + 10)
693
+ .attr("y", minY + 20);
694
+ }
695
+ });
696
+
697
+ // Update group links between flows
698
+ groupLink.attr("d", d => {
699
+ const sourceGroup = groups[d.source];
700
+ const targetGroup = groups[d.target];
701
+
702
+ if (!sourceGroup || !targetGroup) return "";
703
+
704
+ // Find intersection points with group boundaries
705
+ // This ensures links connect to the group's border rather than its center
706
+
707
+ // Calculate centers of groups
708
+ const sx = sourceGroup.centerX;
709
+ const sy = sourceGroup.centerY;
710
+ const tx = targetGroup.centerX;
711
+ const ty = targetGroup.centerY;
712
+
713
+ // Calculate angle between centers - used to find intersection points
714
+ const angle = Math.atan2(ty - sy, tx - sx);
715
+
716
+ // Calculate intersection points with source group borders
717
+ // We cast a ray from center in the direction of the target
718
+ let sourceX, sourceY;
719
+ const cosA = Math.cos(angle);
720
+ const sinA = Math.sin(angle);
721
+
722
+ // Check intersection with horizontal borders (top and bottom)
723
+ const ts_top = (sourceGroup.y - sy) / sinA;
724
+ const ts_bottom = (sourceGroup.y + sourceGroup.height - sy) / sinA;
725
+
726
+ // Check intersection with vertical borders (left and right)
727
+ const ts_left = (sourceGroup.x - sx) / cosA;
728
+ const ts_right = (sourceGroup.x + sourceGroup.width - sx) / cosA;
729
+
730
+ // Use the closest positive intersection (first hit with the boundary)
731
+ let t_source = Infinity;
732
+ if (ts_top > 0) t_source = Math.min(t_source, ts_top);
733
+ if (ts_bottom > 0) t_source = Math.min(t_source, ts_bottom);
734
+ if (ts_left > 0) t_source = Math.min(t_source, ts_left);
735
+ if (ts_right > 0) t_source = Math.min(t_source, ts_right);
736
+
737
+ // Target group: Find intersection in the opposite direction
738
+ // We cast a ray from target center toward the source
739
+ let targetX, targetY;
740
+ const oppositeAngle = angle + Math.PI;
741
+ const cosOpp = Math.cos(oppositeAngle);
742
+ const sinOpp = Math.sin(oppositeAngle);
743
+
744
+ // Check intersections for target group
745
+ const tt_top = (targetGroup.y - ty) / sinOpp;
746
+ const tt_bottom = (targetGroup.y + targetGroup.height - ty) / sinOpp;
747
+ const tt_left = (targetGroup.x - tx) / cosOpp;
748
+ const tt_right = (targetGroup.x + targetGroup.width - tx) / cosOpp;
749
+
750
+ // Use the closest positive intersection
751
+ let t_target = Infinity;
752
+ if (tt_top > 0) t_target = Math.min(t_target, tt_top);
753
+ if (tt_bottom > 0) t_target = Math.min(t_target, tt_bottom);
754
+ if (tt_left > 0) t_target = Math.min(t_target, tt_left);
755
+ if (tt_right > 0) t_target = Math.min(t_target, tt_right);
756
+
757
+ // Calculate actual border points using parametric equation:
758
+ // point = center + t * direction
759
+ if (t_source !== Infinity) {
760
+ sourceX = sx + cosA * t_source;
761
+ sourceY = sy + sinA * t_source;
762
+ } else {
763
+ sourceX = sx;
764
+ sourceY = sy;
765
+ }
766
+
767
+ if (t_target !== Infinity) {
768
+ targetX = tx + cosOpp * t_target;
769
+ targetY = ty + sinOpp * t_target;
770
+ } else {
771
+ targetX = tx;
772
+ targetY = ty;
773
+ }
774
+
775
+ // Create a straight line between the border points
776
+ return `M${sourceX},${sourceY} L${targetX},${targetY}`;
777
+ });
778
+
779
+ // Update group link labels
780
+ groupLinkLabel.attr("x", d => {
781
+ const sourceGroup = groups[d.source];
782
+ const targetGroup = groups[d.target];
783
+ if (!sourceGroup || !targetGroup) return 0;
784
+ return (sourceGroup.centerX + targetGroup.centerX) / 2;
785
+ })
786
+ .attr("y", d => {
787
+ const sourceGroup = groups[d.source];
788
+ const targetGroup = groups[d.target];
789
+ if (!sourceGroup || !targetGroup) return 0;
790
+ return (sourceGroup.centerY + targetGroup.centerY) / 2 - 10;
791
+ });
792
+ });
793
+
794
+ // Drag functions
795
+ function dragstarted(event, d) {
796
+ if (!event.active) simulation.alphaTarget(0.3).restart();
797
+ d.fx = d.x;
798
+ d.fy = d.y;
799
+ }
800
+
801
+ function dragged(event, d) {
802
+ d.fx = event.x;
803
+ d.fy = event.y;
804
+ }
805
+
806
+ function dragended(event, d) {
807
+ if (!event.active) simulation.alphaTarget(0);
808
+ d.fx = null;
809
+ d.fy = null;
810
+ }
811
+ });
812
+ </script>
813
+ </body>
814
+ </html>
815
+ """
816
+
817
+ # Replace the placeholders with the actual values
818
+ html_content = html_content.replace("FILENAME_PLACEHOLDER", filename)
819
+ html_content = html_content.replace("TITLE_PLACEHOLDER", html_title)
820
+
821
+ # Write HTML to file
822
+ html_path = os.path.join(output_dir, f"{filename}.html")
823
+ with open(html_path, "w") as f:
824
+ f.write(html_content)
825
+
826
+ print(f"Visualization created at {html_path}")
827
+ return html_path
828
+
829
+
830
+ def find_free_port():
831
+ """Find a free port on localhost."""
832
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
833
+ s.bind(("", 0))
834
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
835
+ return s.getsockname()[1]
836
+
837
+
838
+ def start_http_server(directory, port=None):
839
+ """Start an HTTP server in the given directory.
840
+
841
+ Args:
842
+ directory: Directory to serve files from
843
+ port: Port to use (finds a free port if None)
844
+
845
+ Returns:
846
+ tuple: (server_thread, port)
847
+ """
848
+ if port is None:
849
+ port = find_free_port()
850
+
851
+ # Get the absolute path of the directory
852
+ directory = str(Path(directory).absolute())
853
+
854
+ # Change to the directory to serve files
855
+ os.chdir(directory)
856
+
857
+ # Create HTTP server
858
+ handler = http.server.SimpleHTTPRequestHandler
859
+ httpd = socketserver.TCPServer(("", port), handler)
860
+
861
+ # Start server in a separate thread
862
+ server_thread = threading.Thread(target=httpd.serve_forever)
863
+ server_thread.daemon = (
864
+ True # This makes the thread exit when the main program exits
865
+ )
866
+ server_thread.start()
867
+
868
+ print(f"Server started at http://localhost:{port}")
869
+ return server_thread, port
870
+
871
+
872
+ def serve_and_open_visualization(html_path, auto_open=True):
873
+ """Serve the HTML file and open it in a browser.
874
+
875
+ Args:
876
+ html_path: Path to the HTML file
877
+ auto_open: Whether to automatically open the browser
878
+
879
+ Returns:
880
+ tuple: (server_thread, url)
881
+ """
882
+ # Get the directory and filename
883
+ directory = os.path.dirname(os.path.abspath(html_path))
884
+ filename = os.path.basename(html_path)
885
+
886
+ # Start the server
887
+ server_thread, port = start_http_server(directory)
888
+
889
+ # Build the URL
890
+ url = f"http://localhost:{port}/{filename}"
891
+
892
+ # Open the URL in a browser
893
+ if auto_open:
894
+ print(f"Opening {url} in your browser...")
895
+ webbrowser.open(url)
896
+ else:
897
+ print(f"Visualization available at {url}")
898
+
899
+ return server_thread, url
900
+
901
+
902
+ def visualize_flow(
903
+ flow: Flow,
904
+ flow_name: str,
905
+ serve: bool = True,
906
+ auto_open: bool = True,
907
+ output_dir: str = "./viz",
908
+ html_title: Optional[str] = None,
909
+ ) -> Union[str, Tuple[str, Any, str]]:
910
+ """Helper function to visualize a flow with both mermaid and D3.js
911
+
912
+ Args:
913
+ flow: Flow object to visualize
914
+ flow_name: Name of the flow (used for filename and display)
915
+ serve: Whether to start a server for the visualization
916
+ auto_open: Whether to automatically open in browser
917
+ output_dir: Directory to save visualization files
918
+ html_title: Custom title for the HTML page (defaults to flow_name if None)
919
+
920
+ Returns:
921
+ str or tuple: Path to HTML file, or (path, server_thread, url) if serve=True
922
+ """
923
+ print(f"\n--- {flow_name} Mermaid Diagram ---")
924
+ print(build_mermaid(start=flow))
925
+
926
+ print(f"\n--- {flow_name} D3.js Visualization ---")
927
+ json_data = flow_to_json(flow)
928
+
929
+ # Create the visualization
930
+ output_filename = f"{flow_name.lower().replace(' ', '_')}"
931
+
932
+ # Use flow_name as the HTML title if not specified
933
+ if html_title is None:
934
+ html_title = f"PocketFlow: {flow_name}"
935
+
936
+ html_path = create_d3_visualization(
937
+ json_data,
938
+ output_dir=output_dir,
939
+ filename=output_filename,
940
+ html_title=html_title,
941
+ )
942
+
943
+ # Serve and open if requested
944
+ if serve:
945
+ server_thread, url = serve_and_open_visualization(html_path, auto_open)
946
+ return html_path, server_thread, url
947
+
948
+ return html_path
949
+
950
+
951
+ def load_flow_from_module(module_path: str, flow_variable: str) -> Flow:
952
+ """Dynamically load a flow from a module.
953
+
954
+ Args:
955
+ module_path: Path to the module (e.g., 'my_package.my_module')
956
+ flow_variable: Name of the flow variable in the module
957
+
958
+ Returns:
959
+ Flow: The loaded flow object
960
+ """
961
+ try:
962
+ module = importlib.import_module(module_path)
963
+ return getattr(module, flow_variable)
964
+ except (ImportError, AttributeError) as e:
965
+ print(f"Error loading flow: {e}")
966
+ sys.exit(1)
967
+
968
+
969
+ # Example usage
970
+ if __name__ == "__main__":
971
+ import argparse
972
+
973
+ parser = argparse.ArgumentParser(description="Visualize a PocketFlow flow")
974
+ parser.add_argument(
975
+ "--module", default="async_loop_flow", help="Module containing the flow"
976
+ )
977
+ parser.add_argument(
978
+ "--flow", default="order_pipeline", help="Flow variable name in the module"
979
+ )
980
+ parser.add_argument(
981
+ "--name", default="Flow Visualization", help="Name for the visualization"
982
+ )
983
+ parser.add_argument(
984
+ "--output-dir", default="./viz", help="Directory to save visualization files"
985
+ )
986
+ parser.add_argument("--no-serve", action="store_true", help="Don't start a server")
987
+ parser.add_argument(
988
+ "--no-open", action="store_true", help="Don't open browser automatically"
989
+ )
990
+ parser.add_argument("--title", help="Custom HTML title")
991
+
992
+ args = parser.parse_args()
993
+
994
+ # Load flow from the specified module
995
+ flow_obj = load_flow_from_module(args.module, args.flow)
996
+
997
+ # Visualize the flow
998
+ visualize_flow(
999
+ flow=flow_obj,
1000
+ flow_name=args.name,
1001
+ serve=not args.no_serve,
1002
+ auto_open=not args.no_open,
1003
+ output_dir=args.output_dir,
1004
+ html_title=args.title,
1005
+ )
1006
+
1007
+ # Keep server running if serving
1008
+ if not args.no_serve:
1009
+ try:
1010
+ print("\nServer is running. Press Ctrl+C to stop...")
1011
+ while True:
1012
+ time.sleep(1)
1013
+ except KeyboardInterrupt:
1014
+ print("\nShutting down...")