Enhance PocketFlow Visualization with D3.js Integration
- Added interactive D3.js visualization for PocketFlow workflows, allowing users to drag nodes and view group boundaries. - Implemented functions to convert flow graphs to JSON format suitable for D3.js. - Created convenience functions for visualizing flows with both Mermaid diagrams and D3.js. - Updated documentation to reflect new visualization features and usage examples.
This commit is contained in:
parent
f8d39a8de9
commit
2362abff27
|
|
@ -1,4 +1,9 @@
|
|||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
---
|
||||
description: Guidelines for using PocketFlow, Utility Function, Viz and Debug
|
||||
globs:
|
||||
alwaysApply: false
|
||||
|
|
@ -87,7 +92,146 @@ graph LR
|
|||
end
|
||||
```
|
||||
|
||||
## 2. Call Stack Debugging
|
||||
## 2. Interactive D3.js Visualization
|
||||
|
||||
For more complex flows, a static diagram may not be sufficient. We provide a D3.js-based interactive visualization that allows for dragging nodes, showing group boundaries for flows, and connecting flows at their boundaries.
|
||||
|
||||
### Converting Flow to JSON
|
||||
|
||||
First, we convert the PocketFlow graph to JSON format suitable for D3.js:
|
||||
|
||||
```python
|
||||
def flow_to_json(start):
|
||||
"""Convert a flow to JSON format suitable for D3.js visualization.
|
||||
|
||||
This function walks through the flow graph and builds a structure with:
|
||||
- nodes: All non-Flow nodes with their group memberships
|
||||
- links: Connections between nodes within the same group
|
||||
- group_links: Connections between different groups (for inter-flow connections)
|
||||
- flows: Flow information for group labeling
|
||||
"""
|
||||
nodes = []
|
||||
links = []
|
||||
group_links = [] # For connections between groups (Flow to Flow)
|
||||
ids = {}
|
||||
node_types = {}
|
||||
flow_nodes = {} # Keep track of flow nodes
|
||||
ctr = 1
|
||||
|
||||
# Implementation details...
|
||||
|
||||
# Post-processing: Generate group links based on node connections between different groups
|
||||
node_groups = {n["id"]: n["group"] for n in nodes}
|
||||
filtered_links = []
|
||||
|
||||
# Filter out direct node-to-node connections between different groups
|
||||
for link in links:
|
||||
source_id = link["source"]
|
||||
target_id = link["target"]
|
||||
source_group = node_groups.get(source_id, 0)
|
||||
target_group = node_groups.get(target_id, 0)
|
||||
|
||||
if source_group != target_group and source_group > 0 and target_group > 0:
|
||||
# Create group-to-group links instead of node-to-node links across groups
|
||||
if not any(gl["source"] == source_group and gl["target"] == target_group
|
||||
for gl in group_links):
|
||||
group_links.append({
|
||||
"source": source_group,
|
||||
"target": target_group,
|
||||
"action": link["action"]
|
||||
})
|
||||
# Skip adding this link to filtered_links - we don't want direct node connections across groups
|
||||
else:
|
||||
# Keep links within the same group
|
||||
filtered_links.append(link)
|
||||
|
||||
return {
|
||||
"nodes": nodes,
|
||||
"links": filtered_links,
|
||||
"group_links": group_links,
|
||||
"flows": {str(k): v.__class__.__name__ for k, v in flow_nodes.items()},
|
||||
}
|
||||
```
|
||||
|
||||
### Creating the Visualization
|
||||
|
||||
Then, we generate an HTML file with D3.js visualization:
|
||||
|
||||
```python
|
||||
def create_d3_visualization(json_data, output_dir="./viz", filename="flow_viz"):
|
||||
"""Create a D3.js visualization from JSON data."""
|
||||
# Create output directory
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
# Save JSON data to file
|
||||
json_path = os.path.join(output_dir, f"{filename}.json")
|
||||
with open(json_path, "w") as f:
|
||||
json.dump(json_data, f, indent=2)
|
||||
|
||||
# Generate HTML with D3.js visualization
|
||||
# ...HTML template with D3.js code...
|
||||
|
||||
# Key features implemented in the visualization:
|
||||
# 1. Nodes can be dragged to reorganize the layout
|
||||
# 2. Flows are shown as dashed rectangles (groups)
|
||||
# 3. Inter-group connections shown as dashed lines connecting at group boundaries
|
||||
# 4. Edge labels show transition actions
|
||||
|
||||
# Write HTML to file
|
||||
html_path = os.path.join(output_dir, f"{filename}.html")
|
||||
with open(html_path, "w") as f:
|
||||
f.write(html_content)
|
||||
|
||||
print(f"Visualization created at {html_path}")
|
||||
return html_path
|
||||
```
|
||||
|
||||
### Convenience Function
|
||||
|
||||
A convenience function to visualize flows:
|
||||
|
||||
```python
|
||||
def visualize_flow(flow, flow_name):
|
||||
"""Helper function to visualize a flow with both mermaid and D3.js"""
|
||||
print(f"\n--- {flow_name} Mermaid Diagram ---")
|
||||
print(build_mermaid(start=flow))
|
||||
|
||||
print(f"\n--- {flow_name} D3.js Visualization ---")
|
||||
json_data = flow_to_json(flow)
|
||||
create_d3_visualization(
|
||||
json_data, filename=f"{flow_name.lower().replace(' ', '_')}"
|
||||
)
|
||||
```
|
||||
|
||||
### Usage Example
|
||||
|
||||
```python
|
||||
from visualize import visualize_flow
|
||||
|
||||
# Create a complex flow with nested subflows
|
||||
# ...flow definition...
|
||||
|
||||
# Generate visualization
|
||||
visualize_flow(data_science_flow, "Data Science Flow")
|
||||
```
|
||||
|
||||
### Customizing the Visualization
|
||||
|
||||
You can customize the visualization by adjusting the force simulation parameters:
|
||||
|
||||
```javascript
|
||||
const simulation = d3.forceSimulation(data.nodes)
|
||||
// Controls the distance between connected nodes
|
||||
.force("link", d3.forceLink(data.links).id(d => d.id).distance(100))
|
||||
// Controls how nodes repel each other - lower values bring nodes closer
|
||||
.force("charge", d3.forceManyBody().strength(-30))
|
||||
// Centers the entire graph in the SVG
|
||||
.force("center", d3.forceCenter(width / 2, height / 2))
|
||||
// Prevents nodes from overlapping - acts like a minimum distance
|
||||
.force("collide", d3.forceCollide().radius(50));
|
||||
```
|
||||
|
||||
## 3. Call Stack Debugging
|
||||
|
||||
It would be useful to print the Node call stacks for debugging. This can be achieved by inspecting the runtime call stack:
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,131 @@
|
|||
# PocketFlow Visualization
|
||||
|
||||
This directory contains tools for visualizing PocketFlow workflow graphs using interactive D3.js visualizations.
|
||||
|
||||
## Overview
|
||||
|
||||
The visualization tools allow you to:
|
||||
|
||||
1. View PocketFlow nodes and flows as an interactive graph
|
||||
2. See how different flows connect to each other
|
||||
3. Understand the relationships between nodes within flows
|
||||
|
||||
## Features
|
||||
|
||||
- **Interactive Graph**: Nodes can be dragged to reorganize the layout
|
||||
- **Group Visualization**: Flows are displayed as groups with dashed borders
|
||||
- **Inter-Group Links**: Connections between flows are shown as dashed lines connecting group boundaries
|
||||
- **Action Labels**: Edge labels show the actions that trigger transitions between nodes
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.6 or higher
|
||||
- Modern web browser (Chrome, Firefox, Edge) for viewing the visualizations
|
||||
|
||||
## Usage
|
||||
|
||||
### 1. Basic Visualization
|
||||
|
||||
To visualize a PocketFlow graph, you can use the `visualize_flow` function in `visualize.py`:
|
||||
|
||||
```python
|
||||
from visualize import visualize_flow
|
||||
from your_flow_module import your_flow
|
||||
|
||||
# Generate visualization
|
||||
visualize_flow(your_flow, "Your Flow Name")
|
||||
```
|
||||
|
||||
This will:
|
||||
1. Print a Mermaid diagram to the console
|
||||
2. Generate a D3.js visualization in the `./viz` directory
|
||||
|
||||
### 2. Running the Example
|
||||
|
||||
The included example shows an order processing pipeline with payment, inventory, and shipping flows:
|
||||
|
||||
```bash
|
||||
# Navigate to the directory
|
||||
cd cookbook/pocketflow-minimal-flow2flow
|
||||
|
||||
# Run the visualization script
|
||||
python visualize.py
|
||||
```
|
||||
|
||||
This will generate visualization files in the `./viz` directory.
|
||||
|
||||
### 3. Viewing the Visualization
|
||||
|
||||
After running the script:
|
||||
|
||||
1. Host with
|
||||
```
|
||||
cd ./viz/
|
||||
```
|
||||
|
||||
2. Interact with the visualization:
|
||||
- **Drag nodes** to reorganize
|
||||
- **Hover over nodes** to see node names
|
||||
- **Observe connections** between nodes and flows
|
||||
|
||||
## Customizing the Visualization
|
||||
|
||||
### Adjusting Layout Parameters
|
||||
|
||||
You can adjust the force simulation parameters in `visualize.py` to change how nodes and groups are positioned:
|
||||
|
||||
```javascript
|
||||
// Create a force simulation
|
||||
const simulation = d3.forceSimulation(data.nodes)
|
||||
// Controls the distance between connected nodes
|
||||
.force("link", d3.forceLink(data.links).id(d => d.id).distance(100))
|
||||
// Controls how nodes repel each other - lower values bring nodes closer
|
||||
.force("charge", d3.forceManyBody().strength(-30))
|
||||
// Centers the entire graph in the SVG
|
||||
.force("center", d3.forceCenter(width / 2, height / 2))
|
||||
// Prevents nodes from overlapping - acts like a minimum distance
|
||||
.force("collide", d3.forceCollide().radius(50));
|
||||
```
|
||||
|
||||
### Styling
|
||||
|
||||
Adjust the CSS styles in the HTML template inside `create_d3_visualization` function to change colors, shapes, and other visual properties.
|
||||
|
||||
## How It Works
|
||||
|
||||
The visualization process consists of three main steps:
|
||||
|
||||
1. **Flow to JSON Conversion**: The `flow_to_json` function traverses the PocketFlow graph and converts it to a structure with nodes, links, and group information.
|
||||
|
||||
2. **D3.js Visualization**: The JSON data is used to create an interactive D3.js visualization with:
|
||||
- Nodes represented as circles
|
||||
- Flows represented as dashed rectangles containing nodes
|
||||
- Links showing connections within and between flows
|
||||
|
||||
3. **Group Boundary Connections**: The visualization calculates intersection points with group boundaries to ensure inter-group links connect at the borders rather than centers.
|
||||
|
||||
## Extending the Visualization
|
||||
|
||||
You can extend the visualization tools by:
|
||||
|
||||
1. Adding new node shapes
|
||||
2. Implementing additional layout algorithms
|
||||
3. Adding tooltips with more detailed information
|
||||
4. Creating animation for flow execution
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If you encounter any issues:
|
||||
|
||||
- Make sure your flow objects are properly constructed with nodes connected correctly
|
||||
- Check the browser console for any JavaScript errors
|
||||
- Verify that the generated JSON data structure matches what you expect
|
||||
|
||||
## Example Output
|
||||
|
||||
The visualization displays:
|
||||
- Payment processing flow nodes
|
||||
- Inventory management flow nodes
|
||||
- Shipping flow nodes
|
||||
- Group boundaries around each flow
|
||||
- Connections between flows (Payment → Inventory → Shipping)
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
from pocketflow import AsyncNode, AsyncFlow
|
||||
import asyncio
|
||||
|
||||
|
||||
# Define Payment Nodes
|
||||
class ValidatePayment(AsyncNode):
|
||||
async def exec_async(self, prep_res):
|
||||
print("1.1.Validating payment...")
|
||||
return "Payment validated successfully"
|
||||
|
||||
async def post_async(self, shared, prep_res, exec_res):
|
||||
shared["payment_status"] = exec_res
|
||||
return "default"
|
||||
|
||||
|
||||
class ProcessPayment(AsyncNode):
|
||||
async def exec_async(self, prep_res):
|
||||
print("1.2.Processing payment...")
|
||||
return "Payment processed successfully"
|
||||
|
||||
async def post_async(self, shared, prep_res, exec_res):
|
||||
shared["payment_result"] = exec_res
|
||||
return "default"
|
||||
|
||||
|
||||
class PaymentConfirmation(AsyncNode):
|
||||
async def exec_async(self, prep_res):
|
||||
print("1.3.Confirming payment...")
|
||||
return "Payment confirmed"
|
||||
|
||||
async def post_async(self, shared, prep_res, exec_res):
|
||||
shared["payment_confirmation"] = exec_res
|
||||
return "default"
|
||||
|
||||
|
||||
# Define Inventory Nodes
|
||||
class CheckStock(AsyncNode):
|
||||
async def exec_async(self, prep_res):
|
||||
print("2.1.Checking inventory stock...")
|
||||
return "Stock available"
|
||||
|
||||
async def post_async(self, shared, prep_res, exec_res):
|
||||
shared["stock_status"] = exec_res
|
||||
return "default"
|
||||
|
||||
|
||||
class ReserveItems(AsyncNode):
|
||||
async def exec_async(self, prep_res):
|
||||
print("2.2.Reserving items...")
|
||||
return "Items reserved"
|
||||
|
||||
async def post_async(self, shared, prep_res, exec_res):
|
||||
shared["reservation_status"] = exec_res
|
||||
return "default"
|
||||
|
||||
|
||||
class UpdateInventory(AsyncNode):
|
||||
async def exec_async(self, prep_res):
|
||||
print("2.3. Updating inventory...")
|
||||
return "Inventory updated"
|
||||
|
||||
async def post_async(self, shared, prep_res, exec_res):
|
||||
shared["inventory_update"] = exec_res
|
||||
return "default"
|
||||
|
||||
|
||||
# Define Shipping Nodes
|
||||
class CreateLabel(AsyncNode):
|
||||
async def exec_async(self, prep_res):
|
||||
print("3.1 Creating shipping label...")
|
||||
return "Shipping label created"
|
||||
|
||||
async def post_async(self, shared, prep_res, exec_res):
|
||||
shared["shipping_label"] = exec_res
|
||||
return "default"
|
||||
|
||||
|
||||
class AssignCarrier(AsyncNode):
|
||||
async def exec_async(self, prep_res):
|
||||
print("3.2 Assigning carrier...")
|
||||
return "Carrier assigned"
|
||||
|
||||
async def post_async(self, shared, prep_res, exec_res):
|
||||
shared["carrier"] = exec_res
|
||||
return "default"
|
||||
|
||||
|
||||
class SchedulePickup(AsyncNode):
|
||||
async def exec_async(self, prep_res):
|
||||
print("3.3 Scheduling pickup...")
|
||||
return "Pickup scheduled"
|
||||
|
||||
async def post_async(self, shared, prep_res, exec_res):
|
||||
shared["pickup_status"] = exec_res
|
||||
return "default"
|
||||
|
||||
|
||||
# Create node instances
|
||||
validate_payment = ValidatePayment()
|
||||
process_payment = ProcessPayment()
|
||||
payment_confirmation = PaymentConfirmation()
|
||||
|
||||
check_stock = CheckStock()
|
||||
reserve_items = ReserveItems()
|
||||
update_inventory = UpdateInventory()
|
||||
|
||||
create_label = CreateLabel()
|
||||
assign_carrier = AssignCarrier()
|
||||
schedule_pickup = SchedulePickup()
|
||||
|
||||
# Payment processing sub-flow
|
||||
validate_payment >> process_payment >> payment_confirmation
|
||||
payment_flow = AsyncFlow(start=validate_payment)
|
||||
|
||||
# Inventory sub-flow
|
||||
check_stock >> reserve_items >> update_inventory
|
||||
inventory_flow = AsyncFlow(start=check_stock)
|
||||
|
||||
# Shipping sub-flow
|
||||
create_label >> assign_carrier >> schedule_pickup
|
||||
shipping_flow = AsyncFlow(start=create_label)
|
||||
|
||||
# Connect the flows into a main order pipeline
|
||||
payment_flow >> inventory_flow >> shipping_flow
|
||||
# payment_flow >> inventory_flow >> create_label
|
||||
# payment_flow >> inventory_flow >> assign_carrier
|
||||
|
||||
|
||||
# Create the master flow
|
||||
class OrderFlow(AsyncFlow):
|
||||
pass
|
||||
|
||||
|
||||
order_pipeline = OrderFlow(start=payment_flow)
|
||||
|
||||
# Create shared data structure
|
||||
shared_data = {
|
||||
"order_id": "ORD-12345",
|
||||
"customer": "John Doe",
|
||||
"items": [
|
||||
{"id": "ITEM-001", "name": "Smartphone", "price": 999.99, "quantity": 1},
|
||||
{"id": "ITEM-002", "name": "Phone case", "price": 29.99, "quantity": 1},
|
||||
],
|
||||
"shipping_address": {
|
||||
"street": "123 Main St",
|
||||
"city": "Anytown",
|
||||
"state": "CA",
|
||||
"zip": "12345",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# Run the entire pipeline asynchronously
|
||||
async def main():
|
||||
await order_pipeline.run_async(shared_data)
|
||||
|
||||
# Print final status
|
||||
print("\nOrder processing completed!")
|
||||
print(f"Payment: {shared_data.get('payment_confirmation')}")
|
||||
print(f"Inventory: {shared_data.get('inventory_update')}")
|
||||
print(f"Shipping: {shared_data.get('pickup_status')}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
|
@ -0,0 +1,932 @@
|
|||
# %%
|
||||
|
||||
import json
|
||||
import os
|
||||
import http.server
|
||||
import socketserver
|
||||
import threading
|
||||
import webbrowser
|
||||
import time
|
||||
import socket
|
||||
import importlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional, Tuple, Union
|
||||
|
||||
from pocketflow import Flow
|
||||
|
||||
from async_flow import order_pipeline
|
||||
|
||||
|
||||
def build_mermaid(start):
|
||||
ids, visited, lines = {}, set(), ["graph LR"]
|
||||
ctr = 1
|
||||
|
||||
def get_id(n):
|
||||
nonlocal ctr
|
||||
return (
|
||||
ids[n] if n in ids else (ids.setdefault(n, f"N{ctr}"), (ctr := ctr + 1))[0]
|
||||
)
|
||||
|
||||
def link(a, b):
|
||||
lines.append(f" {a} --> {b}")
|
||||
|
||||
def walk(node, parent=None):
|
||||
if node in visited:
|
||||
return parent and link(parent, get_id(node))
|
||||
visited.add(node)
|
||||
if isinstance(node, Flow):
|
||||
node.start_node and parent and link(parent, get_id(node.start_node))
|
||||
lines.append(
|
||||
f"\n subgraph sub_flow_{get_id(node)}[{type(node).__name__}]"
|
||||
)
|
||||
node.start_node and walk(node.start_node)
|
||||
for nxt in node.successors.values():
|
||||
node.start_node and walk(nxt, get_id(node.start_node)) or (
|
||||
parent and link(parent, get_id(nxt))
|
||||
) or walk(nxt)
|
||||
lines.append(" end\n")
|
||||
else:
|
||||
lines.append(f" {(nid := get_id(node))}['{type(node).__name__}']")
|
||||
parent and link(parent, nid)
|
||||
[walk(nxt, nid) for nxt in node.successors.values()]
|
||||
|
||||
walk(start)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def flow_to_json(start):
|
||||
"""Convert a flow to JSON format suitable for D3.js visualization.
|
||||
|
||||
This function walks through the flow graph and builds a structure with:
|
||||
- nodes: All non-Flow nodes with their group memberships
|
||||
- links: Connections between nodes within the same group
|
||||
- group_links: Connections between different groups (for inter-flow connections)
|
||||
- flows: Flow information for group labeling
|
||||
|
||||
Returns:
|
||||
dict: A JSON-serializable dictionary with 'nodes' and 'links' arrays.
|
||||
"""
|
||||
nodes = []
|
||||
links = []
|
||||
group_links = [] # For connections between groups (Flow to Flow)
|
||||
ids = {}
|
||||
node_types = {}
|
||||
flow_nodes = {} # Keep track of flow nodes
|
||||
ctr = 1
|
||||
|
||||
def get_id(n):
|
||||
nonlocal ctr
|
||||
if n not in ids:
|
||||
ids[n] = ctr
|
||||
node_types[ctr] = type(n).__name__
|
||||
if isinstance(n, Flow):
|
||||
flow_nodes[ctr] = n # Store flow reference
|
||||
ctr += 1
|
||||
return ids[n]
|
||||
|
||||
def walk(node, parent=None, group=None, parent_group=None, action=None):
|
||||
"""Recursively walk the flow graph to build the visualization data.
|
||||
|
||||
Args:
|
||||
node: Current node being processed
|
||||
parent: ID of the parent node that connects to this node
|
||||
group: Group (Flow) ID this node belongs to
|
||||
parent_group: Group ID of the parent node
|
||||
action: Action label on the edge from parent to this node
|
||||
"""
|
||||
node_id = get_id(node)
|
||||
|
||||
# Add node if not already in nodes list and not a Flow
|
||||
if not any(n["id"] == node_id for n in nodes) and not isinstance(node, Flow):
|
||||
node_data = {
|
||||
"id": node_id,
|
||||
"name": node_types[node_id],
|
||||
"group": group or 0, # Default group
|
||||
}
|
||||
nodes.append(node_data)
|
||||
|
||||
# Add link from parent if exists
|
||||
if parent and not isinstance(node, Flow):
|
||||
links.append(
|
||||
{"source": parent, "target": node_id, "action": action or "default"}
|
||||
)
|
||||
|
||||
# Process different types of nodes
|
||||
if isinstance(node, Flow):
|
||||
# This is a Flow node - it becomes a group container
|
||||
flow_group = node_id # Use flow's ID as group for contained nodes
|
||||
|
||||
# Add a group-to-group link if this flow has a parent group
|
||||
# This creates connections between nested flows
|
||||
if parent_group is not None and parent_group != flow_group:
|
||||
# Check if this link already exists
|
||||
if not any(
|
||||
l["source"] == parent_group and l["target"] == flow_group
|
||||
for l in group_links
|
||||
):
|
||||
group_links.append(
|
||||
{
|
||||
"source": parent_group,
|
||||
"target": flow_group,
|
||||
"action": action or "default",
|
||||
}
|
||||
)
|
||||
|
||||
if node.start_node:
|
||||
# Process the start node of this flow
|
||||
walk(node.start_node, parent, flow_group, parent_group, action)
|
||||
|
||||
# Process successors of the flow's start node
|
||||
for next_action, nxt in node.successors.items():
|
||||
walk(
|
||||
nxt,
|
||||
get_id(node.start_node),
|
||||
flow_group,
|
||||
parent_group,
|
||||
next_action,
|
||||
)
|
||||
else:
|
||||
# Process successors for regular nodes
|
||||
for next_action, nxt in node.successors.items():
|
||||
if isinstance(nxt, Flow):
|
||||
# This node connects to a flow - track the group relationship
|
||||
flow_group_id = get_id(nxt)
|
||||
walk(nxt, node_id, None, group, next_action)
|
||||
else:
|
||||
# Regular node-to-node connection
|
||||
walk(nxt, node_id, group, parent_group, next_action)
|
||||
|
||||
# Start the traversal
|
||||
walk(start)
|
||||
|
||||
# Post-processing: Generate group links based on node connections between different groups
|
||||
# This ensures that when nodes in different groups are connected, we show a group-to-group
|
||||
# link rather than a direct node-to-node link
|
||||
node_groups = {n["id"]: n["group"] for n in nodes}
|
||||
filtered_links = []
|
||||
|
||||
for link in links:
|
||||
source_id = link["source"]
|
||||
target_id = link["target"]
|
||||
source_group = node_groups.get(source_id, 0)
|
||||
target_group = node_groups.get(target_id, 0)
|
||||
|
||||
# If source and target are in different groups and both groups are valid
|
||||
if source_group != target_group and source_group > 0 and target_group > 0:
|
||||
# Add to group links if not already there
|
||||
# This creates the dashed lines connecting group boxes
|
||||
if not any(
|
||||
gl["source"] == source_group and gl["target"] == target_group
|
||||
for gl in group_links
|
||||
):
|
||||
group_links.append(
|
||||
{
|
||||
"source": source_group,
|
||||
"target": target_group,
|
||||
"action": link["action"],
|
||||
}
|
||||
)
|
||||
# Skip adding this link to filtered_links - we don't want direct node connections across groups
|
||||
else:
|
||||
# Keep links within the same group
|
||||
filtered_links.append(link)
|
||||
|
||||
return {
|
||||
"nodes": nodes,
|
||||
"links": filtered_links, # Use filtered links instead of all links
|
||||
"group_links": group_links,
|
||||
"flows": {str(k): v.__class__.__name__ for k, v in flow_nodes.items()},
|
||||
}
|
||||
|
||||
|
||||
def create_d3_visualization(
|
||||
json_data,
|
||||
output_dir="./viz",
|
||||
filename="flow_viz",
|
||||
html_title="PocketFlow Visualization",
|
||||
):
|
||||
"""Create a D3.js visualization from JSON data.
|
||||
|
||||
Args:
|
||||
json_data: The JSON data for the visualization
|
||||
output_dir: Directory to save the files
|
||||
filename: Base filename (without extension)
|
||||
html_title: Title for the HTML page
|
||||
|
||||
Returns:
|
||||
str: Path to the HTML file
|
||||
"""
|
||||
# Create output directory if it doesn't exist
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
# Save JSON data to file
|
||||
json_path = os.path.join(output_dir, f"{filename}.json")
|
||||
with open(json_path, "w") as f:
|
||||
json.dump(json_data, f, indent=2)
|
||||
|
||||
# Create HTML file with D3.js visualization
|
||||
html_content = r"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>TITLE_PLACEHOLDER</title>
|
||||
<script src="https://d3js.org/d3.v7.min.js"></script>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
svg {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
.links path {
|
||||
fill: none;
|
||||
stroke: #999;
|
||||
stroke-opacity: 0.6;
|
||||
stroke-width: 1.5px;
|
||||
}
|
||||
.group-links path {
|
||||
fill: none;
|
||||
stroke: #333;
|
||||
stroke-opacity: 0.8;
|
||||
stroke-width: 2px;
|
||||
stroke-dasharray: 5,5;
|
||||
}
|
||||
.nodes circle {
|
||||
stroke: #fff;
|
||||
stroke-width: 1.5px;
|
||||
}
|
||||
.node-labels {
|
||||
font-size: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.link-labels {
|
||||
font-size: 10px;
|
||||
fill: #666;
|
||||
pointer-events: none;
|
||||
}
|
||||
.group-link-labels {
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
fill: #333;
|
||||
pointer-events: none;
|
||||
}
|
||||
.group-container {
|
||||
stroke: #333;
|
||||
stroke-width: 1.5px;
|
||||
stroke-dasharray: 5,5;
|
||||
fill: rgba(200, 200, 200, 0.1);
|
||||
rx: 10;
|
||||
ry: 10;
|
||||
}
|
||||
.group-label {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<svg id="graph"></svg>
|
||||
<script>
|
||||
// Load data from file
|
||||
d3.json("FILENAME_PLACEHOLDER.json").then(data => {
|
||||
const svg = d3.select("#graph");
|
||||
const width = window.innerWidth;
|
||||
const height = window.innerHeight;
|
||||
|
||||
// Define arrow markers for links
|
||||
svg.append("defs").append("marker")
|
||||
.attr("id", "arrowhead")
|
||||
.attr("viewBox", "0 -5 10 10")
|
||||
.attr("refX", 25) // Position the arrow away from the target node
|
||||
.attr("refY", 0)
|
||||
.attr("orient", "auto")
|
||||
.attr("markerWidth", 6)
|
||||
.attr("markerHeight", 6)
|
||||
.attr("xoverflow", "visible")
|
||||
.append("path")
|
||||
.attr("d", "M 0,-5 L 10,0 L 0,5")
|
||||
.attr("fill", "#999");
|
||||
|
||||
// Define thicker arrow markers for group links
|
||||
svg.append("defs").append("marker")
|
||||
.attr("id", "group-arrowhead")
|
||||
.attr("viewBox", "0 -5 10 10")
|
||||
.attr("refX", 3) // Position at the boundary of the group
|
||||
.attr("refY", 0)
|
||||
.attr("orient", "auto")
|
||||
.attr("markerWidth", 8)
|
||||
.attr("markerHeight", 8)
|
||||
.attr("xoverflow", "visible")
|
||||
.append("path")
|
||||
.attr("d", "M 0,-5 L 10,0 L 0,5")
|
||||
.attr("fill", "#333");
|
||||
|
||||
// Color scale for node groups
|
||||
const color = d3.scaleOrdinal(d3.schemeCategory10);
|
||||
|
||||
// Process the data to identify groups
|
||||
const groups = {};
|
||||
data.nodes.forEach(node => {
|
||||
if (node.group > 0) {
|
||||
if (!groups[node.group]) {
|
||||
// Use the flow name instead of generic "Group X"
|
||||
const flowName = data.flows && data.flows[node.group] ? data.flows[node.group] : `Flow ${node.group}`;
|
||||
groups[node.group] = {
|
||||
id: node.group,
|
||||
name: flowName,
|
||||
nodes: [],
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 0,
|
||||
height: 0
|
||||
};
|
||||
}
|
||||
groups[node.group].nodes.push(node);
|
||||
}
|
||||
});
|
||||
|
||||
// Create a force simulation
|
||||
const simulation = d3.forceSimulation(data.nodes)
|
||||
// Controls the distance between connected nodes
|
||||
.force("link", d3.forceLink(data.links).id(d => d.id).distance(100))
|
||||
// Controls how nodes repel each other - lower values bring nodes closer
|
||||
.force("charge", d3.forceManyBody().strength(-30))
|
||||
// Centers the entire graph in the SVG
|
||||
.force("center", d3.forceCenter(width / 2, height / 2))
|
||||
// Prevents nodes from overlapping - acts like a minimum distance
|
||||
.force("collide", d3.forceCollide().radius(50));
|
||||
|
||||
// Group forces - create a force to keep nodes in the same group closer together
|
||||
// This creates the effect of nodes clustering within their group boxes
|
||||
const groupForce = alpha => {
|
||||
for (let i = 0; i < data.nodes.length; i++) {
|
||||
const node = data.nodes[i];
|
||||
if (node.group > 0) {
|
||||
const group = groups[node.group];
|
||||
if (group && group.nodes.length > 1) {
|
||||
// Calculate center of group
|
||||
let centerX = 0, centerY = 0;
|
||||
group.nodes.forEach(n => {
|
||||
centerX += n.x || 0;
|
||||
centerY += n.y || 0;
|
||||
});
|
||||
centerX /= group.nodes.length;
|
||||
centerY /= group.nodes.length;
|
||||
|
||||
// Move nodes toward center
|
||||
const k = alpha * 0.3; // Increased from 0.1 to 0.3
|
||||
node.vx += (centerX - node.x) * k;
|
||||
node.vy += (centerY - node.y) * k;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Additional force to position groups in a more organized layout (like in the image)
|
||||
// This arranges the groups horizontally/vertically based on their connections
|
||||
const groupLayoutForce = alpha => {
|
||||
// Get group centers
|
||||
const groupCenters = Object.values(groups).map(g => {
|
||||
return { id: g.id, cx: 0, cy: 0 };
|
||||
});
|
||||
|
||||
// Calculate current center positions
|
||||
Object.values(groups).forEach(g => {
|
||||
if (g.nodes.length > 0) {
|
||||
let cx = 0, cy = 0;
|
||||
g.nodes.forEach(n => {
|
||||
cx += n.x || 0;
|
||||
cy += n.y || 0;
|
||||
});
|
||||
|
||||
const groupCenter = groupCenters.find(gc => gc.id === g.id);
|
||||
if (groupCenter) {
|
||||
groupCenter.cx = cx / g.nodes.length;
|
||||
groupCenter.cy = cy / g.nodes.length;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Apply forces to position groups
|
||||
const k = alpha * 0.05;
|
||||
|
||||
// Try to position groups in a more structured way
|
||||
// Adjust these values to change the overall layout
|
||||
for (let i = 0; i < data.group_links.length; i++) {
|
||||
const link = data.group_links[i];
|
||||
const source = groupCenters.find(g => g.id === link.source);
|
||||
const target = groupCenters.find(g => g.id === link.target);
|
||||
|
||||
if (source && target) {
|
||||
// Add a horizontal force to align groups
|
||||
const desiredDx = 300; // Desired horizontal distance between linked groups
|
||||
const dx = target.cx - source.cx;
|
||||
const diff = desiredDx - Math.abs(dx);
|
||||
|
||||
// Apply forces to group nodes
|
||||
groups[source.id].nodes.forEach(n => {
|
||||
if (dx > 0) {
|
||||
n.vx -= diff * k;
|
||||
} else {
|
||||
n.vx += diff * k;
|
||||
}
|
||||
});
|
||||
|
||||
groups[target.id].nodes.forEach(n => {
|
||||
if (dx > 0) {
|
||||
n.vx += diff * k;
|
||||
} else {
|
||||
n.vx -= diff * k;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
simulation.force("group", groupForce);
|
||||
simulation.force("groupLayout", groupLayoutForce);
|
||||
|
||||
// Create links with arrow paths instead of lines
|
||||
const link = svg.append("g")
|
||||
.attr("class", "links")
|
||||
.selectAll("path")
|
||||
.data(data.links)
|
||||
.enter()
|
||||
.append("path")
|
||||
.attr("stroke-width", 2)
|
||||
.attr("stroke", "#999")
|
||||
.attr("marker-end", "url(#arrowhead)"); // Add the arrowhead marker
|
||||
|
||||
// Create group containers (drawn before nodes)
|
||||
const groupContainers = svg.append("g")
|
||||
.attr("class", "groups")
|
||||
.selectAll("rect")
|
||||
.data(Object.values(groups))
|
||||
.enter()
|
||||
.append("rect")
|
||||
.attr("class", "group-container")
|
||||
.attr("fill", d => d3.color(color(d.id)).copy({opacity: 0.2}));
|
||||
|
||||
// Create group links between flows
|
||||
const groupLink = svg.append("g")
|
||||
.attr("class", "group-links")
|
||||
.selectAll("path")
|
||||
.data(data.group_links || [])
|
||||
.enter()
|
||||
.append("path")
|
||||
.attr("stroke-width", 2)
|
||||
.attr("stroke", "#333")
|
||||
.attr("marker-end", "url(#group-arrowhead)");
|
||||
|
||||
// Create group link labels
|
||||
const groupLinkLabel = svg.append("g")
|
||||
.attr("class", "group-link-labels")
|
||||
.selectAll("text")
|
||||
.data(data.group_links || [])
|
||||
.enter()
|
||||
.append("text")
|
||||
.text(d => d.action)
|
||||
.attr("font-size", "11px")
|
||||
.attr("font-weight", "bold")
|
||||
.attr("fill", "#333");
|
||||
|
||||
// Create group labels
|
||||
const groupLabels = svg.append("g")
|
||||
.attr("class", "group-labels")
|
||||
.selectAll("text")
|
||||
.data(Object.values(groups))
|
||||
.enter()
|
||||
.append("text")
|
||||
.attr("class", "group-label")
|
||||
.text(d => d.name) // Now using the proper flow name
|
||||
.attr("fill", d => d3.color(color(d.id)).darker());
|
||||
|
||||
// Create link labels
|
||||
const linkLabel = svg.append("g")
|
||||
.attr("class", "link-labels")
|
||||
.selectAll("text")
|
||||
.data(data.links)
|
||||
.enter()
|
||||
.append("text")
|
||||
.text(d => d.action)
|
||||
.attr("font-size", "10px")
|
||||
.attr("fill", "#666");
|
||||
|
||||
// Create nodes
|
||||
const node = svg.append("g")
|
||||
.attr("class", "nodes")
|
||||
.selectAll("circle")
|
||||
.data(data.nodes)
|
||||
.enter()
|
||||
.append("circle")
|
||||
.attr("r", 15)
|
||||
.attr("fill", d => color(d.group))
|
||||
.call(d3.drag()
|
||||
.on("start", dragstarted)
|
||||
.on("drag", dragged)
|
||||
.on("end", dragended));
|
||||
|
||||
// Create node labels
|
||||
const nodeLabel = svg.append("g")
|
||||
.attr("class", "node-labels")
|
||||
.selectAll("text")
|
||||
.data(data.nodes)
|
||||
.enter()
|
||||
.append("text")
|
||||
.text(d => d.name)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("dy", 25);
|
||||
|
||||
// Add tooltip on hover
|
||||
node.append("title")
|
||||
.text(d => d.name);
|
||||
|
||||
// Update positions on each tick
|
||||
simulation.on("tick", () => {
|
||||
// Update links with straight lines
|
||||
link.attr("d", d => {
|
||||
return `M${d.source.x},${d.source.y} L${d.target.x},${d.target.y}`;
|
||||
});
|
||||
|
||||
// Update nodes
|
||||
node
|
||||
.attr("cx", d => d.x)
|
||||
.attr("cy", d => d.y);
|
||||
|
||||
// Update node labels
|
||||
nodeLabel
|
||||
.attr("x", d => d.x)
|
||||
.attr("y", d => d.y);
|
||||
|
||||
// Position link labels at midpoint
|
||||
linkLabel
|
||||
.attr("x", d => (d.source.x + d.target.x) / 2)
|
||||
.attr("y", d => (d.source.y + d.target.y) / 2);
|
||||
|
||||
// Update group containers
|
||||
groupContainers.each(function(d) {
|
||||
// If there are nodes in this group
|
||||
if (d.nodes.length > 0) {
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
|
||||
// Find the bounding box for all nodes in the group
|
||||
d.nodes.forEach(n => {
|
||||
minX = Math.min(minX, n.x - 30);
|
||||
minY = Math.min(minY, n.y - 30);
|
||||
maxX = Math.max(maxX, n.x + 30);
|
||||
maxY = Math.max(maxY, n.y + 40); // Extra space for labels
|
||||
});
|
||||
|
||||
// Add padding
|
||||
const padding = 20;
|
||||
minX -= padding;
|
||||
minY -= padding;
|
||||
maxX += padding;
|
||||
maxY += padding;
|
||||
|
||||
// Save group dimensions
|
||||
d.x = minX;
|
||||
d.y = minY;
|
||||
d.width = maxX - minX;
|
||||
d.height = maxY - minY;
|
||||
d.centerX = minX + d.width / 2;
|
||||
d.centerY = minY + d.height / 2;
|
||||
|
||||
// Set position and size of the group container
|
||||
d3.select(this)
|
||||
.attr("x", minX)
|
||||
.attr("y", minY)
|
||||
.attr("width", d.width)
|
||||
.attr("height", d.height);
|
||||
|
||||
// Update group label position (top-left of group)
|
||||
groupLabels.filter(g => g.id === d.id)
|
||||
.attr("x", minX + 10)
|
||||
.attr("y", minY + 20);
|
||||
}
|
||||
});
|
||||
|
||||
// Update group links between flows
|
||||
groupLink.attr("d", d => {
|
||||
const sourceGroup = groups[d.source];
|
||||
const targetGroup = groups[d.target];
|
||||
|
||||
if (!sourceGroup || !targetGroup) return "";
|
||||
|
||||
// Find intersection points with group boundaries
|
||||
// This ensures links connect to the group's border rather than its center
|
||||
|
||||
// Calculate centers of groups
|
||||
const sx = sourceGroup.centerX;
|
||||
const sy = sourceGroup.centerY;
|
||||
const tx = targetGroup.centerX;
|
||||
const ty = targetGroup.centerY;
|
||||
|
||||
// Calculate angle between centers - used to find intersection points
|
||||
const angle = Math.atan2(ty - sy, tx - sx);
|
||||
|
||||
// Calculate intersection points with source group borders
|
||||
// We cast a ray from center in the direction of the target
|
||||
let sourceX, sourceY;
|
||||
const cosA = Math.cos(angle);
|
||||
const sinA = Math.sin(angle);
|
||||
|
||||
// Check intersection with horizontal borders (top and bottom)
|
||||
const ts_top = (sourceGroup.y - sy) / sinA;
|
||||
const ts_bottom = (sourceGroup.y + sourceGroup.height - sy) / sinA;
|
||||
|
||||
// Check intersection with vertical borders (left and right)
|
||||
const ts_left = (sourceGroup.x - sx) / cosA;
|
||||
const ts_right = (sourceGroup.x + sourceGroup.width - sx) / cosA;
|
||||
|
||||
// Use the closest positive intersection (first hit with the boundary)
|
||||
let t_source = Infinity;
|
||||
if (ts_top > 0) t_source = Math.min(t_source, ts_top);
|
||||
if (ts_bottom > 0) t_source = Math.min(t_source, ts_bottom);
|
||||
if (ts_left > 0) t_source = Math.min(t_source, ts_left);
|
||||
if (ts_right > 0) t_source = Math.min(t_source, ts_right);
|
||||
|
||||
// Target group: Find intersection in the opposite direction
|
||||
// We cast a ray from target center toward the source
|
||||
let targetX, targetY;
|
||||
const oppositeAngle = angle + Math.PI;
|
||||
const cosOpp = Math.cos(oppositeAngle);
|
||||
const sinOpp = Math.sin(oppositeAngle);
|
||||
|
||||
// Check intersections for target group
|
||||
const tt_top = (targetGroup.y - ty) / sinOpp;
|
||||
const tt_bottom = (targetGroup.y + targetGroup.height - ty) / sinOpp;
|
||||
const tt_left = (targetGroup.x - tx) / cosOpp;
|
||||
const tt_right = (targetGroup.x + targetGroup.width - tx) / cosOpp;
|
||||
|
||||
// Use the closest positive intersection
|
||||
let t_target = Infinity;
|
||||
if (tt_top > 0) t_target = Math.min(t_target, tt_top);
|
||||
if (tt_bottom > 0) t_target = Math.min(t_target, tt_bottom);
|
||||
if (tt_left > 0) t_target = Math.min(t_target, tt_left);
|
||||
if (tt_right > 0) t_target = Math.min(t_target, tt_right);
|
||||
|
||||
// Calculate actual border points using parametric equation:
|
||||
// point = center + t * direction
|
||||
if (t_source !== Infinity) {
|
||||
sourceX = sx + cosA * t_source;
|
||||
sourceY = sy + sinA * t_source;
|
||||
} else {
|
||||
sourceX = sx;
|
||||
sourceY = sy;
|
||||
}
|
||||
|
||||
if (t_target !== Infinity) {
|
||||
targetX = tx + cosOpp * t_target;
|
||||
targetY = ty + sinOpp * t_target;
|
||||
} else {
|
||||
targetX = tx;
|
||||
targetY = ty;
|
||||
}
|
||||
|
||||
// Create a straight line between the border points
|
||||
return `M${sourceX},${sourceY} L${targetX},${targetY}`;
|
||||
});
|
||||
|
||||
// Update group link labels
|
||||
groupLinkLabel.attr("x", d => {
|
||||
const sourceGroup = groups[d.source];
|
||||
const targetGroup = groups[d.target];
|
||||
if (!sourceGroup || !targetGroup) return 0;
|
||||
return (sourceGroup.centerX + targetGroup.centerX) / 2;
|
||||
})
|
||||
.attr("y", d => {
|
||||
const sourceGroup = groups[d.source];
|
||||
const targetGroup = groups[d.target];
|
||||
if (!sourceGroup || !targetGroup) return 0;
|
||||
return (sourceGroup.centerY + targetGroup.centerY) / 2 - 10;
|
||||
});
|
||||
});
|
||||
|
||||
// Drag functions
|
||||
function dragstarted(event, d) {
|
||||
if (!event.active) simulation.alphaTarget(0.3).restart();
|
||||
d.fx = d.x;
|
||||
d.fy = d.y;
|
||||
}
|
||||
|
||||
function dragged(event, d) {
|
||||
d.fx = event.x;
|
||||
d.fy = event.y;
|
||||
}
|
||||
|
||||
function dragended(event, d) {
|
||||
if (!event.active) simulation.alphaTarget(0);
|
||||
d.fx = null;
|
||||
d.fy = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
# Replace the placeholders with the actual values
|
||||
html_content = html_content.replace("FILENAME_PLACEHOLDER", filename)
|
||||
html_content = html_content.replace("TITLE_PLACEHOLDER", html_title)
|
||||
|
||||
# Write HTML to file
|
||||
html_path = os.path.join(output_dir, f"{filename}.html")
|
||||
with open(html_path, "w") as f:
|
||||
f.write(html_content)
|
||||
|
||||
print(f"Visualization created at {html_path}")
|
||||
return html_path
|
||||
|
||||
|
||||
def find_free_port():
|
||||
"""Find a free port on localhost."""
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("", 0))
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
def start_http_server(directory, port=None):
|
||||
"""Start an HTTP server in the given directory.
|
||||
|
||||
Args:
|
||||
directory: Directory to serve files from
|
||||
port: Port to use (finds a free port if None)
|
||||
|
||||
Returns:
|
||||
tuple: (server_thread, port)
|
||||
"""
|
||||
if port is None:
|
||||
port = find_free_port()
|
||||
|
||||
# Get the absolute path of the directory
|
||||
directory = str(Path(directory).absolute())
|
||||
|
||||
# Change to the directory to serve files
|
||||
os.chdir(directory)
|
||||
|
||||
# Create HTTP server
|
||||
handler = http.server.SimpleHTTPRequestHandler
|
||||
httpd = socketserver.TCPServer(("", port), handler)
|
||||
|
||||
# Start server in a separate thread
|
||||
server_thread = threading.Thread(target=httpd.serve_forever)
|
||||
server_thread.daemon = (
|
||||
True # This makes the thread exit when the main program exits
|
||||
)
|
||||
server_thread.start()
|
||||
|
||||
print(f"Server started at http://localhost:{port}")
|
||||
return server_thread, port
|
||||
|
||||
|
||||
def serve_and_open_visualization(html_path, auto_open=True):
|
||||
"""Serve the HTML file and open it in a browser.
|
||||
|
||||
Args:
|
||||
html_path: Path to the HTML file
|
||||
auto_open: Whether to automatically open the browser
|
||||
|
||||
Returns:
|
||||
tuple: (server_thread, url)
|
||||
"""
|
||||
# Get the directory and filename
|
||||
directory = os.path.dirname(os.path.abspath(html_path))
|
||||
filename = os.path.basename(html_path)
|
||||
|
||||
# Start the server
|
||||
server_thread, port = start_http_server(directory)
|
||||
|
||||
# Build the URL
|
||||
url = f"http://localhost:{port}/{filename}"
|
||||
|
||||
# Open the URL in a browser
|
||||
if auto_open:
|
||||
print(f"Opening {url} in your browser...")
|
||||
webbrowser.open(url)
|
||||
else:
|
||||
print(f"Visualization available at {url}")
|
||||
|
||||
return server_thread, url
|
||||
|
||||
|
||||
def visualize_flow(
|
||||
flow: Flow,
|
||||
flow_name: str,
|
||||
serve: bool = True,
|
||||
auto_open: bool = True,
|
||||
output_dir: str = "./viz",
|
||||
html_title: Optional[str] = None,
|
||||
) -> Union[str, Tuple[str, Any, str]]:
|
||||
"""Helper function to visualize a flow with both mermaid and D3.js
|
||||
|
||||
Args:
|
||||
flow: Flow object to visualize
|
||||
flow_name: Name of the flow (used for filename and display)
|
||||
serve: Whether to start a server for the visualization
|
||||
auto_open: Whether to automatically open in browser
|
||||
output_dir: Directory to save visualization files
|
||||
html_title: Custom title for the HTML page (defaults to flow_name if None)
|
||||
|
||||
Returns:
|
||||
str or tuple: Path to HTML file, or (path, server_thread, url) if serve=True
|
||||
"""
|
||||
print(f"\n--- {flow_name} Mermaid Diagram ---")
|
||||
print(build_mermaid(start=flow))
|
||||
|
||||
print(f"\n--- {flow_name} D3.js Visualization ---")
|
||||
json_data = flow_to_json(flow)
|
||||
|
||||
# Create the visualization
|
||||
output_filename = f"{flow_name.lower().replace(' ', '_')}"
|
||||
|
||||
# Use flow_name as the HTML title if not specified
|
||||
if html_title is None:
|
||||
html_title = f"PocketFlow: {flow_name}"
|
||||
|
||||
html_path = create_d3_visualization(
|
||||
json_data,
|
||||
output_dir=output_dir,
|
||||
filename=output_filename,
|
||||
html_title=html_title,
|
||||
)
|
||||
|
||||
# Serve and open if requested
|
||||
if serve:
|
||||
server_thread, url = serve_and_open_visualization(html_path, auto_open)
|
||||
return html_path, server_thread, url
|
||||
|
||||
return html_path
|
||||
|
||||
|
||||
def load_flow_from_module(module_path: str, flow_variable: str) -> Flow:
|
||||
"""Dynamically load a flow from a module.
|
||||
|
||||
Args:
|
||||
module_path: Path to the module (e.g., 'my_package.my_module')
|
||||
flow_variable: Name of the flow variable in the module
|
||||
|
||||
Returns:
|
||||
Flow: The loaded flow object
|
||||
"""
|
||||
try:
|
||||
module = importlib.import_module(module_path)
|
||||
return getattr(module, flow_variable)
|
||||
except (ImportError, AttributeError) as e:
|
||||
print(f"Error loading flow: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# Example usage
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Visualize a PocketFlow flow")
|
||||
parser.add_argument(
|
||||
"--module", default="async_flow", help="Module containing the flow"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--flow", default="order_pipeline", help="Flow variable name in the module"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--name", default="Flow Visualization", help="Name for the visualization"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-dir", default="./viz", help="Directory to save visualization files"
|
||||
)
|
||||
parser.add_argument("--no-serve", action="store_true", help="Don't start a server")
|
||||
parser.add_argument(
|
||||
"--no-open", action="store_true", help="Don't open browser automatically"
|
||||
)
|
||||
parser.add_argument("--title", help="Custom HTML title")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Load flow from the specified module
|
||||
flow_obj = load_flow_from_module(args.module, args.flow)
|
||||
|
||||
# Visualize the flow
|
||||
visualize_flow(
|
||||
flow=flow_obj,
|
||||
flow_name=args.name,
|
||||
serve=not args.no_serve,
|
||||
auto_open=not args.no_open,
|
||||
output_dir=args.output_dir,
|
||||
html_title=args.title,
|
||||
)
|
||||
|
||||
# Keep server running if serving
|
||||
if not args.no_serve:
|
||||
try:
|
||||
print("\nServer is running. Press Ctrl+C to stop...")
|
||||
while True:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
print("\nShutting down...")
|
||||
|
|
@ -0,0 +1,504 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>PocketFlow: Flow Visualization</title>
|
||||
<script src="https://d3js.org/d3.v7.min.js"></script>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
svg {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
.links path {
|
||||
fill: none;
|
||||
stroke: #999;
|
||||
stroke-opacity: 0.6;
|
||||
stroke-width: 1.5px;
|
||||
}
|
||||
.group-links path {
|
||||
fill: none;
|
||||
stroke: #333;
|
||||
stroke-opacity: 0.8;
|
||||
stroke-width: 2px;
|
||||
stroke-dasharray: 5,5;
|
||||
}
|
||||
.nodes circle {
|
||||
stroke: #fff;
|
||||
stroke-width: 1.5px;
|
||||
}
|
||||
.node-labels {
|
||||
font-size: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.link-labels {
|
||||
font-size: 10px;
|
||||
fill: #666;
|
||||
pointer-events: none;
|
||||
}
|
||||
.group-link-labels {
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
fill: #333;
|
||||
pointer-events: none;
|
||||
}
|
||||
.group-container {
|
||||
stroke: #333;
|
||||
stroke-width: 1.5px;
|
||||
stroke-dasharray: 5,5;
|
||||
fill: rgba(200, 200, 200, 0.1);
|
||||
rx: 10;
|
||||
ry: 10;
|
||||
}
|
||||
.group-label {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<svg id="graph"></svg>
|
||||
<script>
|
||||
// Load data from file
|
||||
d3.json("flow_visualization.json").then(data => {
|
||||
const svg = d3.select("#graph");
|
||||
const width = window.innerWidth;
|
||||
const height = window.innerHeight;
|
||||
|
||||
// Define arrow markers for links
|
||||
svg.append("defs").append("marker")
|
||||
.attr("id", "arrowhead")
|
||||
.attr("viewBox", "0 -5 10 10")
|
||||
.attr("refX", 25) // Position the arrow away from the target node
|
||||
.attr("refY", 0)
|
||||
.attr("orient", "auto")
|
||||
.attr("markerWidth", 6)
|
||||
.attr("markerHeight", 6)
|
||||
.attr("xoverflow", "visible")
|
||||
.append("path")
|
||||
.attr("d", "M 0,-5 L 10,0 L 0,5")
|
||||
.attr("fill", "#999");
|
||||
|
||||
// Define thicker arrow markers for group links
|
||||
svg.append("defs").append("marker")
|
||||
.attr("id", "group-arrowhead")
|
||||
.attr("viewBox", "0 -5 10 10")
|
||||
.attr("refX", 3) // Position at the boundary of the group
|
||||
.attr("refY", 0)
|
||||
.attr("orient", "auto")
|
||||
.attr("markerWidth", 8)
|
||||
.attr("markerHeight", 8)
|
||||
.attr("xoverflow", "visible")
|
||||
.append("path")
|
||||
.attr("d", "M 0,-5 L 10,0 L 0,5")
|
||||
.attr("fill", "#333");
|
||||
|
||||
// Color scale for node groups
|
||||
const color = d3.scaleOrdinal(d3.schemeCategory10);
|
||||
|
||||
// Process the data to identify groups
|
||||
const groups = {};
|
||||
data.nodes.forEach(node => {
|
||||
if (node.group > 0) {
|
||||
if (!groups[node.group]) {
|
||||
// Use the flow name instead of generic "Group X"
|
||||
const flowName = data.flows && data.flows[node.group] ? data.flows[node.group] : `Flow ${node.group}`;
|
||||
groups[node.group] = {
|
||||
id: node.group,
|
||||
name: flowName,
|
||||
nodes: [],
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 0,
|
||||
height: 0
|
||||
};
|
||||
}
|
||||
groups[node.group].nodes.push(node);
|
||||
}
|
||||
});
|
||||
|
||||
// Create a force simulation
|
||||
const simulation = d3.forceSimulation(data.nodes)
|
||||
// Controls the distance between connected nodes
|
||||
.force("link", d3.forceLink(data.links).id(d => d.id).distance(100))
|
||||
// Controls how nodes repel each other - lower values bring nodes closer
|
||||
.force("charge", d3.forceManyBody().strength(-30))
|
||||
// Centers the entire graph in the SVG
|
||||
.force("center", d3.forceCenter(width / 2, height / 2))
|
||||
// Prevents nodes from overlapping - acts like a minimum distance
|
||||
.force("collide", d3.forceCollide().radius(50));
|
||||
|
||||
// Group forces - create a force to keep nodes in the same group closer together
|
||||
// This creates the effect of nodes clustering within their group boxes
|
||||
const groupForce = alpha => {
|
||||
for (let i = 0; i < data.nodes.length; i++) {
|
||||
const node = data.nodes[i];
|
||||
if (node.group > 0) {
|
||||
const group = groups[node.group];
|
||||
if (group && group.nodes.length > 1) {
|
||||
// Calculate center of group
|
||||
let centerX = 0, centerY = 0;
|
||||
group.nodes.forEach(n => {
|
||||
centerX += n.x || 0;
|
||||
centerY += n.y || 0;
|
||||
});
|
||||
centerX /= group.nodes.length;
|
||||
centerY /= group.nodes.length;
|
||||
|
||||
// Move nodes toward center
|
||||
const k = alpha * 0.3; // Increased from 0.1 to 0.3
|
||||
node.vx += (centerX - node.x) * k;
|
||||
node.vy += (centerY - node.y) * k;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Additional force to position groups in a more organized layout (like in the image)
|
||||
// This arranges the groups horizontally/vertically based on their connections
|
||||
const groupLayoutForce = alpha => {
|
||||
// Get group centers
|
||||
const groupCenters = Object.values(groups).map(g => {
|
||||
return { id: g.id, cx: 0, cy: 0 };
|
||||
});
|
||||
|
||||
// Calculate current center positions
|
||||
Object.values(groups).forEach(g => {
|
||||
if (g.nodes.length > 0) {
|
||||
let cx = 0, cy = 0;
|
||||
g.nodes.forEach(n => {
|
||||
cx += n.x || 0;
|
||||
cy += n.y || 0;
|
||||
});
|
||||
|
||||
const groupCenter = groupCenters.find(gc => gc.id === g.id);
|
||||
if (groupCenter) {
|
||||
groupCenter.cx = cx / g.nodes.length;
|
||||
groupCenter.cy = cy / g.nodes.length;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Apply forces to position groups
|
||||
const k = alpha * 0.05;
|
||||
|
||||
// Try to position groups in a more structured way
|
||||
// Adjust these values to change the overall layout
|
||||
for (let i = 0; i < data.group_links.length; i++) {
|
||||
const link = data.group_links[i];
|
||||
const source = groupCenters.find(g => g.id === link.source);
|
||||
const target = groupCenters.find(g => g.id === link.target);
|
||||
|
||||
if (source && target) {
|
||||
// Add a horizontal force to align groups
|
||||
const desiredDx = 300; // Desired horizontal distance between linked groups
|
||||
const dx = target.cx - source.cx;
|
||||
const diff = desiredDx - Math.abs(dx);
|
||||
|
||||
// Apply forces to group nodes
|
||||
groups[source.id].nodes.forEach(n => {
|
||||
if (dx > 0) {
|
||||
n.vx -= diff * k;
|
||||
} else {
|
||||
n.vx += diff * k;
|
||||
}
|
||||
});
|
||||
|
||||
groups[target.id].nodes.forEach(n => {
|
||||
if (dx > 0) {
|
||||
n.vx += diff * k;
|
||||
} else {
|
||||
n.vx -= diff * k;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
simulation.force("group", groupForce);
|
||||
simulation.force("groupLayout", groupLayoutForce);
|
||||
|
||||
// Create links with arrow paths instead of lines
|
||||
const link = svg.append("g")
|
||||
.attr("class", "links")
|
||||
.selectAll("path")
|
||||
.data(data.links)
|
||||
.enter()
|
||||
.append("path")
|
||||
.attr("stroke-width", 2)
|
||||
.attr("stroke", "#999")
|
||||
.attr("marker-end", "url(#arrowhead)"); // Add the arrowhead marker
|
||||
|
||||
// Create group containers (drawn before nodes)
|
||||
const groupContainers = svg.append("g")
|
||||
.attr("class", "groups")
|
||||
.selectAll("rect")
|
||||
.data(Object.values(groups))
|
||||
.enter()
|
||||
.append("rect")
|
||||
.attr("class", "group-container")
|
||||
.attr("fill", d => d3.color(color(d.id)).copy({opacity: 0.2}));
|
||||
|
||||
// Create group links between flows
|
||||
const groupLink = svg.append("g")
|
||||
.attr("class", "group-links")
|
||||
.selectAll("path")
|
||||
.data(data.group_links || [])
|
||||
.enter()
|
||||
.append("path")
|
||||
.attr("stroke-width", 2)
|
||||
.attr("stroke", "#333")
|
||||
.attr("marker-end", "url(#group-arrowhead)");
|
||||
|
||||
// Create group link labels
|
||||
const groupLinkLabel = svg.append("g")
|
||||
.attr("class", "group-link-labels")
|
||||
.selectAll("text")
|
||||
.data(data.group_links || [])
|
||||
.enter()
|
||||
.append("text")
|
||||
.text(d => d.action)
|
||||
.attr("font-size", "11px")
|
||||
.attr("font-weight", "bold")
|
||||
.attr("fill", "#333");
|
||||
|
||||
// Create group labels
|
||||
const groupLabels = svg.append("g")
|
||||
.attr("class", "group-labels")
|
||||
.selectAll("text")
|
||||
.data(Object.values(groups))
|
||||
.enter()
|
||||
.append("text")
|
||||
.attr("class", "group-label")
|
||||
.text(d => d.name) // Now using the proper flow name
|
||||
.attr("fill", d => d3.color(color(d.id)).darker());
|
||||
|
||||
// Create link labels
|
||||
const linkLabel = svg.append("g")
|
||||
.attr("class", "link-labels")
|
||||
.selectAll("text")
|
||||
.data(data.links)
|
||||
.enter()
|
||||
.append("text")
|
||||
.text(d => d.action)
|
||||
.attr("font-size", "10px")
|
||||
.attr("fill", "#666");
|
||||
|
||||
// Create nodes
|
||||
const node = svg.append("g")
|
||||
.attr("class", "nodes")
|
||||
.selectAll("circle")
|
||||
.data(data.nodes)
|
||||
.enter()
|
||||
.append("circle")
|
||||
.attr("r", 15)
|
||||
.attr("fill", d => color(d.group))
|
||||
.call(d3.drag()
|
||||
.on("start", dragstarted)
|
||||
.on("drag", dragged)
|
||||
.on("end", dragended));
|
||||
|
||||
// Create node labels
|
||||
const nodeLabel = svg.append("g")
|
||||
.attr("class", "node-labels")
|
||||
.selectAll("text")
|
||||
.data(data.nodes)
|
||||
.enter()
|
||||
.append("text")
|
||||
.text(d => d.name)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("dy", 25);
|
||||
|
||||
// Add tooltip on hover
|
||||
node.append("title")
|
||||
.text(d => d.name);
|
||||
|
||||
// Update positions on each tick
|
||||
simulation.on("tick", () => {
|
||||
// Update links with straight lines
|
||||
link.attr("d", d => {
|
||||
return `M${d.source.x},${d.source.y} L${d.target.x},${d.target.y}`;
|
||||
});
|
||||
|
||||
// Update nodes
|
||||
node
|
||||
.attr("cx", d => d.x)
|
||||
.attr("cy", d => d.y);
|
||||
|
||||
// Update node labels
|
||||
nodeLabel
|
||||
.attr("x", d => d.x)
|
||||
.attr("y", d => d.y);
|
||||
|
||||
// Position link labels at midpoint
|
||||
linkLabel
|
||||
.attr("x", d => (d.source.x + d.target.x) / 2)
|
||||
.attr("y", d => (d.source.y + d.target.y) / 2);
|
||||
|
||||
// Update group containers
|
||||
groupContainers.each(function(d) {
|
||||
// If there are nodes in this group
|
||||
if (d.nodes.length > 0) {
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
|
||||
// Find the bounding box for all nodes in the group
|
||||
d.nodes.forEach(n => {
|
||||
minX = Math.min(minX, n.x - 30);
|
||||
minY = Math.min(minY, n.y - 30);
|
||||
maxX = Math.max(maxX, n.x + 30);
|
||||
maxY = Math.max(maxY, n.y + 40); // Extra space for labels
|
||||
});
|
||||
|
||||
// Add padding
|
||||
const padding = 20;
|
||||
minX -= padding;
|
||||
minY -= padding;
|
||||
maxX += padding;
|
||||
maxY += padding;
|
||||
|
||||
// Save group dimensions
|
||||
d.x = minX;
|
||||
d.y = minY;
|
||||
d.width = maxX - minX;
|
||||
d.height = maxY - minY;
|
||||
d.centerX = minX + d.width / 2;
|
||||
d.centerY = minY + d.height / 2;
|
||||
|
||||
// Set position and size of the group container
|
||||
d3.select(this)
|
||||
.attr("x", minX)
|
||||
.attr("y", minY)
|
||||
.attr("width", d.width)
|
||||
.attr("height", d.height);
|
||||
|
||||
// Update group label position (top-left of group)
|
||||
groupLabels.filter(g => g.id === d.id)
|
||||
.attr("x", minX + 10)
|
||||
.attr("y", minY + 20);
|
||||
}
|
||||
});
|
||||
|
||||
// Update group links between flows
|
||||
groupLink.attr("d", d => {
|
||||
const sourceGroup = groups[d.source];
|
||||
const targetGroup = groups[d.target];
|
||||
|
||||
if (!sourceGroup || !targetGroup) return "";
|
||||
|
||||
// Find intersection points with group boundaries
|
||||
// This ensures links connect to the group's border rather than its center
|
||||
|
||||
// Calculate centers of groups
|
||||
const sx = sourceGroup.centerX;
|
||||
const sy = sourceGroup.centerY;
|
||||
const tx = targetGroup.centerX;
|
||||
const ty = targetGroup.centerY;
|
||||
|
||||
// Calculate angle between centers - used to find intersection points
|
||||
const angle = Math.atan2(ty - sy, tx - sx);
|
||||
|
||||
// Calculate intersection points with source group borders
|
||||
// We cast a ray from center in the direction of the target
|
||||
let sourceX, sourceY;
|
||||
const cosA = Math.cos(angle);
|
||||
const sinA = Math.sin(angle);
|
||||
|
||||
// Check intersection with horizontal borders (top and bottom)
|
||||
const ts_top = (sourceGroup.y - sy) / sinA;
|
||||
const ts_bottom = (sourceGroup.y + sourceGroup.height - sy) / sinA;
|
||||
|
||||
// Check intersection with vertical borders (left and right)
|
||||
const ts_left = (sourceGroup.x - sx) / cosA;
|
||||
const ts_right = (sourceGroup.x + sourceGroup.width - sx) / cosA;
|
||||
|
||||
// Use the closest positive intersection (first hit with the boundary)
|
||||
let t_source = Infinity;
|
||||
if (ts_top > 0) t_source = Math.min(t_source, ts_top);
|
||||
if (ts_bottom > 0) t_source = Math.min(t_source, ts_bottom);
|
||||
if (ts_left > 0) t_source = Math.min(t_source, ts_left);
|
||||
if (ts_right > 0) t_source = Math.min(t_source, ts_right);
|
||||
|
||||
// Target group: Find intersection in the opposite direction
|
||||
// We cast a ray from target center toward the source
|
||||
let targetX, targetY;
|
||||
const oppositeAngle = angle + Math.PI;
|
||||
const cosOpp = Math.cos(oppositeAngle);
|
||||
const sinOpp = Math.sin(oppositeAngle);
|
||||
|
||||
// Check intersections for target group
|
||||
const tt_top = (targetGroup.y - ty) / sinOpp;
|
||||
const tt_bottom = (targetGroup.y + targetGroup.height - ty) / sinOpp;
|
||||
const tt_left = (targetGroup.x - tx) / cosOpp;
|
||||
const tt_right = (targetGroup.x + targetGroup.width - tx) / cosOpp;
|
||||
|
||||
// Use the closest positive intersection
|
||||
let t_target = Infinity;
|
||||
if (tt_top > 0) t_target = Math.min(t_target, tt_top);
|
||||
if (tt_bottom > 0) t_target = Math.min(t_target, tt_bottom);
|
||||
if (tt_left > 0) t_target = Math.min(t_target, tt_left);
|
||||
if (tt_right > 0) t_target = Math.min(t_target, tt_right);
|
||||
|
||||
// Calculate actual border points using parametric equation:
|
||||
// point = center + t * direction
|
||||
if (t_source !== Infinity) {
|
||||
sourceX = sx + cosA * t_source;
|
||||
sourceY = sy + sinA * t_source;
|
||||
} else {
|
||||
sourceX = sx;
|
||||
sourceY = sy;
|
||||
}
|
||||
|
||||
if (t_target !== Infinity) {
|
||||
targetX = tx + cosOpp * t_target;
|
||||
targetY = ty + sinOpp * t_target;
|
||||
} else {
|
||||
targetX = tx;
|
||||
targetY = ty;
|
||||
}
|
||||
|
||||
// Create a straight line between the border points
|
||||
return `M${sourceX},${sourceY} L${targetX},${targetY}`;
|
||||
});
|
||||
|
||||
// Update group link labels
|
||||
groupLinkLabel.attr("x", d => {
|
||||
const sourceGroup = groups[d.source];
|
||||
const targetGroup = groups[d.target];
|
||||
if (!sourceGroup || !targetGroup) return 0;
|
||||
return (sourceGroup.centerX + targetGroup.centerX) / 2;
|
||||
})
|
||||
.attr("y", d => {
|
||||
const sourceGroup = groups[d.source];
|
||||
const targetGroup = groups[d.target];
|
||||
if (!sourceGroup || !targetGroup) return 0;
|
||||
return (sourceGroup.centerY + targetGroup.centerY) / 2 - 10;
|
||||
});
|
||||
});
|
||||
|
||||
// Drag functions
|
||||
function dragstarted(event, d) {
|
||||
if (!event.active) simulation.alphaTarget(0.3).restart();
|
||||
d.fx = d.x;
|
||||
d.fy = d.y;
|
||||
}
|
||||
|
||||
function dragged(event, d) {
|
||||
d.fx = event.x;
|
||||
d.fy = event.y;
|
||||
}
|
||||
|
||||
function dragended(event, d) {
|
||||
if (!event.active) simulation.alphaTarget(0);
|
||||
d.fx = null;
|
||||
d.fy = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": 3,
|
||||
"name": "ValidatePayment",
|
||||
"group": 2
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "ProcessPayment",
|
||||
"group": 2
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"name": "PaymentConfirmation",
|
||||
"group": 2
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"name": "CheckStock",
|
||||
"group": 6
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"name": "ReserveItems",
|
||||
"group": 6
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"name": "UpdateInventory",
|
||||
"group": 6
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"name": "CreateLabel",
|
||||
"group": 10
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"name": "AssignCarrier",
|
||||
"group": 10
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"name": "SchedulePickup",
|
||||
"group": 10
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"source": 3,
|
||||
"target": 4,
|
||||
"action": "default"
|
||||
},
|
||||
{
|
||||
"source": 4,
|
||||
"target": 5,
|
||||
"action": "default"
|
||||
},
|
||||
{
|
||||
"source": 7,
|
||||
"target": 8,
|
||||
"action": "default"
|
||||
},
|
||||
{
|
||||
"source": 8,
|
||||
"target": 9,
|
||||
"action": "default"
|
||||
},
|
||||
{
|
||||
"source": 11,
|
||||
"target": 12,
|
||||
"action": "default"
|
||||
},
|
||||
{
|
||||
"source": 12,
|
||||
"target": 13,
|
||||
"action": "default"
|
||||
}
|
||||
],
|
||||
"group_links": [
|
||||
{
|
||||
"source": 2,
|
||||
"target": 6,
|
||||
"action": "default"
|
||||
},
|
||||
{
|
||||
"source": 6,
|
||||
"target": 10,
|
||||
"action": "default"
|
||||
}
|
||||
],
|
||||
"flows": {
|
||||
"1": "OrderFlow",
|
||||
"2": "AsyncFlow",
|
||||
"6": "AsyncFlow",
|
||||
"10": "AsyncFlow"
|
||||
}
|
||||
}
|
||||
|
|
@ -89,7 +89,151 @@ graph LR
|
|||
end
|
||||
```
|
||||
|
||||
## 2. Call Stack Debugging
|
||||
## 2. Interactive D3.js Visualization
|
||||
|
||||
For more complex flows, a static diagram may not be sufficient. We provide a D3.js-based interactive visualization that allows for dragging nodes, showing group boundaries for flows, and connecting flows at their boundaries.
|
||||
|
||||
### Converting Flow to JSON
|
||||
|
||||
First, we convert the PocketFlow graph to JSON format suitable for D3.js:
|
||||
|
||||
```python
|
||||
def flow_to_json(start):
|
||||
"""Convert a flow to JSON format suitable for D3.js visualization.
|
||||
|
||||
This function walks through the flow graph and builds a structure with:
|
||||
- nodes: All non-Flow nodes with their group memberships
|
||||
- links: Connections between nodes within the same group
|
||||
- group_links: Connections between different groups (for inter-flow connections)
|
||||
- flows: Flow information for group labeling
|
||||
"""
|
||||
nodes = []
|
||||
links = []
|
||||
group_links = [] # For connections between groups (Flow to Flow)
|
||||
ids = {}
|
||||
node_types = {}
|
||||
flow_nodes = {} # Keep track of flow nodes
|
||||
ctr = 1
|
||||
|
||||
def get_id(n):
|
||||
nonlocal ctr
|
||||
if n not in ids:
|
||||
ids[n] = ctr
|
||||
node_types[ctr] = type(n).__name__
|
||||
if isinstance(n, Flow):
|
||||
flow_nodes[ctr] = n # Store flow reference
|
||||
ctr += 1
|
||||
return ids[n]
|
||||
|
||||
def walk(node, parent=None, group=None, parent_group=None, action=None):
|
||||
# Traverse the flow graph recursively
|
||||
# ...implementation details...
|
||||
|
||||
# Start the traversal
|
||||
walk(start)
|
||||
|
||||
# Post-processing: Generate group links based on node connections between different groups
|
||||
node_groups = {n["id"]: n["group"] for n in nodes}
|
||||
filtered_links = []
|
||||
|
||||
for link in links:
|
||||
source_id = link["source"]
|
||||
target_id = link["target"]
|
||||
source_group = node_groups.get(source_id, 0)
|
||||
target_group = node_groups.get(target_id, 0)
|
||||
|
||||
# If source and target are in different groups and both groups are valid
|
||||
if source_group != target_group and source_group > 0 and target_group > 0:
|
||||
# Add to group links if not already there
|
||||
# Skip adding this link to filtered_links - we don't want direct node connections across groups
|
||||
else:
|
||||
# Keep links within the same group
|
||||
filtered_links.append(link)
|
||||
|
||||
return {
|
||||
"nodes": nodes,
|
||||
"links": filtered_links, # Use filtered links instead of all links
|
||||
"group_links": group_links,
|
||||
"flows": {str(k): v.__class__.__name__ for k, v in flow_nodes.items()},
|
||||
}
|
||||
```
|
||||
|
||||
### Creating the Visualization
|
||||
|
||||
Then, we generate an HTML file with D3.js visualization:
|
||||
|
||||
```python
|
||||
def create_d3_visualization(json_data, output_dir="./viz", filename="flow_viz"):
|
||||
"""Create a D3.js visualization from JSON data.
|
||||
|
||||
This generates an HTML file with an interactive visualization where:
|
||||
- Nodes are represented as circles
|
||||
- Flows are shown as dashed rectangles (groups)
|
||||
- Inter-group connections are shown as dashed lines connecting at group boundaries
|
||||
- Node and group labels are displayed
|
||||
- Nodes can be dragged to reorganize the layout
|
||||
"""
|
||||
# Create output directory
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
# Save JSON data to file
|
||||
json_path = os.path.join(output_dir, f"{filename}.json")
|
||||
with open(json_path, "w") as f:
|
||||
json.dump(json_data, f, indent=2)
|
||||
|
||||
# Generate HTML with D3.js visualization
|
||||
# ...HTML template with D3.js code...
|
||||
|
||||
# Write HTML to file
|
||||
html_path = os.path.join(output_dir, f"{filename}.html")
|
||||
with open(html_path, "w") as f:
|
||||
f.write(html_content)
|
||||
|
||||
print(f"Visualization created at {html_path}")
|
||||
return html_path
|
||||
```
|
||||
|
||||
### Convenience Function
|
||||
|
||||
A convenience function to visualize flows:
|
||||
|
||||
```python
|
||||
def visualize_flow(flow, flow_name):
|
||||
"""Helper function to visualize a flow with both mermaid and D3.js"""
|
||||
print(f"\n--- {flow_name} Mermaid Diagram ---")
|
||||
print(build_mermaid(start=flow))
|
||||
|
||||
print(f"\n--- {flow_name} D3.js Visualization ---")
|
||||
json_data = flow_to_json(flow)
|
||||
create_d3_visualization(
|
||||
json_data, filename=f"{flow_name.lower().replace(' ', '_')}"
|
||||
)
|
||||
```
|
||||
|
||||
### Usage Example
|
||||
|
||||
```python
|
||||
from visualize import visualize_flow
|
||||
|
||||
# Create a complex flow with nested subflows
|
||||
# ...flow definition...
|
||||
|
||||
# Generate visualization
|
||||
visualize_flow(data_science_flow, "Data Science Flow")
|
||||
```
|
||||
|
||||
This generates:
|
||||
1. A mermaid diagram in the console
|
||||
2. A JSON file with the flow structure
|
||||
3. An HTML file with the interactive D3.js visualization
|
||||
|
||||
The D3.js visualization offers several advantages:
|
||||
- **Interactivity**: Nodes can be dragged to reorganize the layout
|
||||
- **Group visualization**: Flows are shown as groups with their own boundaries
|
||||
- **Inter-group connections**: Links between groups connect at boundaries for cleaner visualization
|
||||
- **Action labels**: Edge labels show transition actions
|
||||
|
||||
## 3. Call Stack Debugging
|
||||
|
||||
It would be useful to print the Node call stacks for debugging. This can be achieved by inspecting the runtime call stack:
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue