Visualizing Complex Data with React Flow, Elk.js, and a Custom Radial Layout


1. Introduction: Taming the Data Beast
As software engineers, we’re constantly building and maintaining intricate systems. Think about a microservice architecture with dozens of interconnected services, a sprawling data pipeline with multiple transformation stages, or a complex dependency graph in a large codebase. While our code runs and produces data, truly understanding how these elements interoperate often feels like navigating a dense jungle with just a compass.
Traditional methods — logs, tables, and lists — provide raw data, but they rarely offer the intuitive grasp needed to understand relationships, flows, and hierarchical structures at a glance. This is precisely where the power of data visualization comes in, transforming abstract information into tangible, digestible visual narratives.
My recent journey has been all about tackling this challenge head-on. I’ve been diving deep into building powerful, interactive visualizations, and I’m excited to share my experience with two fantastic JavaScript libraries: React Flow for its incredible UI capabilities and Elk.js for its robust graph layout algorithms.
But, as you’ll see, sometimes even the best off-the-shelf tools need a little custom touch. To perfectly capture the semantic relationships in specific datasets, I developed and implemented a custom radial layout algorithm. This post will walk you through my hands-on experience, demonstrating how this bespoke approach elevates the visualization experience and transforms complex data into immediately intuitive diagrams.
2. The Power Duo: React Flow & Elk.js in Practice
Let’s start with the foundational tools that make these interactive visualizations possible.
React Flow: Your Interactive Canvas
React Flow is a declarative component library for React that simplifies the creation of node-based editors and interactive diagrams. It handles the complexities of rendering, dragging, zooming, panning, and connecting elements, providing a highly customizable and performant canvas for your data. Its component-based approach makes it incredibly natural for React developers.
Here’s a simplified look at how you might set up a basic React Flow component:
// MyFlowComponent.jsx
import React from 'react';
import ReactFlow, { MiniMap, Controls, Background } from 'reactflow';
import 'reactflow/dist/style.css'; // Don't forget the styles!
const initialNodes = [
{ id: 'node-1', position: { x: 0, y: 0 }, data: { label: 'Start Service' } },
{ id: 'node-2', position: { x: 200, y: 100 }, data: { label: 'Process Data' } },
{ id: 'node-3', position: { x: 400, y: 0 }, data: { label: 'Store Result' } },
];
const initialEdges = [
{ id: 'edge-1-2', source: 'node-1', target: 'node-2', animated: true },
{ id: 'edge-2-3', source: 'node-2', target: 'node-3' },
];
function MyBasicFlow() {
return (
<div style={{ width: '100%', height: '500px', border: '1px solid #eee' }}>
<ReactFlow
nodes={initialNodes}
edges={initialEdges}
fitView // Zooms to fit all nodes initially
>
<MiniMap /> {/* Handy navigation map */}
<Controls /> {/* Zoom, fit view controls */}
<Background variant="dots" gap={12} size={1} /> {/* Grid background */}
</ReactFlow>
</div>
);
}
export default MyBasicFlow;
Elk.js: The Layout Engine
While React Flow excels at rendering and user interaction, manually positioning hundreds or thousands of nodes in a complex graph is simply not feasible. This is where Elk.js comes in. Elk.js is a JavaScript port of the Eclipse Layout Kernel, a powerful open-source framework for graph layout. You provide it with your graph’s structure (nodes and edges), and it intelligently computes optimal x
and y
coordinates for each element, automatically arranging your diagram into a clean, readable layout.
Integrating Elk.js usually involves a function that takes your React Flow nodes and edges, transforms them into Elk’s specific graph format, calls elk.layout()
, and then maps the calculated positions back to your React Flow nodes.
// utils/getLayoutedElements.js
import ELK from 'elkjs/lib/elk.bundled.js'; // Use the bundled version for simplicity
const elk = new ELK(); // Initialize Elk instance
// Default options for ELK, you can customize these
const defaultLayoutOptions = {
'elk.algorithm': 'layered', // Common algorithms: 'layered', 'force', 'radial'
'elk.layered.spacing.nodeNodeBetweenLayers': '100',
'elk.spacing.nodeNode': '80',
};
export const getLayoutedElements = async (nodes, edges, options = {}) => {
const graph = {
id: 'root',
layoutOptions: { ...defaultLayoutOptions, ...options },
children: nodes.map(node => ({
...node,
// Elk needs explicit width/height for layout calculations
width: node.width || 150, // Example default width
height: node.height || 50, // Example default height
})),
edges: edges,
};
try {
const layoutedGraph = await elk.layout(graph);
// Map Elk's layouted positions back to React Flow nodes
const layoutedNodes = layoutedGraph.children.map(node => ({
...node,
position: { x: node.x, y: node.y },
}));
return { nodes: layoutedNodes, edges: layoutedGraph.edges };
} catch (error) {
console.error('Elk layout error:', error);
// Fallback: return original nodes/edges on error
return { nodes, edges };
}
};
You’d then use this getLayoutedElements
function within your React component, typically in a useEffect
or useLayoutEffect
hook, to trigger the layout whenever your graph data changes.
A simple graph laid out by Elk.js using its ‘layered’ algorithm. Notice the automatic, clean arrangement.
3. The Challenge: When Standard Layouts Aren’t Enough
While the combination of React Flow and Elk.js is incredibly powerful, and Elk.js offers a variety of general-purpose layout algorithms (layered
, force
, radial
, etc.), I encountered specific visualization problems where these standard layouts didn't quite capture the semantic meaning or desired visual hierarchy of my data.
Consider a scenario where you’re visualizing a central entity and its radiating relationships. This could be:
A core service in a microservice architecture and its direct and indirect dependencies.
A key user in a social network and their first, second, and third-degree connections.
A central concept in a knowledge graph with its related topics.
A traditional layered
layout might spread these nodes out horizontally or vertically, losing the immediate sense of 'centrality' and 'connectedness around a core'. Even Elk.js's built-in radial
algorithm, while a good start, often required extensive fine-tuning or didn't provide the precise control over angular distribution and layer spacing that I needed for optimal readability and a perfect representation of the underlying data's structure.
I wanted a visualization where a central node sits at the core, and its direct relationships radiate outwards in concentric circles, with subsequent layers extending further. More importantly, I needed to ensure that subtrees or clusters of related nodes stayed together, and that their angular space was proportional to their ‘weight’ or number of descendants, making the visualization immediately intuitive.
While functional, a default radial layout from Elk.js might not always perfectly cluster related sub-graphs or align elements to convey specific domain semantics.
4. Implementing My Custom Radial Layout Algorithm
To address these specific requirements, I developed a custom radial layout algorithm. The core idea was to take the raw graph data, identify a central node, and then programmatically assign each node a depth
(for its concentric circle) and an angle
(for its position on that circle).
My algorithm works in three main phases:
Phase 1: Identifying the Core and Layers (BFS for Depth)
First, we need to establish the ‘center’ of our radial graph. For this example, let’s assume you provide a centerNodeId
. From this central node, we perform a Breadth-First Search (BFS) traversal. This allows us to calculate the depth
(or layer
) for every other reachable node, representing its distance from the center. Nodes directly connected to the center are depth 1, their children depth 2, and so on. This immediately gives us our concentric rings.
// utils/customRadialLayout.js - Part 1: BFS for Depth
export const applyCustomRadialLayout = (nodes, edges, centerNodeId, config = {}) => {
const { radiusIncrement = 200, nodeWidth = 150, nodeHeight = 50 } = config;
// Create a map for easy node access and to store layout properties
const nodeMap = new Map(nodes.map(node => [node.id, { ...node, depth: -1, angle: 0, subtreeSize: 1 }]));
// Build an adjacency list for graph traversal
const adjList = new Map();
nodes.forEach(node => adjList.set(node.id, []));
edges.forEach(edge => {
// For radial layout, we often treat edges as undirected for BFS
adjList.get(edge.source)?.push(edge.target);
adjList.get(edge.target)?.push(edge.source);
});
// --- BFS to determine depth (layer) of each node ---
const queue = [{ id: centerNodeId, depth: 0 }];
const centerNode = nodeMap.get(centerNodeId);
if (!centerNode) {
console.warn("Center node not found. Using default React Flow positions.");
return { nodes, edges }; // Return original if center not found
}
centerNode.depth = 0;
centerNode.isRoot = true; // Mark the root for later specific handling
let head = 0;
while (head < queue.length) {
const { id, depth } = queue[head++];
const neighbors = adjList.get(id) || [];
for (const neighborId of neighbors) {
const neighborNode = nodeMap.get(neighborId);
if (neighborNode && neighborNode.depth === -1) { // If not visited
neighborNode.depth = depth + 1;
queue.push({ id: neighborId, depth: depth + 1 });
}
}
}
// Now, all reachable nodes have a 'depth' property
// ... continue to Phase 2
};
Phase 2: Angular Distribution within Layers (Recursive Traversal)
This is where the ‘custom’ part truly comes alive and differentiates it from a generic radial layout. Instead of just evenly distributing nodes around each circle, I wanted to ensure that subtrees or clusters of related nodes stayed together, and that their angular space was proportional to their ‘weight’ or number of descendants.
To achieve this, after assigning depths, I perform a recursive angular assignment starting from the center. For each child of a node, I calculate its angular ‘slice’ based on the number of descendants it has (its subtreeSize
). Children with more descendants get a wider arc. This ensures that even in dense graphs, related nodes don't get splayed across the entire circle, maintaining their visual proximity and readability.
// utils/customRadialLayout.js - Part 2: Angular Assignment
// (This is a simplified conceptual representation. A full robust implementation
// would handle disconnected subgraphs and more complex weighting.)
// Helper to get children for angular assignment (nodes with depth = parent.depth + 1)
const getChildrenOfNode = (nodeId, nodeMap, originalEdges) => {
const node = nodeMap.get(nodeId);
if (!node) return [];
return originalEdges
.filter(edge => edge.source === nodeId || edge.target === nodeId)
.map(edge => (edge.source === nodeId ? edge.target : edge.source))
.filter(childId => {
const childNode = nodeMap.get(childId);
// Ensure the 'child' is one depth level deeper, forming a hierarchy from root
return childNode && childNode.depth === node.depth + 1;
});
};
// Recursive function to calculate subtree size and assign initial angles
const assignAnglesAndSubtreeSize = (nodeId, startAngle, endAngle, nodeMap, originalEdges) => {
const node = nodeMap.get(nodeId);
if (!node) return 0;
const children = getChildrenOfNode(nodeId, nodeMap, originalEdges);
node.childrenCount = children.length; // Store for potential use
if (children.length === 0) {
// Leaf node: it gets its "share" of angle, or a minimum.
// For simplicity here, we'll assign its angle later based on its position in the segment.
node.subtreeSize = 1; // It counts as 1 for its subtree size
return 1;
}
let currentAngle = startAngle;
let totalChildrenSubtreeSize = 0;
const sortedChildren = children.sort(); // Optional: stable sort for consistent layout
for (const childId of sortedChildren) {
// Recursively calculate subtree size for children
const childSubtreeSize = assignAnglesAndSubtreeSize(childId, 0, 0, nodeMap, originalEdges); // Angles passed will be ignored here, just for subtree calculation
nodeMap.get(childId).subtreeSize = childSubtreeSize;
totalChildrenSubtreeSize += childSubtreeSize;
}
node.subtreeSize = totalChildrenSubtreeSize + 1; // Add self to subtree size
// Now, assign angles based on calculated subtree sizes for this node's children
for (const childId of sortedChildren) {
const childNode = nodeMap.get(childId);
const angleSpan = (endAngle - startAngle) * (childNode.subtreeSize / totalChildrenSubtreeSize);
childNode.assignedStartAngle = currentAngle;
childNode.assignedEndAngle = currentAngle + angleSpan;
currentAngle += angleSpan;
}
return node.subtreeSize;
};
// Initial call (after BFS):
// assignAnglesAndSubtreeSize(centerNodeId, 0, 2 * Math.PI, nodeMap, edges);
// At this point, each node has subtreeSize, and non-root children have assignedStartAngle/EndAngle
// Final angular position for each node within its segment
Array.from(nodeMap.values()).forEach(node => {
if (node.isRoot) {
node.angle = 0; // Center node at 0 radians (e.g., to the right)
} else {
// Simple approach: position node at the middle of its assigned angular span
node.angle = (node.assignedStartAngle + node.assignedEndAngle) / 2;
}
});
Phase 3: Cartesian Coordinate Calculation
Finally, once each node has a depth
(determining its radius) and an angle
(determining its position on that circle), we can convert these polar coordinates into Cartesian x
and y
coordinates. These are the positions that React Flow expects.
The radius for each layer is simply depth * radiusIncrement
. A bit of trigonometry, and we have our final positions. For better visual appeal, I'm also adjusting the targetPosition
and sourcePosition
for edges to make them connect cleanly to the nodes in a radial layout.
// utils/customRadialLayout.js - Part 3: Polar to Cartesian
// ... (after depth and angle calculation in previous steps)
const finalLayoutedNodes = Array.from(nodeMap.values()).map(node => {
// Center the entire layout around (0,0) or a desired point.
// For React Flow, often starting around 0,0 is fine, then fitView handles it.
const radius = node.depth * radiusIncrement;
const x = radius * Math.cos(node.angle);
const y = radius * Math.sin(node.angle);
return {
...node,
position: { x, y },
// Adjust target/source handles for cleaner radial edge rendering
targetPosition: 'top', // Or 'center', 'left', 'right' based on visual preference
sourcePosition: 'bottom', // Or 'center'
// Ensure width and height are available for React Flow
width: node.width || nodeWidth,
height: node.height || nodeHeight,
};
});
// Update the original edges with potential new source/target handles if needed
const finalLayoutedEdges = edges.map(edge => ({
...edge,
// If you need custom handles based on radial direction, add logic here
}));
return { nodes: finalLayoutedNodes, edges: finalLayoutedEdges };
};
This applyCustomRadialLayout
function can then be used in your main React component in a useEffect
hook, just like the getLayoutedElements
from Elk.js.
A visualization using the custom radial layout. Notice how related nodes form tighter clusters, and the hierarchy radiates clearly from the center.
5. Lessons Learned & Best Practices
Developing and integrating this custom algorithm taught me several crucial lessons that I hope will benefit your own data visualization endeavors:
Semantic Mapping is Paramount: Don’t just lay out a graph based on generic algorithms. Understand the meaning and intent behind your data. Often, custom data properties (like the depth
and subtreeSize
here) are essential guides for a layout that truly resonates with your domain.
Iterative Refinement is Key: Layout algorithms are rarely perfect on the first try. Start with a basic idea, implement it, visualize the results, identify shortcomings, and refine. It’s an iterative process of trial and error.
Performance Considerations: For very large graphs (hundreds or thousands of nodes), the efficiency of your layout algorithm becomes critical. Consider optimizing your graph traversal algorithms, leveraging Web Workers
to offload computation from the main thread, and memoizing your React Flow components to prevent unnecessary re-renders.
React Flow’s Flexibility: React Flow’s strength lies in its unopinionated nature regarding node positioning. It’s incredibly easy to feed it the x
and y
coordinates generated by any layout algorithm, custom or third-party, making it a versatile rendering layer.
When to Go Custom: You should consider developing a custom layout when:
Generic algorithms obscure critical semantic meaning or hierarchical relationships.
You have very specific, domain-driven display requirements that aren’t met by existing solutions.
You need absolute, granular control over node placement based on attributes beyond simple graph connectivity.
6. Conclusion & Future Work
In conclusion, the combination of React Flow for its interactive rendering capabilities and Elk.js for its powerful general-purpose graph layout provides an excellent foundation for building sophisticated data visualizations. However, as demonstrated by the custom radial layout, the true power often lies in understanding your specific data and, when necessary, extending these tools with bespoke algorithmic approaches.
This strategy transforms raw, confusing data into genuinely insightful, intuitive, and actionable visual experiences. For the future, I’m excited to explore:
Dynamic Center Selection: Allowing users to interactively select any node as the new center of the radial layout.
Animated Transitions: Smoothly animating the layout changes when the data or layout configuration updates.
Integration with Real-time Data: Connecting these visualizations to live data streams for real-time monitoring and anomaly detection.
Thank you for reading!
I hope this deep dive into custom data visualization with React Flow, Elk.js, and a custom radial layout has been insightful. I encourage you to experiment with these powerful tools in your own projects.
Feel free to leave comments, questions, or share your own data visualization experiences below!
Subscribe to my newsletter
Read articles from Daichi Toyoda directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Daichi Toyoda
Daichi Toyoda
I'm Daichi — a full-stack AI engineer passionate about building intelligent software and e-commerce systems. I specialize in JavaScript, TypeScript, Node.js, and cutting-edge frameworks like MedusaJS, Next.js, and LangChain. I’ve deployed production-grade AI apps (like eye contact correction and crypto prediction tools), and I’m currently focused on building scalable SaaS platforms with clean architecture and real-time data pipelines. My goal is to help global clients build smarter digital products — fast, efficient, and user-first. Topics I write about: AI development, backend architecture, e-commerce, Next.js, automation, and engineering tips learned from real-world projects. Let’s connect if you're into AI, modern web stacks, or building something meaningful.