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,94 @@
/**
* Accessibility plumbing shared by every chart component per
* ADR-0023's six commitments. Single source of truth so a sloppy
* contributor can't drop one silently.
*
* The helpers here either *return* DOM nodes (so the consumer can
* attach them to a `<figure>` it controls) or *augment* an existing
* SVG produced by Plot / D3 with the missing a11y bits (Plot does
* not emit `<title>` or `<desc>` by default).
*/
let idCounter = 0;
/**
* Stable per-chart id used to wire `aria-labelledby` /
* `aria-describedby`. Generated client-side at component
* construction; not stable across SSR (we don't SSR the SPAs per
* ADR-0004).
*/
export function chartId(): string {
idCounter += 1;
return `chart-${idCounter}`;
}
/**
* Find the first `<svg>` inside a node Plot returned. Plot returns
* either the SVG itself (no legend) or an HTML `<figure>` wrapping
* a legend `<div>` + the SVG when `color.legend: true`. Charts care
* about the SVG specifically — that's where `<title>` + `<desc>`
* live for assistive tech.
*/
export function findChartSvg(plotOutput: SVGSVGElement | HTMLElement): SVGSVGElement | null {
if (plotOutput instanceof SVGSVGElement) {
return plotOutput;
}
return plotOutput.querySelector('svg');
}
/**
* Prepend `<title>` and `<desc>` elements to a Plot-generated SVG
* so screen readers expose them as the chart's accessible name and
* long description. SVG2 + ARIA require these elements to be the
* first children of the `<svg>` for the AT to pick them up reliably.
*
* Idempotent: if a `<title>` is already present, no-op.
*/
export function injectSvgTitleDesc(svg: SVGSVGElement, caption: string, description: string): void {
const doc = svg.ownerDocument;
// Strip any pre-existing title/desc the Plot helper might have
// injected so we don't accumulate on re-renders.
Array.from(svg.children)
.filter((c) => c.tagName === 'title' || c.tagName === 'desc')
.forEach((c) => c.remove());
const titleEl = doc.createElementNS('http://www.w3.org/2000/svg', 'title');
titleEl.textContent = caption;
const descEl = doc.createElementNS('http://www.w3.org/2000/svg', 'desc');
descEl.textContent = description;
// The first child is the title, second is the description.
svg.insertBefore(descEl, svg.firstChild);
svg.insertBefore(titleEl, svg.firstChild);
}
/**
* Test whether the active user has requested reduced motion. Used
* by the chart components to skip Plot's default transitions per
* ADR-0016. The check is read once per render — Plot doesn't expose
* an animation toggle, so this drives whether we apply the
* `[no-transitions]` CSS reset shipped with the lib's stylesheet.
*/
export function prefersReducedMotion(): boolean {
if (typeof window === 'undefined' || !window.matchMedia) {
return false;
}
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
/**
* Resolve the active theme from `<html class="dark">` (toggled by
* `LayoutStateService.themeMode` upstream — see
* `libs/shared/state/src/lib/layout-state.service.ts`). Charts swap
* their colour ramp + axis text colour based on this.
*
* Safe on the server: returns `'light'` if `document` is absent
* (irrelevant for v1 since both SPAs are CSR-only, but defensive).
*/
export function resolveTheme(): 'light' | 'dark' {
if (typeof document === 'undefined') {
return 'light';
}
return document.documentElement.classList.contains('dark') ? 'dark' : 'light';
}
@@ -0,0 +1,113 @@
// Shared a11y envelope used by every chart component in the lib.
// Extracted at the third consumer (stacked-bar-chart) per the
// "three similar lines is better than a premature abstraction"
// rule from CLAUDE.md — bar + donut + stacked-bar = three charts
// sharing the same figure / caption / fallback shell, time to
// consolidate.
//
// Imported via `@use` from each chart's own .scss; that file
// only carries the chart-specific tweaks (canvas dimensions,
// chart-specific SVG element rules).
.chart-figure {
margin: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.chart-caption {
font-size: 0.875rem;
font-weight: 600;
color: #1f2937;
:where(.dark) & {
color: #f3f4f6;
}
}
// Screen-reader-only utility (WCAG technique G81).
.chart-description.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.chart-canvas {
width: 100%;
min-height: 16rem;
// Plot's default text colour is hardcoded black; override to the
// theme-aware grayscale so axis ticks and labels stay readable
// in dark mode without re-rendering.
svg [text-anchor],
svg text {
fill: #374151;
:where(.dark) & {
fill: #d1d5db;
}
}
svg[data-no-transitions] * {
transition: none !important;
animation: none !important;
}
}
.chart-fallback {
font-size: 0.75rem;
summary {
cursor: pointer;
color: #6b7280;
padding: 0.25rem 0;
border-radius: 0.125rem;
&:focus-visible {
outline: 2px solid var(--color-brand-primary-500, #2563eb);
outline-offset: 2px;
}
:where(.dark) & {
color: #9ca3af;
}
}
table {
width: 100%;
margin-top: 0.5rem;
border-collapse: collapse;
color: #374151;
:where(.dark) & {
color: #d1d5db;
}
}
th,
td {
padding: 0.25rem 0.5rem;
text-align: left;
border-bottom: 1px solid #e5e7eb;
:where(.dark) & {
border-bottom-color: #1f2937;
}
}
th[scope='col'] {
font-weight: 600;
}
td {
font-family: ui-monospace, monospace;
font-size: 0.6875rem;
}
}
@@ -0,0 +1,27 @@
/**
* Common types shared across every chart component in the lib.
*
* Per ADR-0023 every `<lib-*-chart>` exposes the same minimum input
* shape (caption, description, ariaLabel, colorScheme). Chart-
* specific inputs (xKey, yKey, …) layer on top per component.
*/
import type { ColorScheme } from './palette';
export type { ColorScheme };
/**
* Inputs every chart honours. Components extend this with their
* own keys.
*/
export interface ChartBaseInputs<T> {
/** Raw rows. The chart's own type-specific keys index into these. */
readonly data: readonly T[];
/** Visible caption + SVG `<title>`. ≤ ~80 chars reads cleanly. */
readonly caption: string;
/** SVG `<desc>` — full sentence describing what the chart shows. */
readonly description: string;
/** Outer `<figure>`'s aria-label. Often the same as `caption`. */
readonly ariaLabel: string;
/** Optional palette family. `categorical` when no explicit value. */
readonly colorScheme?: ColorScheme;
}
@@ -0,0 +1,73 @@
import { interpolateCividis, interpolateViridis, schemeSet2 } from 'd3-scale-chromatic';
/**
* Colour palette resolver shared across every chart in the lib.
*
* Per ADR-0023, palettes are chosen from a *fixed* colour-blind-safe
* set so no contributor can accidentally ship a chart with a non-
* accessible scheme. Two families:
*
* - **Sequential** — for quantitative orderings (a continuous value
* encoded along one ramp). Default: Viridis (perceptually
* uniform, colour-blind safe, prints OK in greyscale). Cividis
* is the dark-mode-aware sibling.
* - **Categorical** — for nominal categories (e.g. event outcomes).
* Default: ColorBrewer Set2 — 8 hues, colour-blind safe per
* ColorBrewer's review.
*
* Consumers can NOT override these from outside the lib — an ESLint
* rule (added in this PR) blocks direct imports of
* `d3-scale-chromatic` outside this file so the lib stays the only
* source of palettes.
*/
export type ColorScheme = 'sequential' | 'categorical';
/**
* Build a function that maps a numeric input in [0, 1] to a hex
* colour from the sequential ramp. Use this for value-driven fills
* (bar height encoded as colour, heatmap density, …).
*
* `mode` is the active theme — `dark` swaps Viridis for Cividis,
* which retains its colour-blind safety while being visually
* lighter against a dark surface.
*/
export function sequentialScale(mode: 'light' | 'dark'): (t: number) => string {
return mode === 'dark' ? interpolateCividis : interpolateViridis;
}
/**
* Return the categorical palette as a flat string array. Consumers
* cycle the array; if they exceed the length (≥ 8 categories), they
* should reconsider whether categorical is the right encoding (per
* the chart literature, ≥ 8 categorical colours stop being
* distinguishable to most viewers, colour-blind or not).
*/
export function categoricalPalette(): readonly string[] {
return schemeSet2 as readonly string[];
}
/**
* Helper: resolve a colour scheme + count to an array of strings.
* Used by the chart components to feed `Plot.scale({color: {range:
* […]}})` without having to know which underlying palette is
* active.
*/
export function paletteFor(
scheme: ColorScheme,
mode: 'light' | 'dark',
count: number,
): readonly string[] {
if (scheme === 'categorical') {
return categoricalPalette().slice(0, count);
}
const interpolate = sequentialScale(mode);
// Sample the ramp at `count` evenly-spaced points. count === 1
// picks the midpoint so a single-series sequential chart doesn't
// render the darkest extreme.
if (count <= 1) {
return [interpolate(0.5)];
}
const step = 1 / (count - 1);
return Array.from({ length: count }, (_, i) => interpolate(i * step));
}
@@ -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);
}
}
@@ -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 chart-canvas--donut" aria-hidden="true"></div>
<details class="chart-fallback">
<summary>Data table</summary>
<table>
<thead>
<tr>
<th scope="col">{{ categoryKey() }}</th>
<th scope="col">{{ valueKey() }}</th>
</tr>
</thead>
<tbody>
@for (row of tableRows(); track row.category) {
<tr>
<th scope="row">{{ row.category }}</th>
<td>{{ row.value }}</td>
</tr>
}
</tbody>
</table>
</details>
</figure>
@@ -0,0 +1,17 @@
@use '../_internal/chart-envelope';
// Donut-specific layout: centre the SVG inside the canvas so the
// donut stays anchored regardless of available width.
.chart-canvas--donut {
display: flex;
justify-content: center;
align-items: center;
}
// Centre label rendered inside the SVG when the consumer supplies
// `centerLabel` (e.g. the total). Inherits the theme's text colour
// via the `.chart-canvas svg text` rule from the shared envelope.
.donut-center-label {
font-size: 1.25rem;
font-weight: 600;
}
@@ -0,0 +1,78 @@
import { TestBed } from '@angular/core/testing';
import { DonutChart } from './donut-chart';
interface OutcomeRow {
readonly outcome: string;
readonly count: number;
}
const SAMPLE: readonly OutcomeRow[] = [
{ outcome: 'success', count: 120 },
{ outcome: 'failure', count: 6 },
{ outcome: 'denied', count: 3 },
];
function render() {
TestBed.configureTestingModule({ imports: [DonutChart] });
const fixture = TestBed.createComponent<DonutChart<OutcomeRow>>(DonutChart);
fixture.componentRef.setInput('data', SAMPLE);
fixture.componentRef.setInput('categoryKey', 'outcome');
fixture.componentRef.setInput('valueKey', 'count');
fixture.componentRef.setInput('caption', 'Outcome breakdown');
fixture.componentRef.setInput(
'description',
'Donut chart of audit-event outcomes for the current page (success, failure, denied).',
);
fixture.componentRef.setInput('ariaLabel', 'Outcome breakdown — donut chart');
fixture.componentRef.setInput('centerLabel', '129');
fixture.detectChanges();
return fixture;
}
describe('DonutChart', () => {
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 an SVG with <title> + <desc> as the first two children + one <path> per slice', () => {
const fixture = render();
const root = fixture.nativeElement as HTMLElement;
const svg = root.querySelector<SVGSVGElement>('.chart-canvas svg');
expect(svg).not.toBeNull();
const children = Array.from(svg?.children ?? []);
expect(children[0]?.tagName).toBe('title');
expect(children[1]?.tagName).toBe('desc');
const slices = children.filter((c) => c.tagName === 'path');
expect(slices.length).toBe(SAMPLE.length);
});
it('each slice has its own <title> child carrying "<category>: <value>"', () => {
const fixture = render();
const root = fixture.nativeElement as HTMLElement;
const slices = root.querySelectorAll<SVGPathElement>('.chart-canvas svg path');
expect(slices.length).toBe(SAMPLE.length);
expect(slices[0]?.querySelector('title')?.textContent).toBe('success: 120');
expect(slices[1]?.querySelector('title')?.textContent).toBe('failure: 6');
});
it('renders the centerLabel in a <text> when supplied', () => {
const fixture = render();
const root = fixture.nativeElement as HTMLElement;
expect(root.querySelector('.donut-center-label')?.textContent?.trim()).toBe('129');
});
it('renders a <details> tabular fallback with one row per category', () => {
const fixture = render();
const root = fixture.nativeElement as HTMLElement;
const rows = root.querySelectorAll('details.chart-fallback tbody tr');
expect(rows.length).toBe(SAMPLE.length);
expect(rows[0]?.querySelector('th')?.textContent?.trim()).toBe('success');
expect(rows[0]?.querySelector('td')?.textContent?.trim()).toBe('120');
});
});
@@ -0,0 +1,138 @@
import { arc, pie, type PieArcDatum } from 'd3-shape';
import {
ChangeDetectionStrategy,
Component,
ElementRef,
ViewEncapsulation,
computed,
effect,
inject,
input,
} from '@angular/core';
import { chartId, injectSvgTitleDesc, prefersReducedMotion, resolveTheme } from '../_internal/a11y';
import type { ChartBaseInputs } from '../_internal/chart-types';
import { paletteFor } from '../_internal/palette';
/**
* `<lib-donut-chart>` — categorical breakdown with a hollow centre.
*
* Plot ships marks for bar / line / area / dot but no first-class
* "pie" or "donut" — its design philosophy is that those rarely
* win against a bar chart for legibility. We still ship one
* because audit-log outcome breakdown reads naturally as a donut
* (the empty centre carries the total). The component is therefore
* raw D3 (`d3-shape`'s `pie` + `arc` generators) wrapped in the
* same a11y envelope as the other charts.
*
* Per ADR-0023's "same `<lib-…>` API regardless of underlying
* technique" rule, the input shape mirrors the bar chart — only
* the chart-specific keys differ.
*/
export interface DonutChartInputs<T> extends ChartBaseInputs<T> {
/** Key carrying the category label. */
readonly categoryKey: keyof T & string;
/** Key carrying the numeric value. */
readonly valueKey: keyof T & string;
/** Optional centre-of-donut text (e.g. the total). */
readonly centerLabel?: string;
}
@Component({
selector: 'lib-donut-chart',
templateUrl: './donut-chart.html',
styleUrl: './donut-chart.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
})
export class DonutChart<T extends Record<string, unknown>> {
private readonly host = inject(ElementRef<HTMLElement>);
readonly data = input.required<readonly T[]>();
readonly categoryKey = input.required<keyof T & string>();
readonly valueKey = input.required<keyof T & string>();
readonly caption = input.required<string>();
readonly description = input.required<string>();
readonly ariaLabel = input.required<string>();
readonly centerLabel = 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`;
protected readonly tableRows = computed(() =>
this.data().map((row) => ({
category: String(row[this.categoryKey()]),
value: Number(row[this.valueKey()]),
})),
);
constructor() {
effect(() => {
this.renderDonut();
});
}
private renderDonut(): void {
const canvas = (this.host.nativeElement as HTMLElement).querySelector<HTMLDivElement>(
'.chart-canvas',
);
if (!canvas) {
return;
}
const data = this.data();
const categoryKey = this.categoryKey();
const valueKey = this.valueKey();
const palette = paletteFor(this.colorScheme(), resolveTheme(), Math.max(1, data.length));
const width = canvas.clientWidth || 320;
const height = 240;
const radius = Math.min(width, height) / 2;
const innerRadius = radius * 0.6;
const svgNs = 'http://www.w3.org/2000/svg';
const svg = canvas.ownerDocument.createElementNS(svgNs, 'svg');
svg.setAttribute('viewBox', `${-width / 2} ${-height / 2} ${width} ${height}`);
svg.setAttribute('width', String(width));
svg.setAttribute('height', String(height));
injectSvgTitleDesc(svg, this.caption(), this.description());
const pieLayout = pie<T>()
.value((d) => Number(d[valueKey]))
.sort(null);
const arcGen = arc<PieArcDatum<T>>().innerRadius(innerRadius).outerRadius(radius);
const arcs = pieLayout(data as T[]);
arcs.forEach((slice, i) => {
const path = canvas.ownerDocument.createElementNS(svgNs, 'path');
path.setAttribute('d', arcGen(slice) ?? '');
path.setAttribute('fill', palette[i % palette.length] ?? '#888');
path.setAttribute('stroke', resolveTheme() === 'dark' ? '#111827' : '#ffffff');
path.setAttribute('stroke-width', '1.5');
// Each slice gets its own <title> child so hovering announces
// the category + value to screen readers via the SVG tooltip
// convention. Belt-and-suspenders to the top-level desc.
const sliceTitle = canvas.ownerDocument.createElementNS(svgNs, 'title');
sliceTitle.textContent = `${slice.data[categoryKey]}: ${slice.data[valueKey]}`;
path.appendChild(sliceTitle);
svg.appendChild(path);
});
if (this.centerLabel()) {
const text = canvas.ownerDocument.createElementNS(svgNs, 'text');
text.setAttribute('text-anchor', 'middle');
text.setAttribute('dominant-baseline', 'central');
text.setAttribute('class', 'donut-center-label');
text.textContent = this.centerLabel() ?? '';
svg.appendChild(text);
}
if (prefersReducedMotion()) {
svg.setAttribute('data-no-transitions', '');
}
canvas.replaceChildren(svg);
}
}
@@ -0,0 +1,32 @@
<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">{{ seriesKey() }}</th>
<th scope="col">{{ yLabel() ?? yKey() }}</th>
</tr>
</thead>
<tbody>
@for (row of tableRows(); track $index) {
<tr>
<th scope="row">{{ row.x }}</th>
<td>{{ row.series }}</td>
<td>{{ row.y }}</td>
</tr>
}
</tbody>
</table>
</details>
</figure>
@@ -0,0 +1,16 @@
@use '../_internal/chart-envelope';
// Plot renders the legend as an inline-flex of swatches above the
// SVG. Style them to match the lib's typography density without
// re-rendering the legend in Angular.
.chart-canvas {
> .plot-legend,
> [data-plot-legend] {
font-size: 0.75rem;
margin-bottom: 0.5rem;
:where(.dark) & {
color: #d1d5db;
}
}
}
@@ -0,0 +1,67 @@
import { TestBed } from '@angular/core/testing';
import { StackedBarChart } from './stacked-bar-chart';
interface EventRow {
readonly day: string;
readonly eventType: string;
readonly count: number;
}
const SAMPLE: readonly EventRow[] = [
{ day: '2026-05-14', eventType: 'auth.sign_in', count: 8 },
{ day: '2026-05-14', eventType: 'auth.sign_in.failed', count: 1 },
{ day: '2026-05-15', eventType: 'auth.sign_in', count: 12 },
{ day: '2026-05-15', eventType: 'admin.access_denied', count: 2 },
];
function render() {
TestBed.configureTestingModule({ imports: [StackedBarChart] });
const fixture = TestBed.createComponent<StackedBarChart<EventRow>>(StackedBarChart);
fixture.componentRef.setInput('data', SAMPLE);
fixture.componentRef.setInput('xKey', 'day');
fixture.componentRef.setInput('yKey', 'count');
fixture.componentRef.setInput('seriesKey', 'eventType');
fixture.componentRef.setInput('caption', 'Events per day, by type');
fixture.componentRef.setInput(
'description',
'Stacked bar chart showing the daily volume of audit events broken down by event type.',
);
fixture.componentRef.setInput('ariaLabel', 'Events per day, by type — stacked bar chart');
fixture.detectChanges();
return fixture;
}
describe('StackedBarChart', () => {
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('emits an SVG with <title> + <desc> as the first two children', () => {
const fixture = render();
const root = fixture.nativeElement as HTMLElement;
const svg = root.querySelector<SVGSVGElement>('.chart-canvas svg');
expect(svg).not.toBeNull();
const children = Array.from(svg?.children ?? []).slice(0, 2);
expect(children[0]?.tagName).toBe('title');
expect(children[1]?.tagName).toBe('desc');
expect(children[0]?.textContent).toBe('Events per day, by type');
});
it('renders a <details> tabular fallback with three columns and one row per datum', () => {
const fixture = render();
const root = fixture.nativeElement as HTMLElement;
const headers = root.querySelectorAll('details.chart-fallback thead th');
expect(headers.length).toBe(3);
const rows = root.querySelectorAll('details.chart-fallback tbody tr');
expect(rows.length).toBe(SAMPLE.length);
expect(rows[0]?.children[0]?.textContent?.trim()).toBe('2026-05-14');
expect(rows[0]?.children[1]?.textContent?.trim()).toBe('auth.sign_in');
expect(rows[0]?.children[2]?.textContent?.trim()).toBe('8');
});
});
@@ -0,0 +1,145 @@
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-stacked-bar-chart>` — bars stacked by a series key,
* categorised on an X axis. Use case in the audit-log: events per
* day, stacked by event_type, so a viewer sees both the total
* volume per day and the breakdown.
*
* Backed by `Plot.barY` with the `fill` channel set to the series
* key — Plot then auto-stacks the values per X bucket. Renders the
* same a11y envelope as the other charts.
*
* The component accepts a flat array of rows, each carrying the
* X-axis bucket, the series key, and the value. Pre-pivoted data
* stays the simplest input model for consumers to feed.
*/
export interface StackedBarRow {
readonly [key: string]: unknown;
}
export interface StackedBarChartInputs<T> extends ChartBaseInputs<T> {
readonly xKey: keyof T & string;
readonly yKey: keyof T & string;
readonly seriesKey: keyof T & string;
readonly xLabel?: string;
readonly yLabel?: string;
}
@Component({
selector: 'lib-stacked-bar-chart',
templateUrl: './stacked-bar-chart.html',
styleUrl: './stacked-bar-chart.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
})
export class StackedBarChart<T extends StackedBarRow> {
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 seriesKey = 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 row shape — flat, one row per datum. */
protected readonly tableRows = computed(() =>
this.data().map((row) => ({
x: String(row[this.xKey()]),
series: String(row[this.seriesKey()]),
y: Number(row[this.yKey()]),
})),
);
/** Unique series labels in encounter order — drives the legend. */
protected readonly seriesLabels = computed(() => {
const seen = new Set<string>();
for (const row of this.data()) {
seen.add(String(row[this.seriesKey()]));
}
return Array.from(seen);
});
constructor() {
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 seriesKey = this.seriesKey();
const xLabel = this.xLabel() ?? null;
const yLabel = this.yLabel() ?? null;
const palette = paletteFor(this.colorScheme(), resolveTheme(), this.seriesLabels().length);
const plot = Plot.plot({
width: canvas.clientWidth || 720,
marginLeft: 56,
marginBottom: 40,
x: { label: xLabel, type: 'band' },
y: { label: yLabel, grid: true, nice: true },
color: {
type: 'ordinal',
domain: this.seriesLabels() as string[],
range: palette,
legend: true,
},
marks: [
Plot.barY(data as Plot.Data, {
x: xKey,
y: yKey,
fill: seriesKey,
}),
Plot.ruleY([0]),
],
});
const svg = findChartSvg(plot);
if (svg) {
injectSvgTitleDesc(svg, this.caption(), this.description());
}
if (prefersReducedMotion()) {
plot.setAttribute('data-no-transitions', '');
}
canvas.replaceChildren(plot);
}
}