Jerry Orta
← Concepts

Charts Library Architecture

  • charts
  • d3
  • angular
  • architecture

The blog post Charts as a Library, Not a Component makes the argument: one config-driven, layered chart beats a folder of one-off chart components. This note is the other half — the how. Four mechanics carry the whole design: a single component driven by config, layers that compose on a shared base, tree-shaking that pulls only the renderers you import, and a tooltip that stays smooth by sidestepping Angular's change detection.

One component, no central switch

The entire public surface is a single nge-chart element. It takes one input — a plain ChartConfig — lays out the plot area, draws the shared axes, and owns the tooltip and legend. It knows nothing about bars, lines, or scatter points.

import { createBarChartConfig } from '@nge/charts';

config = createBarChartConfig({
  data: [
    { label: 'A', value: 30 },
    { label: 'B', value: 55 },
  ],
  tooltip: { enabled: true },
});

The config splits into three parts:

  • base — margins, axis visibility and labels, tick formatting: the shared frame every layer draws inside.
  • layers[] — one entry per chart type, each carrying its own data and its own render function.
  • legend — optional.

The component never asks "what kind of chart is this?" There is no switch (type) mapping a string to a component. That absence is the whole trick, and the next two sections are its consequences.

Layers compose on a shared base

A layer is { type, data, renderer } — where renderer is a pure D3 function that draws into a shared render context. The component builds one base layout (an SVG wrapper, a bounds group, margins, and a clipped group that every layer renders into so marks never spill past the axes) and one set of scales derived from base. Then it walks the layers and hands each one that context:

for (const layer of config.layers) {
  layer.renderer(context); // context = { bounds, data, scales, dimensions, theme[type], … }
}

Because every layer shares one layout and one set of scales, they stack: bars under a line under a scatter, all on the same axes, just by appending to config.layers. A new chart type is a render function, a config, a theme slice, and a preset factory — never another Angular component wired into a registry.

The chart below is exactly that — a live one. It's a bar layer (monthly revenue) with three line layers (Target, Avg, Forecast) sharing one band x-axis and one linear y-axis. Four layers, one component, one config:

A bar layer (monthly revenue) with three line layers — Target, Avg, Forecast — composed on one shared set of axes.

Hover any bar or trend point to see the shared tooltip described further down. The bars and lines are separate render functions that never coordinate — they just draw into the same layout the component built once.

Tree-shaking falls out of the design

Look again at that loop. The registry iterates config.layers and calls layer.renderer(context) — it references the renderer through the config object, not by name. Nothing in the library holds a table like { bar: renderBarLayer, line: renderLineLayer, scatter: renderScatterLayer, … }.

That is what makes the library tree-shakeable. When a consumer writes:

import { createBarChartConfig } from '@nge/charts';

the bar preset is the only thing that references renderBarLayer, so that's the only render function the bundler keeps. renderLineLayer, renderScatterLayer, and the rest are never reachable from the import graph and drop out of the bundle entirely. A central switch would defeat this — a switch (type) that names every renderer makes all of them reachable, so importing one chart drags in all of them. Layers carrying their own renderer is not just cleaner composition; it's what keeps an app that uses one chart type from paying for sixteen.

The Angular tooltip — content by signal, position by D3

The tooltip is where the two worlds meet, and it splits the work deliberately.

A tooltip does two things on every mouse move: it shows the right content, and it sits at the right position. Content changes rarely — only when you cross from one datum to the next. Position changes on every mousemove, dozens of times a second. Routing both through Angular change detection would run a full CD pass per pointer frame.

So the component splits them:

  • Content flows through an Angular signal. A layer emits a tooltip event, the component sets tooltipContent.set(content), and Angular's template binding renders it — through the default bubble, or through a consumer-supplied template (below). One CD pass, only when the datum changes.

  • Position is written straight onto the element with D3, bypassing change detection entirely:

    // runs on every mousemove — no Angular CD pass
    select(this.tooltipElement).style('left', `${position.x}px`).style('top', `${position.y}px`);
    

The result: the tooltip tracks the cursor at 60fps because the hot path never touches Angular, while the content still gets the ergonomics of a normal Angular template.

And because content is just a template, consumers can replace it. Project a #gigaChartTooltip ng-template and it becomes the tooltip body, fed the current datum:

<nge-chart [config]="config">
  <ng-template #gigaChartTooltip let-content>
    <strong>{{ content?.label }}</strong>
    <span>{{ content?.value | currency }}</span>
  </ng-template>
</nge-chart>

No template? You get a sensible default bubble. The positioning machinery is identical either way — only the content chrome changes.

Because that body is a real Angular template, it can hold anything — including another chart. Add chromelessTooltip (which drops the default bubble) and the projected content becomes the tooltip. The stacked-bar chart below does exactly that: hover a quarter column and its tooltip is a nested nge-chart donut of that column's cost segments — no new chart type, just the stacked-bar and pie presets composed through the template slot:

