In the previous article I talked about the approach I used in building Arclens. This part we'll go over how I layed the dependency graph.
This was probably the part I didn't expect going in.
I initially thought that if I could correctly build the dependency graph, the hard part would be over. It wasn't.
A graph with a few dozen nodes is manageable. A graph with hundreds of nodes is a completely different problem....pheeeewwww...my browser crashed twice while trying this on a project.
On the CLI side, Arclens pre-computes node positions using dagre, a directed graph layout engine. It arranges nodes in a hierarchical top-to-bottom layout based on their dependency relationships. For smaller graphs (under 200 nodes), dagre works well. For larger graphs, Arclens falls back to a simpler folder-based grid layout, because dagre's computation time scales poorly:
export function computeNodeLayouts(
nodes: GraphNode[],
edges: GraphEdge[],
): Map<string, NodeLayout> {
if (nodes.length === 0) return new Map();
if (nodes.length <= DAGRE_THRESHOLD) {
return dagreLayout(nodes, edges);
}
return folderGridLayout(nodes);
}
These positions are embedded into the graph.json output so the viewer doesn't have to recompute layout from scratch every time.
The viewer is a separate React application built with React Flow. It reads the graph.json file that the CLI produces and renders it as an interactive node graph.
The architecture is a two-process setup. The CLI starts a lightweight Node.js HTTP server (node:http, no framework) that serves the viewer's static build and exposes a few API routes: /graph.json returns the analysis output, /api/snippet returns truncated source code for any file in the project, and /api/license handles entitlements. In development mode, it spawns a Vite dev server with HMR instead of serving static assets.
On the frontend, buildFlowGraph transforms the raw graph data into React Flow nodes and edges. Each graph node becomes either an atlas node (the standard component card) or a cluster node (a folder-level aggregate). Edges between the same pair of nodes get merged, so if A both imports and renders B, you see a single edge with a combined label rather than two overlapping arrows:
const mergedEdges = mergeSameDirectionEdges(graph.edges);
const initialEdges: Edge[] = mergedEdges.map((edge, i) => {
const stroke = strokeColorForEdgeTypes(edge.types);
const primaryType = primaryEdgeType(edge.types);
return {
id: `e${i}`,
source: edge.from,
target: edge.to,
type: "smoothstep",
style: { stroke, strokeWidth: compact ? 1.25 : 2 },
markerEnd: { type: "arrowclosed", color: stroke },
// ...
};
});
Layout happens in two stages. On initial load, the viewer checks whether the graph.json includes pre-computed positions from the CLI's dagre pass. If all nodes have positions, it uses those directly. If not (for example, when viewing a filtered subset or a cluster expansion), it runs dagre again client-side. For interactive re-layouts, this computation happens in a Web Worker to keep the UI responsive:
function layoutNodes(graph, nodes, edges, entryIds) {
if (nodes.length === 0) return nodes;
const embedded = applyEmbeddedLayouts(graph, nodes);
if (embedded) return embedded;
return getDagreLayoutedNodes(nodes, edges, "TB", entryIds);
}
The viewer also identifies entry point nodes (pages, layouts, route files) and pins them to rank: 0 in the dagre config so they appear at the top of the hierarchy. Coincident nodes, where dagre places isolated subgraphs at the same coordinates, get nudged apart with a spreadCoincidentPositions pass to prevent visual overlap.
The snippet preview is worth mentioning. When you click a node in the graph, the viewer fetches /api/snippet?file=<path> and renders a truncated preview of the source file. The server reads the file live from disk (or from a pre-generated sidecar if the project isn't local), caps it at 120 lines, and returns the content. This way you can inspect the actual source of any node without leaving the graph view.
Building Arclens pushed me into areas of engineering I hadn't spent much time thinking about before.
I had to work with the TypeScript compiler API through ts-morph, static analysis, dependency resolution, graph algorithms, visualization, caching, and information design.
But the biggest lesson wasn't about ts-morph or dagre or React Flow.
It was about turning complexity into something a human can understand.
A graph can be technically correct and still be almost impossible to read. Small changes to force parameters, node sizing, or edge routing can completely change whether a layout is useful or just noise.
The real challenge isn't extracting information. It's deciding which information deserves your attention.
A codebase already contains an enormous amount of information. The difficult part is transforming that information into something useful. And that is a very different problem from parsing files.
That's what makes Arclens interesting to me.
I'm still experimenting with how much architecture can be inferred statically, which relationships are worth showing, and how the graph can become useful beyond simply visualizing imports.
Arclens is open source on GitHub (leave a star if you find it useful).
If you're working on a React or TypeScript codebase, try it on your own project. I would love to know whether the way I see the architecture matches the way you see yours.