Files
apf_portal/docs/decisions/0023-charts-d3-observable-plot.md
julien 7ee7b2dadf
CI / check (push) Successful in 1m17s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m2s
CI / a11y (push) Successful in 2m33s
CI / perf (push) Successful in 6m7s
Docs site / build (push) Successful in 3m37s
docs(adr-0023): charts and dashboards — d3 + observable plot (#170)
## Summary

Records the decision to use **D3 + Observable Plot**, wrapped in a new `libs/shared/charts/`, as the chart toolkit shared by `portal-shell` and `portal-admin`. ADR-only — implementation lands as the next chantier(s).

This is staged as a 3-PR chantier per the agreed plan:

| PR | Périmètre |
| --- | --- |
| **PR 1 (this one)** | ADR-0023 — decision + a11y contract + bundle plan. |
| PR 2 | `libs/shared/charts/` foundations + 3 starter components (`<lib-bar-chart>`, `<lib-donut-chart>`, `<lib-stacked-bar-chart>`). |
| PR 3 | Integration on the `/audit` page — daily-volume bar + outcome-breakdown donut + event-type-over-time stacked bar. |

## What lands

### [`docs/decisions/0023-charts-d3-observable-plot.md`](docs/decisions/0023-charts-d3-observable-plot.md)

Full MADR 4.0.0 record. Highlights:

- **Choice**: D3 + Observable Plot, both from Mike Bostock / Observable Inc., both MIT, both past 1.0. Plot covers ~80 % of standard charts in declarative one-liners; D3 stays the escape hatch for bespoke viz (heatmap, sankey, …) inside the same lib.
- **Why not D3 alone**: ~250 LOC per chart × 4-5 types × a11y discipline = sustained code investment before the first dashboard ships.
- **Why not ECharts / Chart.js**: 600 KB minified + canvas-rendered + an `aria` plugin afterthought (ECharts), or narrower vocabulary + brittle dark-mode (Chart.js). Both furthest from the Angular-Signals-zoneless idiom the rest of the workspace runs on.
- **A11y contract** is baked into `_internal/` (palette, tabular fallback, SVG `<title>` / `<desc>` builders) so every chart inherits WCAG 2.2 AA + AAA-targeted compliance from the lib, not from contributor discipline. Six commitments, each unit-tested per chart component.
- **Bundle plan**: ~65 KB gzip added to a chart-bearing lazy chunk (d3 modules tree-shaken + Plot + thin wrapper) — well under [ADR-0017](docs/decisions/0017-performance-budgets-lighthouse-ci.md)'s 100 KB cap.
- **Component contract**: every `<lib-*-chart>` exposes the same Signal-based input shape (`[data]`, `[caption]`, `[description]`, `[ariaLabel]`, `[colorScheme]`) regardless of whether Plot or raw D3 powers the rendering.

### [`docs/decisions/README.md`](docs/decisions/README.md)

ADR-0023 added to the index table.

### [`CLAUDE.md`](CLAUDE.md)

- "Architecture (recorded in ADRs)" gains a "Charts + dashboards" bullet describing the lib + a11y baseline + bundle posture.
- "Repository status" bumps the ADR range to `0001 → 0023`.
- "Still on the roadmap" gains the charts implementation entry pointing at this ADR.

## Notes for the reviewer

- **Why honour the user's D3 preference rather than recommend pure ECharts?** D3 (and by extension Plot) is the closest match to the project's tech bar ("stable, recognized, battle-tested") for data-viz on the web; it's also the user's stated preference, and Plot's higher-level layer eliminates the "250 LOC per chart" cost that would otherwise push us toward an alternative. The ADR explicitly walks through ECharts + Chart.js as runners-up so future challengers see the trade-offs we chose against.
- **Why a single shared lib rather than per-app charts?** Both SPAs (portal-shell + portal-admin) will host dashboards. The chart vocabulary, a11y contract, palette, and theme integration are identical between the two — duplicating into app-local code would invite drift. The lib stays at `libs/shared/charts/` next to `libs/shared/ui/`.
- **Why the `_internal/` folder for cross-cutting code?** Single source of truth for the colour palette and the a11y plumbing. A lint rule (added in PR 2) will ban consumers from importing `d3-scale-chromatic` directly so the colour-blind-safe palette stays the only path.
- **Why no ADR amendment to ADR-0016 / ADR-0017?** Both are binding constraints, not superseded. The new ADR operationalises both for the chart surface; cross-references in the "Related ADRs" section make that explicit.

## Test plan

- [x] ADR validates as MADR 4.0.0 (frontmatter, section order, tag vocabulary).
- [x] No code touched — lint / test / build matrix unaffected.
- [x] `docs/decisions/README.md` index updated in the same change per the [ADR conventions](docs/decisions/README.md#conventions).
- [ ] Review for trade-off accuracy: are the bundle estimates fair? Is the "Plot covers ~80 % of standard charts" framing defensible against the user's mental model of D3?
- [ ] Implementation chantier (PR 2) lands directly behind this if accepted: `pnpm add -w d3 @observablehq/plot @types/d3`, `libs/shared/charts/` scaffold via `pnpm nx g @nx/angular:library --name=shared-charts --directory=libs/shared/charts --standalone=true --unitTestRunner=vitest-analog --tags="scope:shared,type:shared" --no-interactive`, then the 3 starter components.

## What's next

If accepted as-is, PR 2 (lib foundations + 3 starter components) follows. If a reviewer wants to push back on D3-vs-ECharts or on the a11y contract's strictness, this is the right PR to surface that — no implementation has started.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #170
2026-05-16 21:28:03 +02:00

14 KiB

status, date, decision-makers, tags
status date decision-makers tags
accepted 2026-05-16 R&D Lead
frontend
accessibility
performance

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's Plot.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:

  1. An outer <figure role="img" aria-labelledby="<id>-title" aria-describedby="<id>-desc"> wrapping the SVG.
  2. SVG <title> + <desc> elements as the first two children — picked up by screen readers as natural alt-text.
  3. 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).
  4. Colour palettes from a fixed set: Viridis / Cividis for quantitative orderings, ColorBrewer Set2 for categorical. Both are colour-blind-safe per the ColorBrewer review.
  5. 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).
  6. Animations gated on prefers-reduced-motion per ADR-0016 — a user with reduce motion 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.overrides recipe 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> carries role="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.ts list (not arbitrary colours).
  • A workspace-wide ESLint rule (custom or via @typescript-eslint/no-restricted-imports) bans direct imports of d3-scale-chromatic outside _internal/palette.ts so 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-angular wrapper + manual mark imports.
  • Bad, because ECharts' rendering layer is canvas-by-default — accessibility falls back to a separate aria plugin 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-charts is 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:
    • ADR-0016 — supplies the WCAG 2.2 AA bar this lib enforces.
    • ADR-0017 — supplies the lazy-chunk budget the lib stays under.
    • ADR-0019 — caller-supplied i18n strings means the lib is locale-free.