Skip to content

Commit bd3d7c5

Browse files
committed
feat: GraphVisualizer
- enable panOnScroll only when scrolling is needed - update Nodes type - update calculateNodePositions to account for multiple root nodes - DropdownNode - update menuPortalTarget
1 parent ac892cc commit bd3d7c5

File tree

5 files changed

+67
-47
lines changed

5 files changed

+67
-47
lines changed

src/Shared/Components/GraphVisualizer/GraphVisualizer.tsx

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useMemo, useRef, useState } from 'react'
1+
import { useEffect, useMemo, useRef, useState } from 'react'
22
import {
33
applyEdgeChanges,
44
applyNodeChanges,
@@ -14,7 +14,7 @@ import {
1414
import '@xyflow/react/dist/style.css'
1515

1616
import { DEFAULT_VIEWPORT, NODE_TYPES, PADDING_X, PADDING_Y } from './constants'
17-
import { GraphVisualizerBaseNode, GraphVisualizerProps } from './types'
17+
import { GraphVisualizerExtendedNode, GraphVisualizerProps } from './types'
1818
import { processEdges, processNodes } from './utils'
1919

2020
import './styles.scss'
@@ -27,9 +27,11 @@ export const GraphVisualizer = ({
2727
}: GraphVisualizerProps) => {
2828
// REFS
2929
const reactFlowInstanceRef = useRef<ReactFlowInstance>()
30+
const graphVisualizerRef = useRef<HTMLDivElement>()
3031

3132
// STATES
3233
const [viewport, setViewport] = useState<Viewport>()
34+
const [panOnScroll, setPanOnScroll] = useState(false)
3335

3436
// MEMOS
3537
const nodes = useMemo(() => processNodes(initialNodes, initialEdges), [initialNodes])
@@ -59,8 +61,28 @@ export const GraphVisualizer = ({
5961
return 0
6062
}, [reactFlowInstanceRef.current, nodes])
6163

64+
// Enable `panOnScroll` if the total node width exceeds the available width.
65+
// When `panOnScroll` is true, it prevents browser scrolling while interacting with the React Flow graph
66+
// So we are disabling `panOnScroll` when no scrolling is needed, so as to browser scrolling works.
67+
useEffect(() => {
68+
if (!graphVisualizerRef.current || !reactFlowInstanceRef.current) return () => {}
69+
70+
const observer = new ResizeObserver((entries) => {
71+
entries.forEach((entry) => {
72+
const { width } = entry.contentRect
73+
setPanOnScroll(reactFlowInstanceRef.current.getNodesBounds(nodes).width + PADDING_X * 2 > width)
74+
})
75+
})
76+
77+
observer.observe(graphVisualizerRef.current)
78+
79+
return () => {
80+
observer.disconnect()
81+
}
82+
}, [reactFlowInstanceRef.current])
83+
6284
// METHODS
63-
const onNodesChange: OnNodesChange<GraphVisualizerBaseNode> = (changes) => {
85+
const onNodesChange: OnNodesChange<GraphVisualizerExtendedNode> = (changes) => {
6486
setNodes((nds) =>
6587
applyNodeChanges(changes, processNodes(nds, edges)).map((node) => {
6688
const _node = node
@@ -81,23 +103,25 @@ export const GraphVisualizer = ({
81103
}
82104

83105
const onViewportChange = (updatedViewport: Viewport) => {
106+
const bounds = reactFlowInstanceRef.current.getNodesBounds(nodes)
84107
const normalizedViewport = updatedViewport
85108
normalizedViewport.x = Math.min(updatedViewport.x, DEFAULT_VIEWPORT.x)
109+
normalizedViewport.y = Math.abs(bounds.y - PADDING_Y)
86110
setViewport(normalizedViewport)
87111
}
88112

89113
const onInit = async (reactFlowInstance: ReactFlowInstance) => {
90114
reactFlowInstanceRef.current = reactFlowInstance
91-
const bounds = reactFlowInstance.getNodesBounds(nodes)
92-
await reactFlowInstance.setViewport({
93-
...DEFAULT_VIEWPORT,
94-
y: Math.abs(bounds.y - PADDING_Y),
95-
})
115+
await reactFlowInstance.setViewport(DEFAULT_VIEWPORT)
96116
}
97117

98118
return (
99119
<ReactFlowProvider>
100-
<div className="graph-visualizer" style={{ height: containerHeight ? `${containerHeight}px` : '100%' }}>
120+
<div
121+
ref={graphVisualizerRef}
122+
className="graph-visualizer"
123+
style={{ height: containerHeight ? `${containerHeight}px` : '100%' }}
124+
>
101125
<ReactFlow
102126
className="border__secondary br-8"
103127
nodes={nodes}
@@ -108,8 +132,9 @@ export const GraphVisualizer = ({
108132
translateExtent={translateExtent}
109133
viewport={viewport}
110134
onViewportChange={onViewportChange}
111-
panOnScroll
135+
panOnScroll={panOnScroll}
112136
panOnScrollMode={PanOnScrollMode.Horizontal}
137+
preventScrolling={panOnScroll}
113138
panOnDrag={false}
114139
zoomOnDoubleClick={false}
115140
zoomOnPinch={false}

src/Shared/Components/GraphVisualizer/components/DropdownNode.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export const DropdownNode = ({ id, data, isConnectable }: NodeProps<DropdownNode
1717
{...data}
1818
classNamePrefix="graph-visualizer-dropdown-node"
1919
variant={SelectPickerVariantType.BORDER_LESS}
20-
menuPosition="absolute"
20+
menuPortalTarget={document.querySelector('.graph-visualizer')}
2121
menuSize={ComponentSizeType.xs}
2222
fullWidth
2323
/>

src/Shared/Components/GraphVisualizer/components/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export type TextNodeProps = Node<
2121
export type DropdownNodeProps = Node<
2222
Omit<
2323
SelectPickerProps<string | number, false>,
24-
'variant' | 'fullWidth' | 'classNamePrefix' | 'menuPosition' | 'menuSize'
24+
'variant' | 'fullWidth' | 'classNamePrefix' | 'menuPosition' | 'menuSize' | 'menuPortalTarget'
2525
>,
2626
'dropdownNode'
2727
>

src/Shared/Components/GraphVisualizer/types.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import { Dispatch, SetStateAction } from 'react'
2-
import { BuiltInNode, Edge } from '@xyflow/react'
2+
import { Edge } from '@xyflow/react'
33

44
import { DropdownNodeProps, IconNodeProps, TextNodeProps } from './components'
55

6-
export type GraphVisualizerBaseNode = IconNodeProps | TextNodeProps | DropdownNodeProps | BuiltInNode
6+
export type GraphVisualizerExtendedNode = IconNodeProps | TextNodeProps | DropdownNodeProps
77

8-
export type GraphVisualizerNode = Omit<GraphVisualizerBaseNode, 'position'>
8+
export type GraphVisualizerNode =
9+
| Omit<IconNodeProps, 'position'>
10+
| Omit<TextNodeProps, 'position'>
11+
| Omit<DropdownNodeProps, 'position'>
912

1013
export type GraphVisualizerEdge = Omit<Edge, 'type'>
1114

src/Shared/Components/GraphVisualizer/utils.ts

Lines changed: 24 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Edge, MarkerType } from '@xyflow/react'
22

33
import { NODE_GAP_X, NODE_GAP_Y, NODE_HEIGHT_MAP, NODE_WIDTH_MAP } from './constants'
4-
import { GraphVisualizerBaseNode, GraphVisualizerNode, GraphVisualizerProps } from './types'
4+
import { GraphVisualizerExtendedNode, GraphVisualizerNode, GraphVisualizerProps } from './types'
55

66
/**
77
* Processes edges by assigning a default type and customizing the marker (arrow style).
@@ -29,22 +29,15 @@ export const processEdges = (edges: GraphVisualizerProps['edges']): Edge[] =>
2929
* @param edges - List of all edges representing parent-child relationships.
3030
* @returns The root node's ID, or `null` if no root is found.
3131
*/
32-
const findRootNode = (nodes: GraphVisualizerProps['nodes'], edges: GraphVisualizerProps['edges']): string | null => {
32+
const findRootNodes = (nodes: GraphVisualizerProps['nodes'], edges: GraphVisualizerProps['edges']) => {
3333
// Create a set of all node IDs
3434
const nodeIds = new Set(nodes.map((node) => node.id))
3535

3636
// Create a set of all child node IDs (targets in edges)
3737
const childIds = new Set(edges.map((edge) => edge.target))
3838

39-
// The root node is the one that is in nodeIds but not in childIds
40-
const rootNodeId = Array.from(nodeIds).find((nodeId) => !childIds.has(nodeId))
41-
42-
if (rootNodeId) {
43-
return rootNodeId
44-
}
45-
46-
// If no root node is found, return null (could indicate a cycle or disconnected nodes)
47-
return null
39+
// Find all nodes that are NOT a child of any other node (i.e., root nodes)
40+
return Array.from(nodeIds).filter((nodeId) => !childIds.has(nodeId))
4841
}
4942

5043
/**
@@ -56,7 +49,7 @@ const findRootNode = (nodes: GraphVisualizerProps['nodes'], edges: GraphVisualiz
5649
* @param childrenMap - Map of node ID → child nodes.
5750
* @param nodeMap - Map of node ID → node data.
5851
*/
59-
const placeNodes = (
52+
const placeNode = (
6053
nodeId: string,
6154
x: number,
6255
y: number,
@@ -88,19 +81,13 @@ const placeNodes = (
8881
// Determine x-position for child nodes
8982
const childX = x + nodeWidth + NODE_GAP_X
9083

91-
// Get height values for each child node
92-
const childHeights = children.map((id) => NODE_HEIGHT_MAP[nodeMap.get(id).type])
93-
94-
// Calculate total height required for children (with spacing)
95-
const totalHeight = childHeights.reduce((sum, h) => sum + h + NODE_GAP_Y, -NODE_GAP_Y)
96-
97-
// Start positioning children from the topmost position
98-
let startY = y - totalHeight / 2
99-
100-
children.forEach((child, index) => {
84+
// Start placing children **below** the parent
85+
let startY = y
86+
children.forEach((child) => {
87+
const childHeight = NODE_HEIGHT_MAP[nodeMap.get(child).type]
10188
// Position each child at the calculated coordinates
102-
placeNodes(child, childX, startY + childHeights[index] / 2, updatedPositions, childrenMap, nodeMap)
103-
startY += childHeights[index] + NODE_GAP_Y // Move Y down for next sibling
89+
placeNode(child, childX, startY, updatedPositions, childrenMap, nodeMap)
90+
startY += childHeight + NODE_GAP_Y // Move the next sibling below
10491
})
10592
}
10693

@@ -116,14 +103,19 @@ const calculateNodePositions = (nodes: GraphVisualizerProps['nodes'], edges: Gra
116103
nodes.forEach((node) => childrenMap.set(node.id, []))
117104
edges.forEach((edge) => childrenMap.get(edge.source).push(edge.target))
118105

119-
// Identify the root node (the node that is never a target in edges)
120-
const rootNode = findRootNode(nodes, edges)
121-
if (!rootNode) {
122-
throw new Error('Either cyclic or disconnected nodes are present!')
106+
// Identify all the root nodes (the nodes that are never a target in edges)
107+
const rootNodes = findRootNodes(nodes, edges)
108+
if (!rootNodes.length) {
109+
return {}
123110
}
124111

125-
// Start recursive positioning from the root node
126-
placeNodes(rootNode, 0, 0, positions, childrenMap, nodeMap)
112+
// Place multiple root nodes vertically spaced at x = 0
113+
let startY = 0
114+
rootNodes.forEach((rootId) => {
115+
const nodeHeight = NODE_HEIGHT_MAP[nodeMap.get(rootId).type]
116+
placeNode(rootId, 0, startY, positions, childrenMap, nodeMap)
117+
startY += nodeHeight + NODE_GAP_Y // Move next root node downward
118+
})
127119

128120
return positions
129121
}
@@ -138,7 +130,7 @@ const calculateNodePositions = (nodes: GraphVisualizerProps['nodes'], edges: Gra
138130
export const processNodes = (
139131
nodes: GraphVisualizerProps['nodes'],
140132
edges: GraphVisualizerProps['edges'],
141-
): GraphVisualizerBaseNode[] => {
133+
): GraphVisualizerExtendedNode[] => {
142134
// Compute node positions based on hierarchy
143135
const positions = calculateNodePositions(nodes, edges)
144136

@@ -149,6 +141,6 @@ export const processNodes = (
149141
selectable: node.type === 'dropdownNode',
150142
// Assign computed position; default to (0,0) if not found (shouldn't happen in a valid tree)
151143
position: positions[node.id] ?? { x: 0, y: 0 },
152-
}) as GraphVisualizerBaseNode,
144+
}) as GraphVisualizerExtendedNode,
153145
)
154146
}

0 commit comments

Comments
 (0)