From 4ce22d6d056a51dbe3b5e8ea7bfa0e8ecae5a381 Mon Sep 17 00:00:00 2001 From: Julien Gautier Date: Sat, 16 May 2026 21:48:47 +0200 Subject: [PATCH] feat(shared-charts): foundations + bar / donut / stacked-bar components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 /<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). --- eslint.config.mjs | 6 +- libs/shared/charts/README.md | 7 + libs/shared/charts/eslint.config.mjs | 60 +++++++ libs/shared/charts/project.json | 13 ++ libs/shared/charts/src/index.ts | 7 + libs/shared/charts/src/lib/_internal/a11y.ts | 94 +++++++++++ .../src/lib/_internal/chart-envelope.scss | 113 +++++++++++++ .../charts/src/lib/_internal/chart-types.ts | 27 ++++ .../charts/src/lib/_internal/palette.ts | 73 +++++++++ .../charts/src/lib/bar-chart/bar-chart.html | 30 ++++ .../charts/src/lib/bar-chart/bar-chart.scss | 6 + .../src/lib/bar-chart/bar-chart.spec.ts | 93 +++++++++++ .../charts/src/lib/bar-chart/bar-chart.ts | 149 ++++++++++++++++++ .../src/lib/donut-chart/donut-chart.html | 30 ++++ .../src/lib/donut-chart/donut-chart.scss | 17 ++ .../src/lib/donut-chart/donut-chart.spec.ts | 78 +++++++++ .../charts/src/lib/donut-chart/donut-chart.ts | 138 ++++++++++++++++ .../stacked-bar-chart/stacked-bar-chart.html | 32 ++++ .../stacked-bar-chart/stacked-bar-chart.scss | 16 ++ .../stacked-bar-chart.spec.ts | 67 ++++++++ .../stacked-bar-chart/stacked-bar-chart.ts | 145 +++++++++++++++++ libs/shared/charts/src/test-setup.ts | 5 + libs/shared/charts/tsconfig.json | 23 +++ libs/shared/charts/tsconfig.lib.json | 27 ++++ libs/shared/charts/tsconfig.spec.json | 23 +++ libs/shared/charts/vite.config.mts | 28 ++++ package.json | 7 + pnpm-lock.yaml | 48 ++++++ tsconfig.base.json | 3 +- 29 files changed, 1363 insertions(+), 2 deletions(-) create mode 100644 libs/shared/charts/README.md create mode 100644 libs/shared/charts/eslint.config.mjs create mode 100644 libs/shared/charts/project.json create mode 100644 libs/shared/charts/src/index.ts create mode 100644 libs/shared/charts/src/lib/_internal/a11y.ts create mode 100644 libs/shared/charts/src/lib/_internal/chart-envelope.scss create mode 100644 libs/shared/charts/src/lib/_internal/chart-types.ts create mode 100644 libs/shared/charts/src/lib/_internal/palette.ts create mode 100644 libs/shared/charts/src/lib/bar-chart/bar-chart.html create mode 100644 libs/shared/charts/src/lib/bar-chart/bar-chart.scss create mode 100644 libs/shared/charts/src/lib/bar-chart/bar-chart.spec.ts create mode 100644 libs/shared/charts/src/lib/bar-chart/bar-chart.ts create mode 100644 libs/shared/charts/src/lib/donut-chart/donut-chart.html create mode 100644 libs/shared/charts/src/lib/donut-chart/donut-chart.scss create mode 100644 libs/shared/charts/src/lib/donut-chart/donut-chart.spec.ts create mode 100644 libs/shared/charts/src/lib/donut-chart/donut-chart.ts create mode 100644 libs/shared/charts/src/lib/stacked-bar-chart/stacked-bar-chart.html create mode 100644 libs/shared/charts/src/lib/stacked-bar-chart/stacked-bar-chart.scss create mode 100644 libs/shared/charts/src/lib/stacked-bar-chart/stacked-bar-chart.spec.ts create mode 100644 libs/shared/charts/src/lib/stacked-bar-chart/stacked-bar-chart.ts create mode 100644 libs/shared/charts/src/test-setup.ts create mode 100644 libs/shared/charts/tsconfig.json create mode 100644 libs/shared/charts/tsconfig.lib.json create mode 100644 libs/shared/charts/tsconfig.spec.json create mode 100644 libs/shared/charts/vite.config.mts diff --git a/eslint.config.mjs b/eslint.config.mjs index d55d975..683ede4 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -5,7 +5,11 @@ export default [ ...nx.configs['flat/typescript'], ...nx.configs['flat/javascript'], { - ignores: ['**/dist', '**/out-tsc', '**/vitest.config.*.timestamp*'], + "ignores": [ + "**/dist", + "**/out-tsc", + "**/vitest.config.*.timestamp*" + ] }, { files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'], diff --git a/libs/shared/charts/README.md b/libs/shared/charts/README.md new file mode 100644 index 0000000..9c02213 --- /dev/null +++ b/libs/shared/charts/README.md @@ -0,0 +1,7 @@ +# shared-charts + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test shared-charts` to execute the unit tests. diff --git a/libs/shared/charts/eslint.config.mjs b/libs/shared/charts/eslint.config.mjs new file mode 100644 index 0000000..f6dafae --- /dev/null +++ b/libs/shared/charts/eslint.config.mjs @@ -0,0 +1,60 @@ +import nx from "@nx/eslint-plugin"; +import baseConfig from "../../../eslint.config.mjs"; + +export default [ + ...nx.configs["flat/angular"], + ...nx.configs["flat/angular-template"], + ...baseConfig, + { + files: [ + "**/*.ts" + ], + rules: { + "@angular-eslint/directive-selector": [ + "error", + { + type: "attribute", + prefix: "lib", + style: "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "error", + { + type: "element", + prefix: "lib", + style: "kebab-case" + } + ], + // Per ADR-0023: the colour-blind-safe palette lives in + // `_internal/palette.ts` and is the *only* source of + // palettes. Importing `d3-scale-chromatic` anywhere else + // in the lib would bypass the a11y contract. + "no-restricted-imports": [ + "error", + { + paths: [ + { + name: "d3-scale-chromatic", + message: "Import palettes from `_internal/palette.ts`. Direct `d3-scale-chromatic` imports bypass the chart lib's colour-blind-safe contract (ADR-0023)." + } + ] + } + ] + } + }, + { + // Exempt the palette module itself — it IS the wrapper. + files: ["**/lib/_internal/palette.ts"], + rules: { + "no-restricted-imports": "off" + } + }, + { + files: [ + "**/*.html" + ], + // Override or add rules here + rules: {} + } +]; diff --git a/libs/shared/charts/project.json b/libs/shared/charts/project.json new file mode 100644 index 0000000..758a9f0 --- /dev/null +++ b/libs/shared/charts/project.json @@ -0,0 +1,13 @@ +{ + "name": "shared-charts", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/shared/charts/src", + "prefix": "lib", + "projectType": "library", + "tags": ["scope:shared", "type:shared"], + "targets": { + "lint": { + "executor": "@nx/eslint:lint" + } + } +} diff --git a/libs/shared/charts/src/index.ts b/libs/shared/charts/src/index.ts new file mode 100644 index 0000000..cce5b5c --- /dev/null +++ b/libs/shared/charts/src/index.ts @@ -0,0 +1,7 @@ +export { BarChart, type BarChartInputs } from './lib/bar-chart/bar-chart'; +export { DonutChart, type DonutChartInputs } from './lib/donut-chart/donut-chart'; +export { + StackedBarChart, + type StackedBarChartInputs, +} from './lib/stacked-bar-chart/stacked-bar-chart'; +export type { ChartBaseInputs, ColorScheme } from './lib/_internal/chart-types'; diff --git a/libs/shared/charts/src/lib/_internal/a11y.ts b/libs/shared/charts/src/lib/_internal/a11y.ts new file mode 100644 index 0000000..6e8f821 --- /dev/null +++ b/libs/shared/charts/src/lib/_internal/a11y.ts @@ -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'; +} diff --git a/libs/shared/charts/src/lib/_internal/chart-envelope.scss b/libs/shared/charts/src/lib/_internal/chart-envelope.scss new file mode 100644 index 0000000..2971c2e --- /dev/null +++ b/libs/shared/charts/src/lib/_internal/chart-envelope.scss @@ -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; + } +} diff --git a/libs/shared/charts/src/lib/_internal/chart-types.ts b/libs/shared/charts/src/lib/_internal/chart-types.ts new file mode 100644 index 0000000..8ea4ff9 --- /dev/null +++ b/libs/shared/charts/src/lib/_internal/chart-types.ts @@ -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; +} diff --git a/libs/shared/charts/src/lib/_internal/palette.ts b/libs/shared/charts/src/lib/_internal/palette.ts new file mode 100644 index 0000000..a14acf6 --- /dev/null +++ b/libs/shared/charts/src/lib/_internal/palette.ts @@ -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)); +} diff --git a/libs/shared/charts/src/lib/bar-chart/bar-chart.html b/libs/shared/charts/src/lib/bar-chart/bar-chart.html new file mode 100644 index 0000000..f3e682a --- /dev/null +++ b/libs/shared/charts/src/lib/bar-chart/bar-chart.html @@ -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> diff --git a/libs/shared/charts/src/lib/bar-chart/bar-chart.scss b/libs/shared/charts/src/lib/bar-chart/bar-chart.scss new file mode 100644 index 0000000..1ff1388 --- /dev/null +++ b/libs/shared/charts/src/lib/bar-chart/bar-chart.scss @@ -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. diff --git a/libs/shared/charts/src/lib/bar-chart/bar-chart.spec.ts b/libs/shared/charts/src/lib/bar-chart/bar-chart.spec.ts new file mode 100644 index 0000000..1470a70 --- /dev/null +++ b/libs/shared/charts/src/lib/bar-chart/bar-chart.spec.ts @@ -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'); + }); +}); diff --git a/libs/shared/charts/src/lib/bar-chart/bar-chart.ts b/libs/shared/charts/src/lib/bar-chart/bar-chart.ts new file mode 100644 index 0000000..e406f4f --- /dev/null +++ b/libs/shared/charts/src/lib/bar-chart/bar-chart.ts @@ -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); + } +} diff --git a/libs/shared/charts/src/lib/donut-chart/donut-chart.html b/libs/shared/charts/src/lib/donut-chart/donut-chart.html new file mode 100644 index 0000000..978b830 --- /dev/null +++ b/libs/shared/charts/src/lib/donut-chart/donut-chart.html @@ -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> diff --git a/libs/shared/charts/src/lib/donut-chart/donut-chart.scss b/libs/shared/charts/src/lib/donut-chart/donut-chart.scss new file mode 100644 index 0000000..1e6ac03 --- /dev/null +++ b/libs/shared/charts/src/lib/donut-chart/donut-chart.scss @@ -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; +} diff --git a/libs/shared/charts/src/lib/donut-chart/donut-chart.spec.ts b/libs/shared/charts/src/lib/donut-chart/donut-chart.spec.ts new file mode 100644 index 0000000..0d4b2dd --- /dev/null +++ b/libs/shared/charts/src/lib/donut-chart/donut-chart.spec.ts @@ -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'); + }); +}); diff --git a/libs/shared/charts/src/lib/donut-chart/donut-chart.ts b/libs/shared/charts/src/lib/donut-chart/donut-chart.ts new file mode 100644 index 0000000..1bc1938 --- /dev/null +++ b/libs/shared/charts/src/lib/donut-chart/donut-chart.ts @@ -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); + } +} diff --git a/libs/shared/charts/src/lib/stacked-bar-chart/stacked-bar-chart.html b/libs/shared/charts/src/lib/stacked-bar-chart/stacked-bar-chart.html new file mode 100644 index 0000000..b6aa643 --- /dev/null +++ b/libs/shared/charts/src/lib/stacked-bar-chart/stacked-bar-chart.html @@ -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> diff --git a/libs/shared/charts/src/lib/stacked-bar-chart/stacked-bar-chart.scss b/libs/shared/charts/src/lib/stacked-bar-chart/stacked-bar-chart.scss new file mode 100644 index 0000000..63f2847 --- /dev/null +++ b/libs/shared/charts/src/lib/stacked-bar-chart/stacked-bar-chart.scss @@ -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; + } + } +} diff --git a/libs/shared/charts/src/lib/stacked-bar-chart/stacked-bar-chart.spec.ts b/libs/shared/charts/src/lib/stacked-bar-chart/stacked-bar-chart.spec.ts new file mode 100644 index 0000000..f049a9e --- /dev/null +++ b/libs/shared/charts/src/lib/stacked-bar-chart/stacked-bar-chart.spec.ts @@ -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'); + }); +}); diff --git a/libs/shared/charts/src/lib/stacked-bar-chart/stacked-bar-chart.ts b/libs/shared/charts/src/lib/stacked-bar-chart/stacked-bar-chart.ts new file mode 100644 index 0000000..d2d81db --- /dev/null +++ b/libs/shared/charts/src/lib/stacked-bar-chart/stacked-bar-chart.ts @@ -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); + } +} diff --git a/libs/shared/charts/src/test-setup.ts b/libs/shared/charts/src/test-setup.ts new file mode 100644 index 0000000..17b7965 --- /dev/null +++ b/libs/shared/charts/src/test-setup.ts @@ -0,0 +1,5 @@ +import '@angular/compiler'; +import '@analogjs/vitest-angular/setup-snapshots'; +import { setupTestBed } from '@analogjs/vitest-angular/setup-testbed'; + +setupTestBed(); diff --git a/libs/shared/charts/tsconfig.json b/libs/shared/charts/tsconfig.json new file mode 100644 index 0000000..116e088 --- /dev/null +++ b/libs/shared/charts/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "emitDecoratorMetadata": false, + "module": "preserve" + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/shared/charts/tsconfig.lib.json b/libs/shared/charts/tsconfig.lib.json new file mode 100644 index 0000000..a89f7da --- /dev/null +++ b/libs/shared/charts/tsconfig.lib.json @@ -0,0 +1,27 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": true, + "outDir": "../../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "include": ["src/**/*.ts"], + "exclude": [ + "src/**/*.spec.ts", + "src/**/*.test.ts", + "vite.config.ts", + "vite.config.mts", + "vitest.config.ts", + "vitest.config.mts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.test.js", + "src/**/*.spec.js", + "src/**/*.test.jsx", + "src/**/*.spec.jsx", + "src/test-setup.ts" + ] +} diff --git a/libs/shared/charts/tsconfig.spec.json b/libs/shared/charts/tsconfig.spec.json new file mode 100644 index 0000000..39a8c3f --- /dev/null +++ b/libs/shared/charts/tsconfig.spec.json @@ -0,0 +1,23 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "types": ["vitest/globals", "vitest/importMeta", "vite/client", "node", "vitest"] + }, + "include": [ + "vite.config.ts", + "vite.config.mts", + "vitest.config.ts", + "vitest.config.mts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.test.js", + "src/**/*.spec.js", + "src/**/*.test.jsx", + "src/**/*.spec.jsx", + "src/**/*.d.ts" + ], + "files": ["src/test-setup.ts"] +} diff --git a/libs/shared/charts/vite.config.mts b/libs/shared/charts/vite.config.mts new file mode 100644 index 0000000..1910ca2 --- /dev/null +++ b/libs/shared/charts/vite.config.mts @@ -0,0 +1,28 @@ +/// <reference types='vitest' /> +import { defineConfig } from 'vite'; +import angular from '@analogjs/vite-plugin-angular'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; +import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin'; + +export default defineConfig(() => ({ + root: __dirname, + cacheDir: '../../../node_modules/.vite/libs/shared/charts', + plugins: [angular(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])], + // Uncomment this if you are using workers. + // worker: { + // plugins: () => [ nxViteTsPaths() ], + // }, + test: { + name: 'shared-charts', + watch: false, + globals: true, + environment: 'jsdom', + include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + setupFiles: ['src/test-setup.ts'], + reporters: ['default'], + coverage: { + reportsDirectory: '../../../coverage/libs/shared/charts', + provider: 'v8' as const, + } + }, +})); diff --git a/package.json b/package.json index d102357..74ab440 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,9 @@ "@swc/helpers": "0.5.21", "@tailwindcss/postcss": "^4.2.4", "@types/cookie-parser": "^1.4.10", + "@types/d3": "^7.4.3", + "@types/d3-scale-chromatic": "^3.1.0", + "@types/d3-shape": "^3.1.8", "@types/express": "^5.0.6", "@types/express-session": "^1.19.0", "@types/jest": "^30.0.0", @@ -143,6 +146,7 @@ "@nestjs/core": "^11.0.0", "@nestjs/platform-express": "^11.0.0", "@nestjs/swagger": "^11.4.3", + "@observablehq/plot": "^0.6.17", "@opentelemetry/api": "^1.9.1", "@opentelemetry/exporter-trace-otlp-http": "^0.217.0", "@opentelemetry/exporter-trace-otlp-proto": "^0.217.0", @@ -167,6 +171,9 @@ "class-validator": "^0.15.1", "connect-redis": "^9.0.0", "cookie-parser": "^1.4.7", + "d3": "^7.9.0", + "d3-scale-chromatic": "^3.1.0", + "d3-shape": "^3.2.0", "express-rate-limit": "^8.5.1", "express-session": "^1.19.0", "helmet": "^8.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 308caf7..5166978 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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) diff --git a/tsconfig.base.json b/tsconfig.base.json index c50f65f..c18fafd 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -6,7 +6,8 @@ "shared-util": ["./libs/shared/util/src/index.ts"], "shared-ui": ["./libs/shared/ui/src/index.ts"], "feature-auth": ["./libs/feature/auth/src/index.ts"], - "shared-state": ["./libs/shared/state/src/index.ts"] + "shared-state": ["./libs/shared/state/src/index.ts"], + "shared-charts": ["./libs/shared/charts/src/index.ts"] }, "sourceMap": true, "declaration": false, -- 2.30.2