feat(shared-charts): foundations + bar / donut / stacked-bar components (#171)
## Summary Implementation of [ADR-0023](docs/decisions/0023-charts-d3-observable-plot.md) — the foundations of the workspace's chart library. PR 2 of the chantier: | PR | Périmètre | | --- | --- | | PR 1 ✅ | ADR-0023 — decision + a11y contract + bundle plan. | | **PR 2 (this one)** | `libs/shared/charts/` foundations + `<lib-bar-chart>`, `<lib-donut-chart>`, `<lib-stacked-bar-chart>`. | | PR 3 | Integration on the `/audit` page — daily-volume bar + outcome-breakdown donut + event-type-over-time stacked bar. | ## What lands ### Workspace deps ``` d3 — top-level toolkit, types via @types/d3 d3-shape — used directly by <lib-donut-chart> d3-scale-chromatic — colour-blind-safe palette source @observablehq/plot — declarative layer over D3, used by bar + stacked-bar ``` All four (+ matching `@types/*`) land in the workspace root `devDependencies`. Tree-shaken at build time per ADR-0023's bundle plan. ### New lib `libs/shared/charts/` ``` libs/shared/charts/src/lib/ ├── _internal/ ← single source of truth for the a11y contract │ ├── a11y.ts ← chartId, findChartSvg, injectSvgTitleDesc, prefersReducedMotion, resolveTheme │ ├── chart-envelope.scss ← shared figure / caption / fallback / dark-mode rules │ ├── chart-types.ts ← `ChartBaseInputs<T>` extended by each component │ └── palette.ts ← Viridis / Cividis (sequential) + ColorBrewer Set2 (categorical) ├── bar-chart/ ← Plot.barY ├── donut-chart/ ← raw d3-shape (pie + arc); Plot has no donut mark └── stacked-bar-chart/ ← Plot.barY with `fill: <seriesKey>` (auto-stacked, legend on) ``` ### A11y contract baked in for v1 Per ADR-0023's six commitments, every chart component produces (and unit-tests for): 1. `<figure role="img" aria-labelledby aria-describedby>` wrapping the SVG. 2. SVG `<title>` + `<desc>` as the **first two children** — injected post-render via `injectSvgTitleDesc` because Plot doesn't emit them itself. `findChartSvg` handles both Plot output shapes (bare SVG, or `<figure>` wrapping a legend + SVG for `legend: true` configs). 3. A `<details>` disclosure with a `<table>` rendering every data point — the keyboard-navigable / screen-reader-friendly fallback for non-visual users. 4. Palette from `_internal/palette.ts` only — Viridis / Cividis for sequential, ColorBrewer Set2 for categorical. Both colour-blind-safe. 5. AA-contrast axis text via `:where(.dark)` flips in `_internal/chart-envelope.scss`. 6. `prefers-reduced-motion` → `data-no-transitions` marker on the SVG, CSS strips animations + transitions. A custom ESLint rule in [`libs/shared/charts/eslint.config.mjs`](libs/shared/charts/eslint.config.mjs) bans direct imports of `d3-scale-chromatic` outside `_internal/palette.ts` so a future contributor can't bypass the colour-blind-safe contract. ### Component contract Every `<lib-*-chart>` exposes the same Signal-based shape per ADR-0023: ```ts [data]: readonly T[]; [caption]: string; [description]: string; [ariaLabel]: string; [colorScheme]?: 'sequential' | 'categorical'; // + chart-specific keys (xKey, yKey, categoryKey, valueKey, seriesKey, …) ``` Re-renders triggered by Angular's `effect()` on input changes; the previous SVG is `replaceChildren`-d out so there's no DOM accumulation across data updates. ## Notes for the reviewer - **Why three SCSS files importing one shared envelope?** Extracted at the third consumer per CLAUDE.md's "three similar lines is better than a premature abstraction" rule — bar + donut + stacked-bar share ~70 LOC of figure / caption / fallback / dark-mode chrome. `_internal/chart-envelope.scss` is the consolidation; each chart's `.scss` is now 4-20 LOC of chart-specific tweaks. - **Why is `<lib-donut-chart>` raw D3 rather than Plot?** Plot's design philosophy explicitly excludes pie/donut marks ("a bar chart is almost always more legible"). The audit-log outcome breakdown reads naturally as a donut (the centre carries the total). Raw `d3-shape` is the lower-level fallback ADR-0023 reserves precisely for this kind of case; the component's API is identical to the Plot-backed siblings. - **Why does the donut also stamp per-slice `<title>`?** Belt-and-suspenders. The top-level SVG `<title>` reads the caption; per-slice `<title>` reads "category: value" on hover (the SVG-native tooltip convention) for keyboard / screen-reader users who land on a specific slice. - **Why no `pnpm.overrides` adjustment for `d3-*` transitives?** None of the new deps brought a vulnerability in this install. The `pnpm audit --audit-level=moderate` gate from #161 stays green. - **What's deliberately deferred to PR 3?** The `/audit`-page integration — data aggregation from the current page (`AdminAuditPage` rows already loaded), the actual `<lib-*-chart>` placements above the existing table, the i18n strings for the captions / descriptions / aria-labels. No mock SAMPLE data shipped in this PR — every test uses local fixtures so the lib stays decoupled from any specific consumer. ## Test plan - [x] `pnpm nx test shared-charts` — **13 specs pass** across the three components. - [x] `pnpm nx lint shared-charts` — clean, including the custom `no-restricted-imports` guard on `d3-scale-chromatic`. - [x] `pnpm nx build shared-charts` — clean (TS strict + ng-packagr). - [x] `pnpm nx run-many -t lint test build --projects=shared-charts,portal-shell,portal-admin` — 12/12 tasks green. - [x] `pnpm nx build portal-shell --configuration=production` — i18n-strict prod build clean. The lib ships no i18n marks (axis labels are caller-supplied per ADR-0023's i18n posture), so no xlf entry change here. - [ ] **Visual smoke (deferred to PR 3 when the components are placed on `/audit`)** — `<figure>` + caption visible, SVG `<title>` exposed by VoiceOver / NVDA, `<details>` fallback expands to a table, dark-mode toggle flips axis text + Cividis palette, `prefers-reduced-motion` strips Plot's fade-in. ## What's next PR 3 wires the three components onto the `/audit` page: aggregates `AdminAuditPage.items` client-side into the three required shapes (daily totals, outcome counts, daily-by-event-type pivots), places them above the existing filter form, ships the i18n strings for the captions and descriptions in `messages.fr.xlf`. Bundle impact on the lazy `audit` chunk gets verified there (the ~65 KB gzip plan from the ADR). --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #171
This commit was merged in pull request #171.
This commit is contained in:
+5
-1
@@ -5,7 +5,11 @@ export default [
|
|||||||
...nx.configs['flat/typescript'],
|
...nx.configs['flat/typescript'],
|
||||||
...nx.configs['flat/javascript'],
|
...nx.configs['flat/javascript'],
|
||||||
{
|
{
|
||||||
ignores: ['**/dist', '**/out-tsc', '**/vitest.config.*.timestamp*'],
|
"ignores": [
|
||||||
|
"**/dist",
|
||||||
|
"**/out-tsc",
|
||||||
|
"**/vitest.config.*.timestamp*"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
|
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",
|
"@swc/helpers": "0.5.21",
|
||||||
"@tailwindcss/postcss": "^4.2.4",
|
"@tailwindcss/postcss": "^4.2.4",
|
||||||
"@types/cookie-parser": "^1.4.10",
|
"@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": "^5.0.6",
|
||||||
"@types/express-session": "^1.19.0",
|
"@types/express-session": "^1.19.0",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
@@ -143,6 +146,7 @@
|
|||||||
"@nestjs/core": "^11.0.0",
|
"@nestjs/core": "^11.0.0",
|
||||||
"@nestjs/platform-express": "^11.0.0",
|
"@nestjs/platform-express": "^11.0.0",
|
||||||
"@nestjs/swagger": "^11.4.3",
|
"@nestjs/swagger": "^11.4.3",
|
||||||
|
"@observablehq/plot": "^0.6.17",
|
||||||
"@opentelemetry/api": "^1.9.1",
|
"@opentelemetry/api": "^1.9.1",
|
||||||
"@opentelemetry/exporter-trace-otlp-http": "^0.217.0",
|
"@opentelemetry/exporter-trace-otlp-http": "^0.217.0",
|
||||||
"@opentelemetry/exporter-trace-otlp-proto": "^0.217.0",
|
"@opentelemetry/exporter-trace-otlp-proto": "^0.217.0",
|
||||||
@@ -167,6 +171,9 @@
|
|||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
"connect-redis": "^9.0.0",
|
"connect-redis": "^9.0.0",
|
||||||
"cookie-parser": "^1.4.7",
|
"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-rate-limit": "^8.5.1",
|
||||||
"express-session": "^1.19.0",
|
"express-session": "^1.19.0",
|
||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
|
|||||||
Generated
+48
@@ -60,6 +60,9 @@ importers:
|
|||||||
'@nestjs/swagger':
|
'@nestjs/swagger':
|
||||||
specifier: ^11.4.3
|
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)
|
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':
|
'@opentelemetry/api':
|
||||||
specifier: ^1.9.1
|
specifier: ^1.9.1
|
||||||
version: 1.9.1
|
version: 1.9.1
|
||||||
@@ -132,6 +135,15 @@ importers:
|
|||||||
cookie-parser:
|
cookie-parser:
|
||||||
specifier: ^1.4.7
|
specifier: ^1.4.7
|
||||||
version: 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:
|
express-rate-limit:
|
||||||
specifier: ^8.5.1
|
specifier: ^8.5.1
|
||||||
version: 8.5.2(express@5.2.1)
|
version: 8.5.2(express@5.2.1)
|
||||||
@@ -280,6 +292,15 @@ importers:
|
|||||||
'@types/cookie-parser':
|
'@types/cookie-parser':
|
||||||
specifier: ^1.4.10
|
specifier: ^1.4.10
|
||||||
version: 1.4.10(@types/express@5.0.6)
|
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':
|
'@types/express':
|
||||||
specifier: ^5.0.6
|
specifier: ^5.0.6
|
||||||
version: 5.0.6
|
version: 5.0.6
|
||||||
@@ -3199,6 +3220,10 @@ packages:
|
|||||||
'@nx/workspace@22.7.1':
|
'@nx/workspace@22.7.1':
|
||||||
resolution: {integrity: sha512-wnBMgeogdGaRdxDDzZspSt1U87PMeYJhz1ygr42L9Lot9E5nCf17E85iyHl3YxcMSNslHxnHyTkq7MyQ8hHjdQ==}
|
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':
|
'@opentelemetry/api-logs@0.217.0':
|
||||||
resolution: {integrity: sha512-Cdq0jW2lknrNfrAm92MyEAvpe2cRsKjdnQLHUL6xRA4IVUnsWx6P65E7NcUO0Y+L4w1Aee5iV8FvjSwd+lrs9A==}
|
resolution: {integrity: sha512-Cdq0jW2lknrNfrAm92MyEAvpe2cRsKjdnQLHUL6xRA4IVUnsWx6P65E7NcUO0Y+L4w1Aee5iV8FvjSwd+lrs9A==}
|
||||||
engines: {node: '>=8.0.0'}
|
engines: {node: '>=8.0.0'}
|
||||||
@@ -5798,6 +5823,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
|
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
binary-search-bounds@2.0.5:
|
||||||
|
resolution: {integrity: sha512-H0ea4Fd3lS1+sTEB2TgcLoK21lLhwEJzlQv3IN47pJS976Gx4zoWe0ak3q+uYh60ppQxg9F16Ri4tS1sfD4+jA==}
|
||||||
|
|
||||||
birpc@2.9.0:
|
birpc@2.9.0:
|
||||||
resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==}
|
resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==}
|
||||||
|
|
||||||
@@ -7641,6 +7669,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==}
|
resolution: {integrity: sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==}
|
||||||
engines: {node: '>=10.13.0'}
|
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:
|
intl-messageformat@10.7.18:
|
||||||
resolution: {integrity: sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==}
|
resolution: {integrity: sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==}
|
||||||
|
|
||||||
@@ -7811,6 +7842,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==}
|
resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
isoformat@0.2.1:
|
||||||
|
resolution: {integrity: sha512-tFLRAygk9NqrRPhJSnNGh7g7oaVWDwR0wKh/GM2LgmPa50Eg4UfyaCO4I8k6EqJHl1/uh2RAD6g06n5ygEnrjQ==}
|
||||||
|
|
||||||
isomorphic-fetch@3.0.0:
|
isomorphic-fetch@3.0.0:
|
||||||
resolution: {integrity: sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==}
|
resolution: {integrity: sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==}
|
||||||
|
|
||||||
@@ -15036,6 +15070,12 @@ snapshots:
|
|||||||
- '@swc/core'
|
- '@swc/core'
|
||||||
- debug
|
- 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':
|
'@opentelemetry/api-logs@0.217.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@opentelemetry/api': 1.9.1
|
'@opentelemetry/api': 1.9.1
|
||||||
@@ -17682,6 +17722,8 @@ snapshots:
|
|||||||
|
|
||||||
binary-extensions@2.3.0: {}
|
binary-extensions@2.3.0: {}
|
||||||
|
|
||||||
|
binary-search-bounds@2.0.5: {}
|
||||||
|
|
||||||
birpc@2.9.0: {}
|
birpc@2.9.0: {}
|
||||||
|
|
||||||
bl@4.1.0:
|
bl@4.1.0:
|
||||||
@@ -19772,6 +19814,10 @@ snapshots:
|
|||||||
|
|
||||||
interpret@3.1.1: {}
|
interpret@3.1.1: {}
|
||||||
|
|
||||||
|
interval-tree-1d@1.0.4:
|
||||||
|
dependencies:
|
||||||
|
binary-search-bounds: 2.0.5
|
||||||
|
|
||||||
intl-messageformat@10.7.18:
|
intl-messageformat@10.7.18:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@formatjs/ecma402-abstract': 2.3.6
|
'@formatjs/ecma402-abstract': 2.3.6
|
||||||
@@ -19897,6 +19943,8 @@ snapshots:
|
|||||||
|
|
||||||
isobject@3.0.1: {}
|
isobject@3.0.1: {}
|
||||||
|
|
||||||
|
isoformat@0.2.1: {}
|
||||||
|
|
||||||
isomorphic-fetch@3.0.0(encoding@0.1.13):
|
isomorphic-fetch@3.0.0(encoding@0.1.13):
|
||||||
dependencies:
|
dependencies:
|
||||||
node-fetch: 2.7.0(encoding@0.1.13)
|
node-fetch: 2.7.0(encoding@0.1.13)
|
||||||
|
|||||||
+2
-1
@@ -6,7 +6,8 @@
|
|||||||
"shared-util": ["./libs/shared/util/src/index.ts"],
|
"shared-util": ["./libs/shared/util/src/index.ts"],
|
||||||
"shared-ui": ["./libs/shared/ui/src/index.ts"],
|
"shared-ui": ["./libs/shared/ui/src/index.ts"],
|
||||||
"feature-auth": ["./libs/feature/auth/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,
|
"sourceMap": true,
|
||||||
"declaration": false,
|
"declaration": false,
|
||||||
|
|||||||
Reference in New Issue
Block a user