Hover a quarter column — its tooltip is a nested <giga-chart> donut of that column's cost segments.

The trick that keeps it smooth is the same layering idea from earlier: the base chart keys its tooltip to the column (not the pixel), and the donut config for each column is built once and looked up by key — so the nested chart mounts and stays put while you move within a column, instead of rebuilding on every mouse move.

Theming rides a CSS-variable contract

One more piece ties it together. Every chart reads a domain-agnostic --chart-* CSS-variable contract — --chart-surface, --chart-on-surface, --chart-outline, --chart-primary and friends — with sensible light-mode defaults baked in, so a chart renders correctly with no theme applied at all. A design theme re-declares those same properties inside its own class selector, and class specificity beats :root, so theme values win without the chart code knowing a theme exists. A chart is themeable without touching its source, and every chart on a page reads as one system.

A private shadow root for dense charts

Those --chart-* tokens do one more job — they're what makes the chart's rendering strategy possible. The component itself stays in the light DOM (ViewEncapsulation.None), then hand-attaches a shadow root to the plot container and renders the D3 SVG inside it.

Why go out of the way for that? A dense chart is a lot of DOM. A scatter plot with a few hundred points is hundreds — sometimes thousands — of SVG nodes:

~360 points across two series — hundreds of SVG nodes isolated inside the chart's shadow root.

In the light DOM, every one of those nodes is subject to the app's entire global stylesheet: on each style recalculation the browser matches every app rule against every node, a cost that grows with nodes × rules. A shadow root is a clean, isolated style scope — the app's CSS stops at the boundary, so the browser has almost nothing to match against the SVG and recalc stays cheap no matter how many points you draw.

Angular can attach a shadow root for you with ViewEncapsulation.ShadowDom, but it's the wrong tool here: it encapsulates the whole component and pushes the styles it manages into the root. The hand-rolled root is deliberately bare — it carries no stylesheet of its own beyond a one-line width/height: 100% sizing rule, so there is nothing to duplicate and nothing to match.

That isolation would normally sever theming too — except CSS custom properties inherit across the shadow boundary. The host app's --chart-* tokens cascade straight into the shadow root, and the chart's D3 styles read them with var(--chart-*). The design tokens flow in; the expensive stylesheet matching stays out. It's the one seam that keeps a dense chart both isolated and themeable.

The shape an agent can extend

Everything above has a second payoff, and it isn't about runtime. Because a chart type is four small, additive pieces — a render function, a layer config, a theme slice, and a preset — and nothing edits a central switch, "add a new chart type" is a bounded, fully-specified change. That is exactly the kind of change an AI agent makes reliably.

I build and extend these charts with Claude Code, and the architecture is what makes that work — not the other way around. A folder of one-off chart components would hand an agent no template and a shared switch (type) to edit — the easiest place to break a chart that already works. The library shape leaves a fixed contract to fill in and no shared surface to disturb. The workflow is the same skills I lean on elsewhere, pointed at charts:

  • A written contract. A canonical architecture doc pins down the four pieces and the invariants that keep a layer well-behaved — a keyed D3 enter/update/exit join, every transition driven off the shared animation clock — and a per-type procedure walks one implementation end-to-end. The agent works from the same rules I would by hand.
  • A scaffolding skill. A single skill generates a new type's full Storybook set — usage, theming, and interaction stories — so it lands with verifiable, on-pattern coverage instead of a hand-rolled demo.
  • One type at a time. New types come off a queue as single, promotion-ready layers, each built to the contract and gated by the library's own lint and tests before it merges.

The design that makes a chart cheap to run is the one that makes it cheap to extend — by me, or by an agent working the same contract.

Why it holds together

Six decisions, one shape — and a dividend:

  • Config-driven — one component driven by data, so there's no per-type branch to grow.
  • Layers carry their own renderer — composition is just an array, and the bundle tree-shakes to only the renderers you import.
  • The tooltip splits content from position — content through Angular, position through D3, so it's both ergonomic and 60fps-smooth.
  • The tooltip body is a real template — it's any Angular content you project, so a tooltip can host a nested chart (hover a bar, get a donut) with no new library surface.
  • Theming rides CSS variables — charts restyle without recompiling, and every chart reads as one system.
  • A private shadow root — isolates a dense chart's hundreds of nodes for a cheap style recalc, with the --chart-* tokens inheriting through the boundary so the theme still reaches in.
  • The same shape an agent can extend — a new type is four additive pieces with no central switch to touch, so adding one is a bounded change I can hand to Claude Code against a written contract, with the library's own lint and tests as the gate.

The payoff, as the thesis post put it, is that the tenth chart costs what the second one did.