505 lines
21 KiB
HTML
505 lines
21 KiB
HTML
<!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>
|