feat(shared-charts): foundations + bar / donut / stacked-bar components (#171)
CI / commits (push) Has been skipped
CI / check (push) Successful in 5m30s
CI / scan (push) Successful in 2m44s
CI / a11y (push) Successful in 2m37s
CI / perf (push) Successful in 4m21s
Docs site / build (push) Successful in 3m1s

## Summary

Implementation of [ADR-0023](docs/decisions/0023-charts-d3-observable-plot.md) — the foundations of the workspace's chart library. PR 2 of the chantier:

| PR | Périmètre |
| --- | --- |
| PR 1  | ADR-0023 — decision + a11y contract + bundle plan. |
| **PR 2 (this one)** | `libs/shared/charts/` foundations + `<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

### Workspace deps

```
d3                       — top-level toolkit, types via @types/d3
d3-shape                 — used directly by <lib-donut-chart>
d3-scale-chromatic       — colour-blind-safe palette source
@observablehq/plot       — declarative layer over D3, used by bar + stacked-bar
```

All four (+ matching `@types/*`) land in the workspace root `devDependencies`. Tree-shaken at build time per ADR-0023's bundle plan.

### New lib `libs/shared/charts/`

```
libs/shared/charts/src/lib/
├── _internal/                   ← single source of truth for the a11y contract
│   ├── a11y.ts                  ← chartId, findChartSvg, injectSvgTitleDesc, prefersReducedMotion, resolveTheme
│   ├── chart-envelope.scss      ← shared figure / caption / fallback / dark-mode rules
│   ├── chart-types.ts           ← `ChartBaseInputs<T>` extended by each component
│   └── palette.ts               ← Viridis / Cividis (sequential) + ColorBrewer Set2 (categorical)
├── bar-chart/                   ← Plot.barY
├── donut-chart/                 ← raw d3-shape (pie + arc); Plot has no donut mark
└── stacked-bar-chart/           ← Plot.barY with `fill: <seriesKey>` (auto-stacked, legend on)
```

### A11y contract baked in for v1

Per ADR-0023's six commitments, every chart component produces (and unit-tests for):

1. `<figure role="img" aria-labelledby aria-describedby>` wrapping the SVG.
2. SVG `<title>` + `<desc>` as the **first two children** — injected post-render via `injectSvgTitleDesc` because Plot doesn't emit them itself. `findChartSvg` handles both Plot output shapes (bare SVG, or `<figure>` wrapping a legend + SVG for `legend: true` configs).
3. A `<details>` disclosure with a `<table>` rendering every data point — the keyboard-navigable / screen-reader-friendly fallback for non-visual users.
4. Palette from `_internal/palette.ts` only — Viridis / Cividis for sequential, ColorBrewer Set2 for categorical. Both colour-blind-safe.
5. AA-contrast axis text via `:where(.dark)` flips in `_internal/chart-envelope.scss`.
6. `prefers-reduced-motion` → `data-no-transitions` marker on the SVG, CSS strips animations + transitions.

A custom ESLint rule in [`libs/shared/charts/eslint.config.mjs`](libs/shared/charts/eslint.config.mjs) bans direct imports of `d3-scale-chromatic` outside `_internal/palette.ts` so a future contributor can't bypass the colour-blind-safe contract.

### Component contract

Every `<lib-*-chart>` exposes the same Signal-based shape per ADR-0023:

```ts
[data]: readonly T[];
[caption]: string;
[description]: string;
[ariaLabel]: string;
[colorScheme]?: 'sequential' | 'categorical';
// + chart-specific keys (xKey, yKey, categoryKey, valueKey, seriesKey, …)
```

Re-renders triggered by Angular's `effect()` on input changes; the previous SVG is `replaceChildren`-d out so there's no DOM accumulation across data updates.

## Notes for the reviewer

- **Why three SCSS files importing one shared envelope?** Extracted at the third consumer per CLAUDE.md's "three similar lines is better than a premature abstraction" rule — bar + donut + stacked-bar share ~70 LOC of figure / caption / fallback / dark-mode chrome. `_internal/chart-envelope.scss` is the consolidation; each chart's `.scss` is now 4-20 LOC of chart-specific tweaks.
- **Why is `<lib-donut-chart>` raw D3 rather than Plot?** Plot's design philosophy explicitly excludes pie/donut marks ("a bar chart is almost always more legible"). The audit-log outcome breakdown reads naturally as a donut (the centre carries the total). Raw `d3-shape` is the lower-level fallback ADR-0023 reserves precisely for this kind of case; the component's API is identical to the Plot-backed siblings.
- **Why does the donut also stamp per-slice `<title>`?** Belt-and-suspenders. The top-level SVG `<title>` reads the caption; per-slice `<title>` reads "category: value" on hover (the SVG-native tooltip convention) for keyboard / screen-reader users who land on a specific slice.
- **Why no `pnpm.overrides` adjustment for `d3-*` transitives?** None of the new deps brought a vulnerability in this install. The `pnpm audit --audit-level=moderate` gate from #161 stays green.
- **What's deliberately deferred to PR 3?** The `/audit`-page integration — data aggregation from the current page (`AdminAuditPage` rows already loaded), the actual `<lib-*-chart>` placements above the existing table, the i18n strings for the captions / descriptions / aria-labels. No mock SAMPLE data shipped in this PR — every test uses local fixtures so the lib stays decoupled from any specific consumer.

## Test plan

- [x] `pnpm nx test shared-charts` — **13 specs pass** across the three components.
- [x] `pnpm nx lint shared-charts` — clean, including the custom `no-restricted-imports` guard on `d3-scale-chromatic`.
- [x] `pnpm nx build shared-charts` — clean (TS strict + ng-packagr).
- [x] `pnpm nx run-many -t lint test build --projects=shared-charts,portal-shell,portal-admin` — 12/12 tasks green.
- [x] `pnpm nx build portal-shell --configuration=production` — i18n-strict prod build clean. The lib ships no i18n marks (axis labels are caller-supplied per ADR-0023's i18n posture), so no xlf entry change here.
- [ ] **Visual smoke (deferred to PR 3 when the components are placed on `/audit`)** — `<figure>` + caption visible, SVG `<title>` exposed by VoiceOver / NVDA, `<details>` fallback expands to a table, dark-mode toggle flips axis text + Cividis palette, `prefers-reduced-motion` strips Plot's fade-in.

## What's next

PR 3 wires the three components onto the `/audit` page: aggregates `AdminAuditPage.items` client-side into the three required shapes (daily totals, outcome counts, daily-by-event-type pivots), places them above the existing filter form, ships the i18n strings for the captions and descriptions in `messages.fr.xlf`. Bundle impact on the lazy `audit` chunk gets verified there (the ~65 KB gzip plan from the ADR).

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #171
This commit was merged in pull request #171.
This commit is contained in:
2026-05-16 21:56:28 +02:00
parent 7ee7b2dadf
commit eb8b65c7bc
29 changed files with 1363 additions and 2 deletions
@@ -0,0 +1,30 @@
<figure
role="img"
class="chart-figure"
[attr.aria-label]="ariaLabel()"
[attr.aria-labelledby]="titleId"
[attr.aria-describedby]="descId"
>
<figcaption [id]="titleId" class="chart-caption">{{ caption() }}</figcaption>
<p [id]="descId" class="chart-description sr-only">{{ description() }}</p>
<div class="chart-canvas" aria-hidden="true"></div>
<details class="chart-fallback">
<summary>Data table</summary>
<table>
<thead>
<tr>
<th scope="col">{{ xLabel() ?? xKey() }}</th>
<th scope="col">{{ yLabel() ?? yKey() }}</th>
</tr>
</thead>
<tbody>
@for (row of tableRows(); track row.x) {
<tr>
<th scope="row">{{ row.x }}</th>
<td>{{ row.y }}</td>
</tr>
}
</tbody>
</table>
</details>
</figure>
@@ -0,0 +1,6 @@
@use '../_internal/chart-envelope';
// Bar-chart-specific tweaks: the Plot-rendered SVG inherits the
// `.chart-canvas` envelope's min-height and dark-mode text colours
// from `_internal/chart-envelope.scss`. Nothing chart-type-specific
// to add today.
@@ -0,0 +1,93 @@
import { TestBed } from '@angular/core/testing';
import { BarChart } from './bar-chart';
interface SampleRow {
readonly day: string;
readonly count: number;
}
const SAMPLE: readonly SampleRow[] = [
{ day: '2026-05-13', count: 12 },
{ day: '2026-05-14', count: 18 },
{ day: '2026-05-15', count: 7 },
];
function render(overrides: Partial<{ description: string }> = {}) {
TestBed.configureTestingModule({ imports: [BarChart] });
const fixture = TestBed.createComponent<BarChart<SampleRow>>(BarChart);
fixture.componentRef.setInput('data', SAMPLE);
fixture.componentRef.setInput('xKey', 'day');
fixture.componentRef.setInput('yKey', 'count');
fixture.componentRef.setInput('caption', 'Daily event volume');
fixture.componentRef.setInput(
'description',
overrides.description ??
'Bar chart showing the count of audit events emitted on each of the last three days.',
);
fixture.componentRef.setInput('ariaLabel', 'Daily event volume — bar chart');
fixture.detectChanges();
return fixture;
}
describe('BarChart', () => {
beforeEach(() => TestBed.resetTestingModule());
it('wraps the chart in a <figure role="img"> with aria-labelledby + aria-describedby', () => {
const fixture = render();
const figure = (fixture.nativeElement as HTMLElement).querySelector('figure.chart-figure');
expect(figure?.getAttribute('role')).toBe('img');
expect(figure?.getAttribute('aria-labelledby')).toMatch(/^chart-\d+-title$/);
expect(figure?.getAttribute('aria-describedby')).toMatch(/^chart-\d+-desc$/);
});
it('renders the caption and screen-reader description from inputs', () => {
const fixture = render();
const root = fixture.nativeElement as HTMLElement;
expect(root.querySelector('.chart-caption')?.textContent?.trim()).toBe('Daily event volume');
expect(root.querySelector('.chart-description')?.textContent?.trim()).toContain(
'Bar chart showing the count of audit events',
);
});
it('renders a <details> tabular fallback with one row per data point', () => {
const fixture = render();
const root = fixture.nativeElement as HTMLElement;
const fallback = root.querySelector('details.chart-fallback');
expect(fallback).not.toBeNull();
const rows = fallback?.querySelectorAll('tbody tr');
expect(rows?.length).toBe(SAMPLE.length);
expect(rows?.[0]?.querySelector('th')?.textContent?.trim()).toBe(SAMPLE[0]?.day);
expect(rows?.[0]?.querySelector('td')?.textContent?.trim()).toBe(String(SAMPLE[0]?.count));
});
it('emits an SVG with <title> and <desc> as the first two children (a11y contract)', () => {
const fixture = render();
const root = fixture.nativeElement as HTMLElement;
const svg = root.querySelector<SVGSVGElement>('.chart-canvas svg');
expect(svg).not.toBeNull();
const firstTwo = Array.from(svg?.children ?? []).slice(0, 2);
expect(firstTwo[0]?.tagName).toBe('title');
expect(firstTwo[1]?.tagName).toBe('desc');
expect(firstTwo[0]?.textContent).toBe('Daily event volume');
expect(firstTwo[1]?.textContent).toContain('Bar chart showing the count');
});
it('regenerates the chart when `data` changes', async () => {
const fixture = render();
const root = fixture.nativeElement as HTMLElement;
const before = root.querySelector('.chart-canvas svg');
expect(before).not.toBeNull();
fixture.componentRef.setInput('data', [
{ day: '2026-05-13', count: 99 },
{ day: '2026-05-14', count: 1 },
]);
fixture.detectChanges();
await fixture.whenStable();
// The fallback table reflects the new dataset.
const rows = root.querySelectorAll('details.chart-fallback tbody tr');
expect(rows.length).toBe(2);
expect(rows[0]?.querySelector('td')?.textContent?.trim()).toBe('99');
});
});
@@ -0,0 +1,149 @@
import {
ChangeDetectionStrategy,
Component,
ElementRef,
ViewEncapsulation,
computed,
effect,
inject,
input,
} from '@angular/core';
import * as Plot from '@observablehq/plot';
import {
chartId,
findChartSvg,
injectSvgTitleDesc,
prefersReducedMotion,
resolveTheme,
} from '../_internal/a11y';
import type { ChartBaseInputs } from '../_internal/chart-types';
import { paletteFor } from '../_internal/palette';
/**
* `<lib-bar-chart>` — vertical bar chart over a categorical X axis.
*
* Backed by `@observablehq/plot`'s `barY` mark per ADR-0023. Every
* row in `data` becomes one bar with its height encoding `yKey` and
* its position encoding `xKey`.
*
* Per the lib's a11y contract:
* - outer `<figure>` carries `role="img"` + `aria-labelledby` +
* `aria-describedby`,
* - SVG gets `<title>` + `<desc>` prepended (Plot doesn't emit
* them; the helper in `_internal/a11y.ts` does),
* - `<details>` disclosure with a `<table>` fallback ships every
* data point in a screen-reader-friendly form,
* - palette is resolved from `_internal/palette.ts` (Set2
* categorical by default, Viridis sequential when requested),
* - `prefers-reduced-motion` skips transitions.
*/
export interface BarChartInputs<T> extends ChartBaseInputs<T> {
/** Key in `T` whose value provides the X-axis category. */
readonly xKey: keyof T & string;
/** Key in `T` whose value provides the bar height. */
readonly yKey: keyof T & string;
/** Human-readable axis labels (no implicit i18n inside the lib). */
readonly xLabel?: string;
readonly yLabel?: string;
}
@Component({
selector: 'lib-bar-chart',
templateUrl: './bar-chart.html',
styleUrl: './bar-chart.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
// Plot renders into a host SVG outside Angular's template
// (imperatively appended via `nativeElement.append(...)`). We
// disable view encapsulation so the lib's stylesheet (axis ticks,
// dark-mode tokens) applies to that injected DOM.
encapsulation: ViewEncapsulation.None,
})
export class BarChart<T extends Record<string, unknown>> {
private readonly host = inject(ElementRef<HTMLElement>);
readonly data = input.required<readonly T[]>();
readonly xKey = input.required<keyof T & string>();
readonly yKey = input.required<keyof T & string>();
readonly caption = input.required<string>();
readonly description = input.required<string>();
readonly ariaLabel = input.required<string>();
readonly xLabel = input<string | undefined>(undefined);
readonly yLabel = input<string | undefined>(undefined);
readonly colorScheme = input<'sequential' | 'categorical'>('categorical');
protected readonly chartId = chartId();
protected readonly titleId = `${this.chartId}-title`;
protected readonly descId = `${this.chartId}-desc`;
/** Tabular fallback rendered under `<details>`. One row per datum. */
protected readonly tableRows = computed(() =>
this.data().map((row) => ({
x: String(row[this.xKey()]),
y: Number(row[this.yKey()]),
})),
);
constructor() {
// Re-render whenever any input that affects the visual changes.
// Plot's `Plot.plot()` returns a fresh DOM node each call — we
// replace the previous one in the host's `.chart-canvas`.
effect(() => {
this.renderPlot();
});
}
private renderPlot(): void {
const canvas = (this.host.nativeElement as HTMLElement).querySelector<HTMLDivElement>(
'.chart-canvas',
);
if (!canvas) {
return;
}
const data = this.data();
const xKey = this.xKey();
const yKey = this.yKey();
const xLabel = this.xLabel() ?? null;
const yLabel = this.yLabel() ?? null;
const palette = paletteFor(this.colorScheme(), resolveTheme(), Math.max(1, data.length));
const plot = Plot.plot({
// Mirrors the host element's available width so the chart is
// responsive. Plot computes a sensible height from the mark.
width: canvas.clientWidth || 600,
marginLeft: 56,
marginBottom: 40,
x: { label: xLabel, type: 'band' },
y: { label: yLabel, grid: true, nice: true },
color: { type: 'ordinal', range: palette },
marks: [
Plot.barY(data as Plot.Data, {
x: xKey,
y: yKey,
fill: xKey,
// Plot's default tooltip would require interaction wiring
// we haven't shipped yet — the tabular fallback under
// `<details>` is the v1 substitute for hover values.
}),
Plot.ruleY([0]),
],
// Plot's CSS-in-style inline output respects our dark-mode
// tokens via the host stylesheet (.chart-canvas .plot-d6a7b1).
});
// Honor the a11y contract — prepend <title> + <desc> to the
// inner SVG before swapping it into the canvas, so screen
// readers see them on first render rather than after a re-
// attach. `findChartSvg` handles both Plot return shapes (bare
// SVG or <figure> wrapping a legend + SVG).
const svg = findChartSvg(plot);
if (svg) {
injectSvgTitleDesc(svg, this.caption(), this.description());
}
if (prefersReducedMotion()) {
plot.setAttribute('data-no-transitions', '');
}
canvas.replaceChildren(plot);
}
}