Hierarchical & Network Layouts in D3

Pick the wrong layout for your data shape and you fight the library forever — a treemap that ignores your leaf weights, a tree that overlaps at depth, a force graph that never settles.

D3 splits structured-relationship rendering into two families. d3-hierarchy turns a rooted tree of data into positioned nodes for tree, cluster, treemap, partition, and pack layouts; d3-force runs a velocity-Verlet physics simulation for graphs that have no single root. Both consume a nested data model and hand you back coordinates, and both sit inside the broader D3.js data binding and layout architecture — the layout computes positions, and the enter-update-exit pattern binds those positions to real nodes. This guide covers when to reach for each, the d3.hierarchy data model, the .sum()/.sort() lifecycle that every space-filling layout depends on, and a production tidy-tree pattern you can drop into a component.

One data tree feeding three hierarchy layouts A single nested dataset is passed through d3.hierarchy and then rendered three ways: as a tidy tree of links, as nested treemap rectangles, and as enclosed circle-pack bubbles. nested data { children: [...] } d3.hierarchy(root) tree treemap pack links preserve topology area encodes value enclosure encodes value same hierarchy object, three coordinate generators
One nested dataset becomes a hierarchy node once, then three layout generators derive different coordinates from it — links for a tree, nested rectangles for a treemap, enclosed circles for a pack.

When to use what

Every hierarchy layout consumes the same HierarchyNode, so the decision is driven by what you want the reader to perceive — topology, proportion, or containment — and by how many nodes you can afford to draw. Trees and clusters preserve parent-child links and read as an org chart or file tree; treemaps, partitions, and pack encode a value per node as area, and only make sense after .sum(). Force is the odd one out: it has no root and encodes adjacency, not descent.

Layout Encodes Data shape Needs .sum() Practical node ceiling
tree / cluster Topology, depth Rooted tree No (position only) ~1–2k before link clutter
treemap Leaf value as area Rooted tree with weights Yes ~5–10k tiles (Canvas)
partition (icicle / sunburst) Value + depth Rooted tree with weights Yes ~2–5k arcs/rects
pack Value as enclosed area Rooted tree with weights Yes ~1–2k circles
d3-force Adjacency (graph) Nodes + links, no root No ~1–3k nodes (SVG), ~10k (Canvas)

The node ceilings are about perception and frame budget, not a hard API limit. A tree will happily lay out 50k nodes, but the links become an unreadable mat and the SVG DOM stalls; past a couple of thousand marks, move to Canvas or aggregate. treemapSquarify degrades gracefully to more tiles than a tree because tiles tessellate space instead of radiating from a root. Force is the most expensive per node because each tick is O(n log n) with the Barnes-Hut approximation, so its ceiling drops fastest as you add link constraints.

The choice also comes down to a single question about your data: is there one root, or is it a mesh? d3-hierarchy requires a rooted tree — every node has exactly one parent, cycles are impossible, and the layout is deterministic because topology alone fixes the geometry. The moment a node can have two parents, or two nodes reference each other, you no longer have a tree and none of the hierarchy layouts apply; that is d3-force territory, where the “layout” is the equilibrium of a physics simulation rather than a closed-form function of the data. A practical corollary: if you have graph data that happens to be tree-shaped (a dependency graph with no shared modules, say), prefer a hierarchy layout — it is faster, stable across reloads, and far easier to read than letting a force simulation discover the tree structure by trial and error.

Tidy tree layout A small hierarchy laid out with a tidy node-link tree. rootAA1A2A3BB1B2CC1
A node-link hierarchy of the kind hierarchical & network layouts in d3 builds, positioned so parents centre over their children.

Core concepts

Everything in d3-hierarchy starts by wrapping your nested data in a HierarchyNode. d3.hierarchy(data, childrenAccessor) walks the data once and returns the root node, augmenting every node with depth (distance from root), height (distance to the deepest leaf), parent, and children. Crucially, it does not compute x/y — those come later from a layout generator. The node also carries your original datum on node.data, so you never lose the source object.

import { hierarchy, tree, type HierarchyNode, type HierarchyPointNode } from 'd3-hierarchy';

