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'; /** * `` — 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 extends ChartBaseInputs { 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 { private readonly host = inject(ElementRef); readonly data = input.required(); readonly xKey = input.required(); readonly yKey = input.required(); readonly seriesKey = input.required(); readonly caption = input.required(); readonly description = input.required(); readonly ariaLabel = input.required(); readonly xLabel = input(undefined); readonly yLabel = input(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(); 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( '.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); } }