Records the decision to use D3 + Observable Plot, wrapped in a new `libs/shared/charts/` lib, as the chart toolkit shared by `portal-shell` and `portal-admin`. Implementation lands as a separate chantier (`pnpm add` + `libs/shared/charts/` foundations + 3 starter components + audit-page integration). The user's original ask cited D3 directly. The ADR honours that preference but pairs D3 with Observable Plot — the same team's higher-level layer (Mike Bostock / Observable Inc.). Plot turns "standard charts" (bar / donut / line / stacked-bar / scatter) into ~50 LOC per type instead of the ~250 LOC each that raw D3 would take. The shared lib's component contract stays uniform whether the underlying technique is Plot or raw D3 — `<lib-bar-chart>`, `<lib-heatmap>`, all expose the same `[data] / [caption] / [description] / [ariaLabel] / [colorScheme]` signal-based shape. Accessibility is enforced at the lib level (not bolted on per chart): SVG `<title>` + `<desc>`, `<details>`-based tabular fallback, colour-blind-safe palettes (Viridis / Cividis for sequential, ColorBrewer Set2 for categorical), AA-contrast text, `prefers-reduced-motion` gating. Six commitments, each unit-tested so a sloppy contributor can't drop one silently. Bundle impact (~65 KB gzip for the v1 vocabulary loaded on a chart-bearing lazy chunk) stays well under ADR-0017's 100 KB lazy- chunk cap via per-`d3-*` module imports. Considered + rejected: * D3 alone — 250 LOC per chart × 4-5 charts × consistent a11y posture across them all = sustained code investment before the first dashboard ships. * Apache ECharts — 600 KB minified + canvas rendering with an `aria` plugin afterthought + JSON-config idiom is the furthest from the Angular-Signals direction the workspace runs on. * Chart.js — narrower vocabulary than Plot, canvas-rendered with weaker out-of-the-box a11y, dark-mode integration brittle. CLAUDE.md and `docs/decisions/README.md` updated in the same change. ADR-0017 (perf budgets) and ADR-0016 (a11y baseline) are referenced as the binding constraints; this ADR doesn't supersede either, it operationalises both for the chart surface.
14 KiB
status, date, decision-makers, tags
| status | date | decision-makers | tags | |||
|---|---|---|---|---|---|---|
| accepted | 2026-05-16 | R&D Lead |
|
Charts and dashboards — D3 + Observable Plot wrapped in libs/shared/charts
Context and Problem Statement
The first dashboards land soon — the audit-log page wants charts for outcome breakdown and daily volume, more business modules will follow. Both portal-shell and portal-admin will consume the same chart vocabulary (bar, donut, line, stacked-bar, plot/scatter…) and we need:
- a stable, recognized toolkit per CLAUDE.md §"Project rules";
- charts that meet ADR-0016's WCAG 2.2 AA baseline + targeted AAA — i.e. screen-reader-accessible, keyboard-navigable, colour-blind-safe, dark-mode aware;
- a single shared lib
libs/shared/charts/so each chart type is written once and consumed by both SPAs.
The original ask cited D3.js directly. D3 is the most recognized JS visualization toolkit, has been at 7.x for years, MIT-licensed, maintained by a stable Observable Inc. team. It is the primitive for data-viz on the web — but it is a toolkit, not a chart library. Producing a polished bar / donut / line chart on D3 alone requires hand-coding ~200-300 LOC per chart type (scales, axes, ticks, tooltips, responsive sizing, animations, a11y). Multiplied by 4-5 chart types in v1, the lib becomes a sustained investment before the first dashboard ships.
Decision Drivers
- Recognition + maturity. Per the project's tech bar, pre-1.0 dependencies and one-maintainer projects are rejected.
- Ergonomics. Adding a new chart type to the lib should be ~50 LOC, not 250. The lib's consumers should write
<lib-bar-chart [data]="x" [xKey]="day" [yKey]="count" />, not a custom SVG-rendering recipe. - Accessibility — baked in, not bolted on. Per ADR-0016, every chart in the lib ships with: SVG
<title>+<desc>(read as alt-text by screen readers), a tabular-data fallback under<details>for non-visual users, colour-blind-safe palettes (Viridis / Cividis for quantitative ordering, ColorBrewer Set2 for categorical), AA-contrast text on axes/labels (≥ 4.5:1), keyboard-reachable focus on interactive elements (tooltips, legend toggles). - Bundle budget. Per ADR-0017, lazy chunks cap at 100 KB gzip. The chart lib must tree-shake cleanly so a page using only the bar chart doesn't pay for the donut / stacked-bar code.
- Escape hatch. Sooner or later a bespoke visualization will land that no "standard chart" library covers (sankey of session-flow, heatmap of audit density, dependency graph). The choice must allow that without re-platforming.
- Angular signals + zoneless ergonomics. Wrappers built on the chosen primitive should integrate cleanly with Signals + OnPush, not import a parallel reactivity model that fights the host app.
- i18n posture. Per ADR-0019, axis labels and legends are caller-supplied strings — the lib stays English-only inside and lets each consumer pass localised strings in.
Considered Options
- D3 + Observable Plot wrapped in
libs/shared/charts(chosen). - D3 alone, every chart hand-written on top.
- Apache ECharts — feature-complete chart library, JSON-config driven.
- Chart.js — canvas-based, mature, narrower scope than ECharts.
Decision Outcome
Chosen: D3 + Observable Plot, wrapped in a new Nx lib libs/shared/charts/.
The combination plays each tool to its strength:
- D3 stays available as the lower-level primitive for custom visualisations. Tree-shakable per-module imports (
d3-scale,d3-shape,d3-array,d3-axis,d3-selection) so the bundle only carries what each chart needs. - Observable Plot (
@observablehq/plot) is the same team's higher-level layer built on top of D3. Plot reduces "standard chart" code from ~250 LOC to ~50 LOC per type, follows the grammar-of-graphics shape (marks + scales + facets) and stays declarative. It hit 1.0 in 2024, is MIT-licensed, maintained by Observable Inc. (the D3 creators), and is used internally by the same team that maintains D3. libs/shared/charts/is the Angular adaptation layer. One component per chart type (<lib-bar-chart>,<lib-donut-chart>,<lib-line-chart>,<lib-stacked-bar-chart>, …), each wrapping Plot'sPlot.plot({…})call and adding the a11y / dark-mode / responsive contract this ADR enforces (see below). Custom-viz components written in raw D3 live in the same lib so consumers see a uniform<lib-…>surface regardless of the underlying technique.
Lib layout
libs/shared/charts/
├── src/
│ ├── lib/
│ │ ├── bar-chart/ (Plot-backed)
│ │ ├── donut-chart/ (Plot-backed)
│ │ ├── line-chart/ (Plot-backed)
│ │ ├── stacked-bar-chart/ (Plot-backed)
│ │ └── _internal/ (a11y helpers, palette, theme integration)
│ └── index.ts
├── project.json
└── tsconfig.lib.json
_internal/ carries the cross-component plumbing: the tabular-fallback renderer, the dark-mode-aware palette resolver, the SVG <title> + <desc> builders. Single source of truth so every chart inherits the same a11y contract.
Component API contract
Every chart component honours the same minimal input shape:
[data]: readonly T[]; // raw rows
[caption]: string; // visible caption + SVG <title>
[description]: string; // SVG <desc>, screen-reader long form
[ariaLabel]: string; // outer <figure>'s aria-label
[colorScheme]?: 'sequential' | 'categorical'; // chooses the colour-blind-safe palette
Chart-specific inputs (xKey, yKey, categoryKey, valueKey, …) layer on top per chart type but keep the same Signal-based shape.
Accessibility contract — baked in for v1
Each chart component, regardless of which (Plot or D3), produces:
- An outer
<figure role="img" aria-labelledby="<id>-title" aria-describedby="<id>-desc">wrapping the SVG. - SVG
<title>+<desc>elements as the first two children — picked up by screen readers as natural alt-text. - A
<details>disclosure with a<table>representation of the same dataset, collapsed by default. The table is the keyboard-navigable, screen-reader-friendly fallback when the visual encoding fails (assistive tech, no-JS, print). - Colour palettes from a fixed set: Viridis / Cividis for quantitative orderings, ColorBrewer Set2 for categorical. Both are colour-blind-safe per the ColorBrewer review.
- Axis tick + legend text rendered at 12 px minimum, contrast tested against the brand-primary background in both light and dark mode (AA: ≥ 4.5:1).
- Animations gated on
prefers-reduced-motionper ADR-0016 — a user withreducemotion gets a static render.
These six commitments are unit-tested in the lib so a future contributor can't ship a chart that quietly drops one of them.
Bundle impact
Per ADR-0017, lazy chunks cap at 100 KB gzip. Estimated bundle additions for the v1 vocabulary:
| Module | Approx gzip |
|---|---|
d3-scale + d3-axis + d3-selection + d3-shape + d3-array (tree-shaken) |
~25 KB |
@observablehq/plot |
~30 KB |
libs/shared/charts (4 components + helpers) |
~10 KB |
| Total added to a chart-consuming lazy chunk | ~65 KB |
Each individual chart on a page adds ~2-3 KB of generated component code, well below the threshold.
Consequences
- Good, because the chart lib ships fast — Plot's declarative API means a new chart type is ~50 LOC of Angular wrapper + a Plot call.
- Good, because D3 stays available as the lower layer for custom visualisations without re-platforming. The same lib hosts both Plot-backed and D3-backed components under a uniform
<lib-…>API. - Good, because the a11y contract is centralised — every consumer gets WCAG 2.2 AA + AAA-targeted compliance without re-deriving it per chart.
- Good, because the bundle footprint stays well within ADR-0017's lazy-chunk budget even with all four v1 chart types loaded.
- Good, because both Plot and D3 are maintained by the same team (Observable Inc.), under the same licence (MIT), with overlapping release cadence — no two-vendor coordination problem.
- Bad, because a fifth dependency adds to the maintenance / vuln surface. Mitigated by the existing Renovate cadence + the
pnpm.overridesrecipe documented in docs/development.md for transitive-vuln remediation. - Bad, because the lib's a11y contract is our commitment, not enforced by Plot itself — a sloppy contributor could bypass
_internal/and ship a non-conforming chart. Mitigated by the unit-test-based confirmation below + code review. - Neutral, because Plot's declarative API is less familiar to D3-only practitioners; the migration cost is a couple of days of reading the Plot manual.
Confirmation
- A unit test per chart component asserts:
- the outer
<figure>carriesrole="img"+aria-labelledby+aria-describedby; - the SVG contains a
<title>and a<desc>populated from inputs; - a
<details>block with a<table>fallback exists and contains every data point; - the rendered SVG uses a palette from the canonical
_internal/palette.tslist (not arbitrary colours).
- the outer
- A workspace-wide ESLint rule (custom or via
@typescript-eslint/no-restricted-imports) bans direct imports ofd3-scale-chromaticoutside_internal/palette.tsso consumer-side overrides can't silently use a non-colour-blind-safe palette. - Lighthouse CI a11y score remains ≥ 90 on any route that renders a chart (already enforced for the broader app per ADR-0016 + ADR-0017).
- A storybook-style "all charts in dark + light mode + reduced-motion" page lands in PR 3 so the visual + a11y baseline is exercisable from a single URL.
Pros and Cons of the Options
D3 + Observable Plot, wrapped in libs/shared/charts (chosen)
- Good, because both libs are MIT, maintained by Observable Inc., past 1.0, used by the same team that builds D3 itself.
- Good, because Plot covers ~80 % of the chart vocabulary in declarative one-liners, D3 covers the remaining 20 % bespoke cases.
- Good, because the chart lib stays small (Plot's design philosophy is "do one thing well per mark"), so our wrapper layer can stay correspondingly small.
- Good, because tree-shaking works cleanly on per-
d3-*module imports. - Bad, because Plot's API isn't a 1:1 port of "every D3 idiom" — there are edge cases (3D, animated transitions across data changes) where you re-implement in D3.
- Neutral, because we're adding the Vue runtime via VitePress for docs (ADR-0022) plus the Observable Plot layer here — two "non-Angular but small" surfaces in the workspace. Both are stable and isolated; no net team-cognitive-load increase.
D3 alone
- Good, because zero abstraction layer between us and the renderer; full control of every pixel.
- Good, because the project's existing tech-bar already validates D3 (mature, recognized, ~290k stars across packages).
- Bad, because each chart type is ~200-300 LOC; v1 ships slower or with less polish.
- Bad, because the a11y contract has to be re-applied in each chart's code — high risk of drift across components when 3-4 contributors author them over time.
- Bad, because some "boring" chart features (stacked bar with hover + legend toggling) become 500+ LOC each in D3 alone.
Apache ECharts
- Good, because feature-complete (50+ chart types out of the box, including the bespoke ones we'd otherwise write).
- Good, because the JSON config API is uniform across chart types.
- Bad, because the bundle ships at ~600 KB minified before tree-shaking; carving lazy-chunks requires the
echarts-for-angularwrapper + manual mark imports. - Bad, because ECharts' rendering layer is canvas-by-default — accessibility falls back to a separate
ariaplugin and a non-trivial config block per chart. Reaching WCAG 2.2 AA requires sustained effort, not a flick of a switch. - Bad, because the team idiom (JSON-config, no reactivity story for Signals) is the furthest from Angular's declarative-by-Signals direction the rest of the workspace runs on.
Chart.js
- Good, because canvas-rendered (great perf for large datasets).
- Good, because the wrapper
ng2-chartsis maintained, Angular-friendly, and reasonably small. - Bad, because canvas charts have weaker out-of-the-box accessibility than SVG (no DOM semantics for screen readers; alt-text + table fallback are the only path).
- Bad, because the chart vocabulary is narrower than Plot's — extending Chart.js with a custom chart type involves canvas rendering primitives, harder than dropping to D3 from Plot.
- Bad, because Chart.js's animation + tooltip styling is brittle to dark-mode integration — most consumers end up writing one-off CSS overrides.
More Information
- Phasing: implementation lands as a separate chantier after this ADR (lib foundations + 3 starter chart components in one PR, then audit-page integration in a follow-up).
- Future chart types (heatmap, sankey, treemap, geo) ship in raw D3 inside
libs/shared/charts/as the use case appears. The component contract above is the only requirement; the rendering technique is implementation detail. - Documentation: the lib gets its own section in docs/development.md §"Charts" once the implementation lands, plus a Storybook-style demo page per the Confirmation section.
- Related ADRs: