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
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:
@@ -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