interface OrgDatum {
  name: string;
  headcount: number;      // leaf weight used by .sum()
  children?: OrgDatum[];
}

// 1. Build the hierarchy (topology only — no coordinates yet).
const root: HierarchyNode<OrgDatum> = hierarchy<OrgDatum>(data, (d) => d.children);

// 2. Aggregate a value up the tree. .sum() sets node.value = own + children's.
//    PERF: .sum() is O(n) and mutates the node in place; call it once, not per frame.
root.sum((d) => d.headcount);

// 3. Sort siblings so the layout is stable and legible (largest first).
//    Sorting BEFORE the layout is what fixes non-deterministic tile/leaf order.
root.sort((a, b) => (b.value ?? 0) - (a.value ?? 0));

// 4. Run a layout generator to assign x/y. This returns a positioned tree.
const layout = tree<OrgDatum>().nodeSize([40, 120]); // [dx, dy] avoids overlap at depth
const positioned: HierarchyPointNode<OrgDatum> = layout(root);

// A11Y: expose the tree to assistive tech via role="tree"/role="treeitem" on the bound DOM,
// mirroring depth with aria-level so a screen reader can announce structure.
positioned.descendants().forEach((n) => {
  // n.x, n.y are now populated; n.data is the original datum.
});

The lifecycle order is not optional. .sum() must run before any space-filling layout (treemap, partition, pack) because those generators read node.value to allocate area — skip it and every tile collapses to zero or NaN. .sort() must run before the layout too, because the layout freezes sibling order into geometry; sorting afterwards changes children order but not the already-computed coordinates. A useful mental model: hierarchy() builds the graph, .sum()/.sort() decorate it, and the layout generator is a pure function from that decorated tree to coordinates.

The two traversal helpers you will use constantly are root.descendants() (self plus all offspring, breadth-first) and root.links() (an array of {source, target} pairs, one per parent-child edge). You bind descendants() to node marks and links() to link marks. root.leaves() returns only nodes without children — the set treemap and pack actually draw as filled area.

Two further methods matter once you go beyond a single static render. root.each(), root.eachBefore(), and root.eachAfter() walk the tree in breadth-first, pre-order, and post-order respectively; post-order (eachAfter) is what you reach for when a computation must see a node’s children before the node itself — laying out labels that depend on subtree extent, for example. And root.copy() returns a fresh hierarchy with the same topology but detached from any layout mutation, which is the clean way to run two different layouts (say a treemap and a pack) from the same source without one clobbering the other’s coordinates. Because every layout generator writes its output back onto the node objects, sharing one decorated root across two generators overwrites the first result; either re-run the generator each time you switch view, or keep separate copies.

A subtle but important point about .sum(): it aggregates a value you supply per node and stores the running total on node.value, but it always adds a node’s own returned value to the sum of its children. If you return a non-zero value from an internal (non-leaf) node’s accessor, that value is counted on top of its descendants, which usually double-counts and distorts the areas. The idiomatic form returns the weight only for leaves — d.children ? 0 : d.weight — or relies on the data only carrying weights on leaves. If instead you want each node’s value to be a count of its descendants rather than a weighted sum, use root.count(), which sets node.value to the number of leaves under each node.

Implementation pattern

The steps below produce a tidy tree: d3.tree() with a nodeSize so nodes never overlap regardless of depth, links drawn with d3.linkHorizontal, and a transform that centres the root. The collapsible tree guide extends this exact pattern with expand/collapse.

import { hierarchy, tree } from 'd3-hierarchy';
import { linkHorizontal } from 'd3-shape';
import { select, type Selection } from 'd3-selection';

interface TreeDatum { name: string; children?: TreeDatum[]; }

