← ./articles

Build One D3 Hierarchy, Then Render Treemap and Sunburst Views

Treemap and sunburst views often show the same hierarchy with different geometry.

The mistake is to build two separate data pipelines: one for rectangles and one for arcs. That duplicates filtering, sorting, aggregation, and drill-down logic. Over time, the two views stop answering the same question.

Build the hierarchy once, then apply separate D3 layouts.

The symptom: two charts disagree

The bug usually appears as a product problem, not a D3 problem:

  • the treemap total differs from the sunburst total
  • a filter works in one view but not the other
  • drill-down breadcrumbs are inconsistent
  • one export includes a node the other view hides
  • bug fixes must be applied twice

If both charts represent the same tree, the tree construction should be shared.

Separate hierarchy construction from layout

Use one view-agnostic builder:

import { hierarchy, type HierarchyNode } from "d3-hierarchy";

type TreeItem = {
  name: string;
  value?: number;
  children?: TreeItem[];
};

export function buildHierarchy(data: TreeItem[]): HierarchyNode<TreeItem> {
  return hierarchy({ name: "root", children: data })
    .sum((node) => node.value ?? 0)
    .sort((a, b) => (b.value ?? 0) - (a.value ?? 0));
}

This function should contain filters, grouping, labels, and value calculation. It should not contain treemap rectangle coordinates or sunburst angles.

Apply treemap and partition independently

Each view gets its own root reference:

import { treemap } from "d3-hierarchy";

export function buildTreemap(data: TreeItem[], width: number, height: number) {
  const root = buildHierarchy(data);
  return treemap<TreeItem>()
    .size([width, height])
    .paddingInner(2)(root);
}

For a sunburst:

import { partition } from "d3-hierarchy";

export function buildSunburst(data: TreeItem[], radius: number) {
  const root = buildHierarchy(data);
  return partition<TreeItem>()
    .size([2 * Math.PI, radius])(root);
}

D3 layouts mutate layout coordinates onto the hierarchy nodes. That is why each view should call buildHierarchy and receive its own root object.

Re-find drill targets after filters change

Do not store only a mutable D3 node forever. Store a stable path or node id:

type DrillState = {
  path: string[];
};

When filters or input data change, rebuild the hierarchy and find the node again:

function findByPath(root: HierarchyNode<TreeItem>, path: string[]) {
  let current: HierarchyNode<TreeItem> | undefined = root;

  for (const name of path) {
    current = current.children?.find((child) => child.data.name === name);
    if (!current) return null;
  }

  return current;
}

Then run the view layout on the subtree or render a zoomed view from that node. This prevents stale references after filters change.

Export needs resolved styles

If your SVG uses CSS variables such as var(--color-section), exported SVG files may lose colors outside the app.

Before serializing:

  1. clone the SVG
  2. walk descendants
  3. read getComputedStyle()
  4. inline fill, stroke, and color
  5. set explicit width, height, and xmlns
  6. serialize with XMLSerializer

This matters for paid data tools because export quality is part of the product, not a bonus.

Verification checklist

Check that:

  • treemap and sunburst totals match
  • the same filters affect both views
  • the same node count appears after filtering
  • drill-down breadcrumbs survive data refresh
  • SVG export keeps colors outside the app
  • PNG export renders at a high enough scale for documents

References