feat(shared-charts): foundations + bar / donut / stacked-bar components (#171)
## 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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user