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
This commit was merged in pull request #170.
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
---
|
||||
status: accepted
|
||||
date: 2026-05-16
|
||||
decision-makers: R&D Lead
|
||||
tags: [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](../../CLAUDE.md) §"Project rules";
|
||||
- charts that meet [ADR-0016](0016-accessibility-baseline-wcag-aa-targeted-aaa.md)'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](https://d3js.org/) 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](0017-performance-budgets-lighthouse-ci.md), 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](0019-internationalisation-angular-localize.md), 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:
|
||||
|
||||
```ts
|
||||
[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](../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](0022-docs-site-vitepress.md)) _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](../development.md) §"Charts" once the implementation lands, plus a Storybook-style demo page per the Confirmation section.
|
||||
- **Related ADRs**:
|
||||
- [ADR-0016](0016-accessibility-baseline-wcag-aa-targeted-aaa.md) — supplies the WCAG 2.2 AA bar this lib enforces.
|
||||
- [ADR-0017](0017-performance-budgets-lighthouse-ci.md) — supplies the lazy-chunk budget the lib stays under.
|
||||
- [ADR-0019](0019-internationalisation-angular-localize.md) — caller-supplied i18n strings means the lib is locale-free.
|
||||
Reference in New Issue
Block a user