Skip to content

Commit ecf57dc

Browse files
committed
feat(calm-hub-ui): Add dynamic group resizing and floating hover panels
- Add dynamic group node resizing that recalculates bounds when children are dragged back inside (groups now shrink to fit) - Make node hover expansion use absolute positioning so it floats over other elements without expanding the parent system box - Keep base node at fixed 220px width for consistent ReactFlow layout Signed-off-by: Paul Merrison <paul.merrison@gmail.com>
1 parent 3a33526 commit ecf57dc

File tree

2 files changed

+120
-12
lines changed

2 files changed

+120
-12
lines changed

calm-hub-ui/src/visualizer/components/reactflow/ArchitectureGraph.tsx

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,15 @@ import ReactFlow, {
77
MiniMap,
88
useNodesState,
99
useEdgesState,
10+
NodeChange,
1011
} from 'reactflow';
1112
import 'reactflow/dist/style.css';
1213
import { FloatingEdge } from './FloatingEdge';
1314
import { CustomNode } from './CustomNode';
1415
import { SystemGroupNode } from './SystemGroupNode';
1516
import { THEME } from './theme';
1617
import { parseCALMData } from './utils/calmTransformer';
18+
import { GRAPH_LAYOUT } from './utils/constants';
1719
import {
1820
CalmArchitectureSchema,
1921
CalmNodeSchema,
@@ -26,8 +28,41 @@ interface ArchitectureGraphProps {
2628
onEdgeClick?: (edge: CalmRelationshipSchema) => void;
2729
}
2830

31+
/**
32+
* Calculate the minimum bounds for a group node based on its children
33+
*/
34+
function calculateGroupBounds(
35+
groupId: string,
36+
allNodes: Node[]
37+
): { width: number; height: number } | null {
38+
const children = allNodes.filter((n) => n.parentId === groupId);
39+
if (children.length === 0) {
40+
return null;
41+
}
42+
43+
const padding = GRAPH_LAYOUT.SYSTEM_NODE_PADDING;
44+
const nodeWidth = GRAPH_LAYOUT.NODE_WIDTH;
45+
const nodeHeight = GRAPH_LAYOUT.NODE_HEIGHT;
46+
47+
let maxX = -Infinity;
48+
let maxY = -Infinity;
49+
50+
children.forEach((child) => {
51+
const childRight = child.position.x + nodeWidth;
52+
const childBottom = child.position.y + nodeHeight;
53+
maxX = Math.max(maxX, childRight);
54+
maxY = Math.max(maxY, childBottom);
55+
});
56+
57+
// Add padding on the right and bottom
58+
return {
59+
width: maxX + padding,
60+
height: maxY + padding,
61+
};
62+
}
63+
2964
export const ArchitectureGraph = ({ jsonData, onNodeClick, onEdgeClick }: ArchitectureGraphProps) => {
30-
const [nodes, setNodes, onNodesChange] = useNodesState([]);
65+
const [nodes, setNodes, onNodesChangeBase] = useNodesState([]);
3166
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
3267

3368
const edgeTypes = useMemo(() => ({ custom: FloatingEdge }), []);
@@ -39,6 +74,55 @@ export const ArchitectureGraph = ({ jsonData, onNodeClick, onEdgeClick }: Archit
3974
setEdges(parsedEdges);
4075
}, [jsonData, setNodes, setEdges, onNodeClick]);
4176

77+
// Custom onNodesChange that recalculates group bounds after node movements
78+
const onNodesChange = useCallback(
79+
(changes: NodeChange[]) => {
80+
// Apply the base changes first
81+
onNodesChangeBase(changes);
82+
83+
// Check if any position changes occurred (from dragging)
84+
const hasPositionChanges = changes.some(
85+
(change) => change.type === 'position' && change.dragging === false
86+
);
87+
88+
if (hasPositionChanges) {
89+
// Recalculate group bounds after drag completes
90+
setNodes((currentNodes) => {
91+
let updated = false;
92+
93+
const newNodes = currentNodes.map((node) => {
94+
if (node.type !== 'group') return node;
95+
96+
const bounds = calculateGroupBounds(node.id, currentNodes);
97+
if (!bounds) return node;
98+
99+
const currentWidth = (node.style?.width as number) || node.width || 0;
100+
const currentHeight = (node.style?.height as number) || node.height || 0;
101+
102+
// Only update if bounds have changed
103+
if (bounds.width !== currentWidth || bounds.height !== currentHeight) {
104+
updated = true;
105+
return {
106+
...node,
107+
width: bounds.width,
108+
height: bounds.height,
109+
style: {
110+
...node.style,
111+
width: bounds.width,
112+
height: bounds.height,
113+
},
114+
};
115+
}
116+
return node;
117+
});
118+
119+
return updated ? newNodes : currentNodes;
120+
});
121+
}
122+
},
123+
[onNodesChangeBase, setNodes]
124+
);
125+
42126
const handleNodeClick = useCallback(
43127
(_event: React.MouseEvent, node: Node) => {
44128
if (onNodeClick) {

calm-hub-ui/src/visualizer/components/reactflow/CustomNode.tsx

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -113,18 +113,26 @@ export const CustomNode = ({ data }: NodeProps) => {
113113
onMouseEnter={() => setIsHovered(true)}
114114
onMouseLeave={() => setIsHovered(false)}
115115
style={{
116-
background: THEME.colors.card,
117-
border: `2px solid ${borderColor}`,
118-
borderRadius: '12px',
119-
padding: '16px',
120-
minWidth: isHovered ? '300px' : '220px',
121-
color: THEME.colors.foreground,
122-
fontSize: '14px',
123-
fontWeight: 500,
124-
boxShadow: isHovered ? THEME.shadows.lg : THEME.shadows.sm,
125-
transition: 'all 0.3s ease-in-out',
116+
// Fixed width so hover expansion doesn't affect ReactFlow layout/parent bounds
117+
width: '220px',
118+
position: 'relative',
126119
}}
127120
>
121+
{/* Base node - always visible, fixed size */}
122+
<div
123+
style={{
124+
background: THEME.colors.card,
125+
border: `2px solid ${borderColor}`,
126+
borderRadius: '12px',
127+
padding: '16px',
128+
width: '100%',
129+
color: THEME.colors.foreground,
130+
fontSize: '14px',
131+
fontWeight: 500,
132+
boxShadow: isHovered ? THEME.shadows.lg : THEME.shadows.sm,
133+
transition: 'box-shadow 0.3s ease-in-out',
134+
}}
135+
>
128136
{/* Hidden handles to satisfy React Flow; floating edge computes actual attachment */}
129137
<Handle type="source" position={Position.Right} id="source" style={{ opacity: 0 }} />
130138
<Handle type="target" position={Position.Left} id="target" style={{ opacity: 0 }} />
@@ -198,9 +206,25 @@ export const CustomNode = ({ data }: NodeProps) => {
198206
</div>
199207
)}
200208
</div>
209+
</div>
201210

211+
{/* Hover details panel - absolutely positioned to float over other elements */}
202212
{isHovered && (
203-
<div style={{ marginTop: '8px' }}>
213+
<div
214+
style={{
215+
position: 'absolute',
216+
top: '100%',
217+
left: 0,
218+
marginTop: '4px',
219+
minWidth: '300px',
220+
background: THEME.colors.card,
221+
border: `2px solid ${borderColor}`,
222+
borderRadius: '12px',
223+
padding: '16px',
224+
boxShadow: THEME.shadows.lg,
225+
zIndex: 1000,
226+
}}
227+
>
204228
<div style={{ borderTop: `1px solid ${THEME.colors.border}`, paddingTop: '8px' }}>
205229
<div style={{ fontSize: '12px', color: THEME.colors.muted, marginBottom: '4px' }}>Type:</div>
206230
<div style={{ fontSize: '12px', fontWeight: 500, color: THEME.colors.accent }}>{nodeType}</div>

0 commit comments

Comments
 (0)