feat(shared-charts): foundations + bar / donut / stacked-bar components
CI / commits (pull_request) Successful in 3m31s
CI / scan (pull_request) Successful in 4m28s
CI / check (pull_request) Failing after 8m9s
CI / a11y (pull_request) Successful in 3m56s
Docs site / build (pull_request) Successful in 4m59s
CI / perf (pull_request) Successful in 10m8s

Implementation of ADR-0023 (PR 1). The next chantier wires these
components into the audit-log page; this PR delivers the lib + the
three starter chart types + the a11y contract.

Deps:
  * d3 + @types/d3 (top-level)
  * d3-shape + @types/d3-shape (donut's pie/arc generators)
  * d3-scale-chromatic + @types/d3-scale-chromatic (palettes)
  * @observablehq/plot (bar + stacked-bar)

Lib `libs/shared/charts/`:
  * `_internal/palette.ts` — single source of palettes. Sequential
    (Viridis / Cividis for dark mode) + categorical (ColorBrewer
    Set2). Colour-blind-safe per the ColorBrewer review.
  * `_internal/a11y.ts` — chartId generator, SVG <title>/<desc>
    injector, `findChartSvg` for Plot's legend-wrapped output,
    prefers-reduced-motion guard, theme resolver.
  * `_internal/chart-envelope.scss` — shared figure/caption/
    fallback rules consumed via `@use` by each chart's .scss.
    Extracted at the third consumer per CLAUDE.md's "three similar
    things" rule.
  * `_internal/chart-types.ts` — common `ChartBaseInputs<T>`
    interface every component extends.

Three Plot-or-D3-backed components:
  * `<lib-bar-chart>` — Plot.barY, categorical-by-default palette.
  * `<lib-donut-chart>` — raw d3-shape (pie + arc); Plot has no
    donut mark so we reach for D3 directly. Each slice carries its
    own <title> child for SVG-tooltip accessibility.
  * `<lib-stacked-bar-chart>` — Plot.barY with `fill` set to the
    series key (auto-stacked), legend on.

Each component honours ADR-0023's six commitments:
  1. <figure role="img" aria-labelledby aria-describedby>
  2. SVG <title> + <desc> as first two children (injected post-
     render because Plot doesn't emit them)
  3. <details> tabular fallback rendering every data point
  4. Palette from the canonical list (no ad-hoc colours)
  5. AA-contrast text on axis ticks (theme-aware grayscale via
     the .chart-canvas svg text rule)
  6. prefers-reduced-motion → data-no-transitions marker on the
     SVG, CSS strips animations.

A custom ESLint rule in `libs/shared/charts/eslint.config.mjs`
bans direct imports of `d3-scale-chromatic` outside
`_internal/palette.ts`, so a future contributor can't bypass the
colour-blind-safe contract.

Tests: 13 specs across the three components. Each component covers
the figure ARIA contract, the SVG title/desc injection, the
tabular fallback, and data-reactivity. Donut spec additionally
asserts per-slice <title> labels for screen readers.

Verification:
  * `pnpm nx lint test build --projects=shared-charts,portal-shell,portal-admin`
    — 12/12 tasks green.
  * `pnpm nx build portal-shell --configuration=production` —
    i18n-strict prod build clean (the lib ships no i18n marks
    since axis labels are caller-supplied).
This commit is contained in:
Julien Gautier
2026-05-16 21:48:47 +02:00
parent 7ee7b2dadf
commit 4ce22d6d05
29 changed files with 1363 additions and 2 deletions
+5 -1
View File
@@ -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'],
+7
View File
@@ -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.
+60
View File
@@ -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: {}
}
];
+13
View File
@@ -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"
}
}
}
+7
View File
@@ -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);
}
}
+5
View File
@@ -0,0 +1,5 @@
import '@angular/compiler';
import '@analogjs/vitest-angular/setup-snapshots';
import { setupTestBed } from '@analogjs/vitest-angular/setup-testbed';
setupTestBed();
+23
View File
@@ -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"
}
]
}
+27
View File
@@ -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"
]
}
+23
View File
@@ -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"]
}
+28
View File
@@ -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,
}
},
}));
+7
View File
@@ -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",
+48
View File
@@ -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
View File
@@ -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,