feat(shared-charts): foundations + bar / donut / stacked-bar components #171

Merged
julien merged 1 commits from feat/shared-charts-foundations into main 2026-05-16 21:56:29 +02:00
Owner

Summary

Implementation of ADR-0023 — 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-motiondata-no-transitions marker on the SVG, CSS strips animations + transitions.

A custom ESLint rule in 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:

[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

  • pnpm nx test shared-charts13 specs pass across the three components.
  • pnpm nx lint shared-charts — clean, including the custom no-restricted-imports guard on d3-scale-chromatic.
  • pnpm nx build shared-charts — clean (TS strict + ng-packagr).
  • pnpm nx run-many -t lint test build --projects=shared-charts,portal-shell,portal-admin — 12/12 tasks green.
  • 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).

## 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).
julien added 1 commit 2026-05-16 21:56:05 +02:00
feat(shared-charts): foundations + bar / donut / stacked-bar components
CI / commits (pull_request) Successful in 3m31s
CI / scan (pull_request) Successful in 4m28s
CI / check (pull_request) Failing after 8m9s
CI / a11y (pull_request) Successful in 3m56s
Docs site / build (pull_request) Successful in 4m59s
CI / perf (pull_request) Successful in 10m8s
4ce22d6d05
Implementation of ADR-0023 (PR 1). The next chantier wires these
components into the audit-log page; this PR delivers the lib + the
three starter chart types + the a11y contract.

Deps:
  * d3 + @types/d3 (top-level)
  * d3-shape + @types/d3-shape (donut's pie/arc generators)
  * d3-scale-chromatic + @types/d3-scale-chromatic (palettes)
  * @observablehq/plot (bar + stacked-bar)

Lib `libs/shared/charts/`:
  * `_internal/palette.ts` — single source of palettes. Sequential
    (Viridis / Cividis for dark mode) + categorical (ColorBrewer
    Set2). Colour-blind-safe per the ColorBrewer review.
  * `_internal/a11y.ts` — chartId generator, SVG <title>/<desc>
    injector, `findChartSvg` for Plot's legend-wrapped output,
    prefers-reduced-motion guard, theme resolver.
  * `_internal/chart-envelope.scss` — shared figure/caption/
    fallback rules consumed via `@use` by each chart's .scss.
    Extracted at the third consumer per CLAUDE.md's "three similar
    things" rule.
  * `_internal/chart-types.ts` — common `ChartBaseInputs<T>`
    interface every component extends.

Three Plot-or-D3-backed components:
  * `<lib-bar-chart>` — Plot.barY, categorical-by-default palette.
  * `<lib-donut-chart>` — raw d3-shape (pie + arc); Plot has no
    donut mark so we reach for D3 directly. Each slice carries its
    own <title> child for SVG-tooltip accessibility.
  * `<lib-stacked-bar-chart>` — Plot.barY with `fill` set to the
    series key (auto-stacked), legend on.

Each component honours ADR-0023's six commitments:
  1. <figure role="img" aria-labelledby aria-describedby>
  2. SVG <title> + <desc> as first two children (injected post-
     render because Plot doesn't emit them)
  3. <details> tabular fallback rendering every data point
  4. Palette from the canonical list (no ad-hoc colours)
  5. AA-contrast text on axis ticks (theme-aware grayscale via
     the .chart-canvas svg text rule)
  6. prefers-reduced-motion → data-no-transitions marker on the
     SVG, CSS strips animations.

A custom ESLint rule in `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.

Tests: 13 specs across the three components. Each component covers
the figure ARIA contract, the SVG title/desc injection, the
tabular fallback, and data-reactivity. Donut spec additionally
asserts per-slice <title> labels for screen readers.

Verification:
  * `pnpm nx lint test build --projects=shared-charts,portal-shell,portal-admin`
    — 12/12 tasks green.
  * `pnpm nx build portal-shell --configuration=production` —
    i18n-strict prod build clean (the lib ships no i18n marks
    since axis labels are caller-supplied).
julien merged commit eb8b65c7bc into main 2026-05-16 21:56:29 +02:00
julien deleted branch feat/shared-charts-foundations 2026-05-16 21:56:31 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: julien/apf_portal#171