function renderTidyTree(
  svg: Selection<SVGSVGElement, unknown, null, undefined>,
  data: TreeDatum,
  width: number,
): void {
  const root = hierarchy<TreeDatum>(data, (d) => d.children);
  root.sort((a, b) => a.data.name.localeCompare(b.data.name));

  // nodeSize keeps rows 24px apart and columns 160px apart at every depth.
  const layout = tree<TreeDatum>().nodeSize([24, 160]);
  const positioned = layout(root);

  const linkGen = linkHorizontal<unknown, { x: number; y: number }>()
    .x((d) => d.y) // swap x/y for a left-to-right tree
    .y((d) => d.x);

  const g = svg.append('g').attr('transform', `translate(80, ${width / 2})`);

  g.selectAll<SVGPathElement, unknown>('path.link')
    .data(positioned.links())
    .join('path')
    .attr('class', 'link')
    .attr('fill', 'none')
    .attr('stroke', '#94a3b8')
    .attr('d', (d) => linkGen(d as never));

  const node = g.selectAll<SVGGElement, typeof positioned>('g.node')
    .data(positioned.descendants())
    .join('g')
    .attr('class', 'node')
    .attr('transform', (d) => `translate(${d.y},${d.x})`)
    .attr('role', 'treeitem')                       // A11Y: expose structure
    .attr('aria-level', (d) => d.depth + 1);

  node.append('circle').attr('r', 4).attr('fill', '#2563eb');
  node.append('text')
    .attr('dy', '0.32em')
    .attr('x', (d) => (d.children ? -8 : 8))
    .attr('text-anchor', (d) => (d.children ? 'end' : 'start'))
    .attr('font-size', 12)
    .text((d) => d.data.name);
}

Performance & memory

d3.hierarchy is O(n) to build and holds one wrapper object per datum, so a 100k-node tree is ~100k allocations you keep for the component’s lifetime — cache the root and re-run only the layout when data changes, never rebuild the hierarchy every frame. Layout generators are also O(n) (tree, treemap, partition) or O(n log n) (pack’s circle packing, force’s Barnes-Hut quadtree per tick). The frame-budget killer is not the layout maths but binding thousands of SVG nodes; a treemap of 8k tiles is 8k <rect> elements that reflow on every attribute write. Past ~2k marks, render to Canvas and keep only hovered/focused marks in the DOM. d3-force is special: it mutates node.x/node.y on every tick, so it is a per-frame allocation-free hot loop, but it retains the entire node and link arrays plus a quadtree rebuilt each tick — see stabilizing a jittery force-directed graph for how to stop it churning after cooldown.

Accessibility checklist

Troubleshooting

  • Symptom: treemap tiles all have zero size. Cause: .sum() was never called, so node.value is undefined. Fix: call root.sum(d => d.weight) before running the layout.
  • Symptom: tree nodes overlap at deep levels. Cause: size() scales the whole tree into a fixed box, squeezing rows together. Fix: use nodeSize([dx, dy]) and let the container scroll — full walkthrough for tiles in fixing treemap tile overlap and padding.
  • Symptom: sibling order changes on every reload. Cause: no .sort(), so order follows insertion. Fix: sort by value or name before the layout runs.
  • Symptom: force graph vibrates forever and pins the CPU. Cause: alphaDecay too low or a re-heated simulation. Fix: tune decay and stop the simulation after cooldown.
  • Symptom: links render as straight diagonal lines with sharp corners. Cause: drawing raw M source L target paths. Fix: use linkHorizontal/linkVertical shape generators.
Force-directed layout snapshot A 22-node graph after a real force simulation reaches equilibrium.
A settled snapshot of the force simulation behind hierarchical & network layouts in d3: repulsion, link springs and centring balanced after cooldown.

Frequently Asked Questions

Do I have to call .sum() for a tree or cluster layout?

No. tree and cluster position nodes purely from topology and depth, so they never read node.value. You only need .sum() for the space-filling layouts — treemap, partition, and pack — which allocate area in proportion to each node’s aggregated value. Calling .sum() for a tree is harmless but pointless.

When should I choose d3-force over d3-hierarchy?

Use d3-force when your data is a general graph with no single root — social networks, dependency graphs, entity-relationship maps — where nodes connect arbitrarily rather than descending from a parent. Use d3-hierarchy whenever there is a clear root and every node has exactly one parent, because a deterministic layout is faster, stable, and easier to read than a physics simulation.

Why do my treemap or pack layouts need sorting?

Sorting siblings before the layout runs makes the output deterministic and legible: treemapSquarify places larger tiles first, and pack nests larger circles predictably. Without .sort(), sibling order follows data insertion order, so the same dataset can produce different arrangements across loads, and updates jump because tiles swap positions.