feat(shared-charts): foundations + bar / donut / stacked-bar components #171
+5
-1
@@ -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'],
|
||||
|
||||
@@ -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.
|
||||
@@ -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: {}
|
||||
}
|
||||
];
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import '@angular/compiler';
|
||||
import '@analogjs/vitest-angular/setup-snapshots';
|
||||
import { setupTestBed } from '@analogjs/vitest-angular/setup-testbed';
|
||||
|
||||
setupTestBed();
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -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",
|
||||
|
||||
Generated
+48
@@ -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)
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user