import { ChangeDetectionStrategy, Component, ElementRef, ViewEncapsulation, computed, effect, inject, input, } from '@angular/core'; import * as Plot from '@observablehq/plot'; import { chartId, findChartSvg, injectSvgTitleDesc, prefersReducedMotion } from '../_internal/a11y'; import type { ChartBaseInputs } from '../_internal/chart-types'; import { DEFAULT_BAR_FILL } from '../_internal/palette'; /** * `` — 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`. * * Every bar is filled with the same colour by default — a single- * series bar chart's X axis is typically a *label* (day, bucket, * name) rather than a category that meaningfully encodes colour, so * rotating through a categorical palette adds visual noise without * carrying signal. Consumers that genuinely want per-bar colouring * (a categorical axis where each bar IS a category with semantic * meaning) should use `` instead, or pass an * explicit `fillColor` if they really do want a different shade. * * Per the lib's a11y contract: * - outer `
` carries `role="img"` + `aria-labelledby` + * `aria-describedby`, * - SVG gets `` + `<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, * - fill defaults to `DEFAULT_BAR_FILL` from * `_internal/palette.ts` (a colour-blind-safe blue), * - `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; /** * Override the default single fill colour. Caller-owned — keep it * colour-blind-safe and contrasted against the surrounding surface. * Most consumers should leave it unset and accept the lib default. */ readonly fillColor?: 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 fillColor = input<string>(DEFAULT_BAR_FILL); 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 fill = this.fillColor(); 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 }, marks: [ Plot.barY(data as Plot.Data, { x: xKey, y: yKey, // Literal-string fill — Plot interprets non-key strings as // a fixed colour. Same shade on every bar, by design. fill, // 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); } }