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

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).
This commit is contained in:
Julien Gautier
2026-05-16 21:48:47 +02:00
parent 7ee7b2dadf
commit 4ce22d6d05
29 changed files with 1363 additions and 2 deletions
+48
View File
@@ -60,6 +60,9 @@ importers:
'@nestjs/swagger':
specifier: ^11.4.3
version: 11.4.3(@nestjs/common@11.1.21(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)
'@observablehq/plot':
specifier: ^0.6.17
version: 0.6.17
'@opentelemetry/api':
specifier: ^1.9.1
version: 1.9.1
@@ -132,6 +135,15 @@ importers:
cookie-parser:
specifier: ^1.4.7
version: 1.4.7
d3:
specifier: ^7.9.0
version: 7.9.0
d3-scale-chromatic:
specifier: ^3.1.0
version: 3.1.0
d3-shape:
specifier: ^3.2.0
version: 3.2.0
express-rate-limit:
specifier: ^8.5.1
version: 8.5.2(express@5.2.1)
@@ -280,6 +292,15 @@ importers:
'@types/cookie-parser':
specifier: ^1.4.10
version: 1.4.10(@types/express@5.0.6)
'@types/d3':
specifier: ^7.4.3
version: 7.4.3
'@types/d3-scale-chromatic':
specifier: ^3.1.0
version: 3.1.0
'@types/d3-shape':
specifier: ^3.1.8
version: 3.1.8
'@types/express':
specifier: ^5.0.6
version: 5.0.6
@@ -3199,6 +3220,10 @@ packages:
'@nx/workspace@22.7.1':
resolution: {integrity: sha512-wnBMgeogdGaRdxDDzZspSt1U87PMeYJhz1ygr42L9Lot9E5nCf17E85iyHl3YxcMSNslHxnHyTkq7MyQ8hHjdQ==}
'@observablehq/plot@0.6.17':
resolution: {integrity: sha512-/qaXP/7mc4MUS0s4cPPFASDRjtsWp85/TbfsciqDgU1HwYixbSbbytNuInD8AcTYC3xaxACgVX06agdfQy9W+g==}
engines: {node: '>=12'}
'@opentelemetry/api-logs@0.217.0':
resolution: {integrity: sha512-Cdq0jW2lknrNfrAm92MyEAvpe2cRsKjdnQLHUL6xRA4IVUnsWx6P65E7NcUO0Y+L4w1Aee5iV8FvjSwd+lrs9A==}
engines: {node: '>=8.0.0'}
@@ -5798,6 +5823,9 @@ packages:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
engines: {node: '>=8'}
binary-search-bounds@2.0.5:
resolution: {integrity: sha512-H0ea4Fd3lS1+sTEB2TgcLoK21lLhwEJzlQv3IN47pJS976Gx4zoWe0ak3q+uYh60ppQxg9F16Ri4tS1sfD4+jA==}
birpc@2.9.0:
resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==}
@@ -7641,6 +7669,9 @@ packages:
resolution: {integrity: sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==}
engines: {node: '>=10.13.0'}
interval-tree-1d@1.0.4:
resolution: {integrity: sha512-wY8QJH+6wNI0uh4pDQzMvl+478Qh7Rl4qLmqiluxALlNvl+I+o5x38Pw3/z7mDPTPS1dQalZJXsmbvxx5gclhQ==}
intl-messageformat@10.7.18:
resolution: {integrity: sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==}
@@ -7811,6 +7842,9 @@ packages:
resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==}
engines: {node: '>=0.10.0'}
isoformat@0.2.1:
resolution: {integrity: sha512-tFLRAygk9NqrRPhJSnNGh7g7oaVWDwR0wKh/GM2LgmPa50Eg4UfyaCO4I8k6EqJHl1/uh2RAD6g06n5ygEnrjQ==}
isomorphic-fetch@3.0.0:
resolution: {integrity: sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==}
@@ -15036,6 +15070,12 @@ snapshots:
- '@swc/core'
- debug
'@observablehq/plot@0.6.17':
dependencies:
d3: 7.9.0
interval-tree-1d: 1.0.4
isoformat: 0.2.1
'@opentelemetry/api-logs@0.217.0':
dependencies:
'@opentelemetry/api': 1.9.1
@@ -17682,6 +17722,8 @@ snapshots:
binary-extensions@2.3.0: {}
binary-search-bounds@2.0.5: {}
birpc@2.9.0: {}
bl@4.1.0:
@@ -19772,6 +19814,10 @@ snapshots:
interpret@3.1.1: {}
interval-tree-1d@1.0.4:
dependencies:
binary-search-bounds: 2.0.5
intl-messageformat@10.7.18:
dependencies:
'@formatjs/ecma402-abstract': 2.3.6
@@ -19897,6 +19943,8 @@ snapshots:
isobject@3.0.1: {}
isoformat@0.2.1: {}
isomorphic-fetch@3.0.0(encoding@0.1.13):
dependencies:
node-fetch: 2.7.0(encoding@0.1.13)