@neuravision/ng-construct 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11303,6 +11303,2291 @@ function cssAttrEscape(value) {
11303
11303
  return value.replace(/(["\\])/g, '\\$1');
11304
11304
  }
11305
11305
 
11306
+ /**
11307
+ * Shared data contracts for the `af-*-chart` family.
11308
+ *
11309
+ * The shapes mirror the dashboard stats DTO (`{ value, delta, series, breakdown }`)
11310
+ * so server aggregates map onto the charts with no client-side reshaping:
11311
+ * a time/category series feeds {@link AfChartSeries}, a breakdown feeds
11312
+ * {@link AfChartDatum}.
11313
+ */
11314
+ /** Maximum number of distinct palette colours before they repeat. */
11315
+ const AF_CHART_PALETTE_SIZE = 8;
11316
+
11317
+ /**
11318
+ * Accessible data-table equivalent of a chart, rendered as a real semantic
11319
+ * `<table>`. Every `af-*-chart` embeds one inside `.ct-chart__table-wrap`, which
11320
+ * keeps it visually hidden but available to assistive technology by default and
11321
+ * reveals it when the consumer toggles `.ct-chart--show-table`.
11322
+ *
11323
+ * This guarantees the chart's information is never conveyed by colour or shape
11324
+ * alone (WCAG 1.4.1) and gives screen-reader and keyboard users the exact values.
11325
+ *
11326
+ * @docs-private — consumers use it indirectly via the chart components.
11327
+ */
11328
+ class AfChartDataTableComponent {
11329
+ /** Table model (caption, headers, rows) describing the chart's data. */
11330
+ model = input.required(...(ngDevMode ? [{ debugName: "model" }] : []));
11331
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AfChartDataTableComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
11332
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.2", type: AfChartDataTableComponent, isStandalone: true, selector: "af-chart-data-table", inputs: { model: { classPropertyName: "model", publicName: "model", isSignal: true, isRequired: true, transformFunction: null } }, host: { styleAttribute: "display: contents" }, ngImport: i0, template: `
11333
+ @if (model(); as m) {
11334
+ <table class="ct-chart__table">
11335
+ <caption>{{ m.caption }}</caption>
11336
+ <thead>
11337
+ <tr>
11338
+ @for (header of m.headers; track $index) {
11339
+ <th scope="col">{{ header }}</th>
11340
+ }
11341
+ </tr>
11342
+ </thead>
11343
+ <tbody>
11344
+ @for (row of m.rows; track $index) {
11345
+ <tr>
11346
+ @for (cell of row; track $index) {
11347
+ @if ($first) {
11348
+ <th scope="row">{{ cell }}</th>
11349
+ } @else {
11350
+ <td>{{ cell }}</td>
11351
+ }
11352
+ }
11353
+ </tr>
11354
+ }
11355
+ </tbody>
11356
+ </table>
11357
+ }
11358
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
11359
+ }
11360
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AfChartDataTableComponent, decorators: [{
11361
+ type: Component,
11362
+ args: [{
11363
+ selector: 'af-chart-data-table',
11364
+ changeDetection: ChangeDetectionStrategy.OnPush,
11365
+ host: { style: 'display: contents' },
11366
+ template: `
11367
+ @if (model(); as m) {
11368
+ <table class="ct-chart__table">
11369
+ <caption>{{ m.caption }}</caption>
11370
+ <thead>
11371
+ <tr>
11372
+ @for (header of m.headers; track $index) {
11373
+ <th scope="col">{{ header }}</th>
11374
+ }
11375
+ </tr>
11376
+ </thead>
11377
+ <tbody>
11378
+ @for (row of m.rows; track $index) {
11379
+ <tr>
11380
+ @for (cell of row; track $index) {
11381
+ @if ($first) {
11382
+ <th scope="row">{{ cell }}</th>
11383
+ } @else {
11384
+ <td>{{ cell }}</td>
11385
+ }
11386
+ }
11387
+ </tr>
11388
+ }
11389
+ </tbody>
11390
+ </table>
11391
+ }
11392
+ `,
11393
+ }]
11394
+ }], propDecorators: { model: [{ type: i0.Input, args: [{ isSignal: true, alias: "model", required: true }] }] } });
11395
+
11396
+ /**
11397
+ * Injection token to override the chart components' screen-reader and control
11398
+ * strings. Libraries ship English defaults; consumers translate via DI.
11399
+ *
11400
+ * @example
11401
+ * providers: [{
11402
+ * provide: AF_CHART_I18N,
11403
+ * useValue: {
11404
+ * showTable: 'Daten als Tabelle anzeigen',
11405
+ * hideTable: 'Tabelle ausblenden',
11406
+ * noData: 'Keine Daten',
11407
+ * valueHeader: 'Wert',
11408
+ * categoryHeader: 'Kategorie',
11409
+ * seriesHeader: 'Datenreihe',
11410
+ * percentHeader: 'Anteil',
11411
+ * tableSuffix: 'Datentabelle unterhalb des Diagramms verfügbar.',
11412
+ * },
11413
+ * }]
11414
+ */
11415
+ const AF_CHART_I18N = new InjectionToken('AfChartI18n', {
11416
+ factory: () => ({
11417
+ showTable: 'Show data table',
11418
+ hideTable: 'Hide data table',
11419
+ noData: 'No data available',
11420
+ valueHeader: 'Value',
11421
+ categoryHeader: 'Category',
11422
+ seriesHeader: 'Series',
11423
+ percentHeader: 'Share',
11424
+ tableSuffix: 'A data table is available below the chart.',
11425
+ sparklineSummary: '{label}: {count} points, min {min}, max {max}, latest {last}.',
11426
+ }),
11427
+ });
11428
+
11429
+ /**
11430
+ * Pure, dependency-free geometry helpers shared by the chart components.
11431
+ *
11432
+ * Everything here is SSR-safe: no DOM, no `window`, no `Date`. Inputs and
11433
+ * outputs are plain numbers and strings so the functions are trivially unit
11434
+ * testable and reusable across line, bar, donut, sparkline and gauge charts.
11435
+ */
11436
+ /** Clamp `value` into the inclusive `[min, max]` range. */
11437
+ function clamp(value, min, max) {
11438
+ return Math.min(max, Math.max(min, value));
11439
+ }
11440
+ /**
11441
+ * Round `value` to at most `decimals` significant fractional digits, dropping
11442
+ * floating-point noise (e.g. `0.30000000000000004` → `0.3`).
11443
+ */
11444
+ function roundTo(value, decimals = 6) {
11445
+ const factor = 10 ** decimals;
11446
+ return Math.round(value * factor) / factor;
11447
+ }
11448
+ /** Round a candidate axis range/step to a visually "nice" 1/2/5×10ⁿ value. */
11449
+ function niceNum(range, round) {
11450
+ const exponent = Math.floor(Math.log10(range));
11451
+ const fraction = range / 10 ** exponent;
11452
+ let niceFraction;
11453
+ if (round) {
11454
+ if (fraction < 1.5)
11455
+ niceFraction = 1;
11456
+ else if (fraction < 3)
11457
+ niceFraction = 2;
11458
+ else if (fraction < 7)
11459
+ niceFraction = 5;
11460
+ else
11461
+ niceFraction = 10;
11462
+ }
11463
+ else if (fraction <= 1)
11464
+ niceFraction = 1;
11465
+ else if (fraction <= 2)
11466
+ niceFraction = 2;
11467
+ else if (fraction <= 5)
11468
+ niceFraction = 5;
11469
+ else
11470
+ niceFraction = 10;
11471
+ return niceFraction * 10 ** exponent;
11472
+ }
11473
+ /**
11474
+ * Compute a "nice" axis domain and evenly spaced ticks covering `[min, max]`.
11475
+ * Always returns at least two ticks and never loops infinitely on degenerate
11476
+ * (equal or inverted) inputs.
11477
+ *
11478
+ * @param maxTicks Upper bound on the number of ticks (≥ 2). Default 5.
11479
+ */
11480
+ function niceScale(min, max, maxTicks = 5) {
11481
+ const safeTicks = Math.max(2, Math.floor(maxTicks));
11482
+ let lo = Math.min(min, max);
11483
+ let hi = Math.max(min, max);
11484
+ if (lo === hi) {
11485
+ // Degenerate range — pad symmetrically so we still draw a usable axis.
11486
+ const pad = Math.abs(lo) > 0 ? Math.abs(lo) * 0.5 : 1;
11487
+ lo -= pad;
11488
+ hi += pad;
11489
+ }
11490
+ const range = niceNum(hi - lo, false);
11491
+ const step = niceNum(range / (safeTicks - 1), true) || 1;
11492
+ const niceMin = Math.floor(lo / step) * step;
11493
+ const niceMax = Math.ceil(hi / step) * step;
11494
+ const ticks = [];
11495
+ for (let v = niceMin; v <= niceMax + step * 0.5; v += step) {
11496
+ ticks.push(roundTo(v));
11497
+ }
11498
+ return { min: niceMin, max: niceMax, ticks };
11499
+ }
11500
+ /**
11501
+ * Build a linear mapping from a data domain to a pixel range.
11502
+ * Returns the identity-safe scale `(value) => pixel`.
11503
+ */
11504
+ function scaleLinear(domainMin, domainMax, rangeMin, rangeMax) {
11505
+ const domainSpan = domainMax - domainMin || 1;
11506
+ const rangeSpan = rangeMax - rangeMin;
11507
+ return (value) => rangeMin + ((value - domainMin) / domainSpan) * rangeSpan;
11508
+ }
11509
+ /** Build an SVG path (`M`/`L`) through `points`. Empty input yields `''`. */
11510
+ function buildLinePath(points) {
11511
+ if (points.length === 0)
11512
+ return '';
11513
+ return points
11514
+ .map((p, i) => `${i === 0 ? 'M' : 'L'}${roundTo(p.x, 2)} ${roundTo(p.y, 2)}`)
11515
+ .join(' ');
11516
+ }
11517
+ /**
11518
+ * Build a closed area path from `points` down to `baselineY`.
11519
+ * Used for area charts and sparkline fills.
11520
+ */
11521
+ function buildAreaPath(points, baselineY) {
11522
+ if (points.length === 0)
11523
+ return '';
11524
+ const first = points[0];
11525
+ const last = points[points.length - 1];
11526
+ const top = points
11527
+ .map((p, i) => `${i === 0 ? 'M' : 'L'}${roundTo(p.x, 2)} ${roundTo(p.y, 2)}`)
11528
+ .join(' ');
11529
+ return (`${top} L${roundTo(last.x, 2)} ${roundTo(baselineY, 2)} ` +
11530
+ `L${roundTo(first.x, 2)} ${roundTo(baselineY, 2)} Z`);
11531
+ }
11532
+ /** Convert a polar coordinate (degrees, 0° = 12 o'clock, clockwise) to cartesian. */
11533
+ function polarToCartesian(cx, cy, radius, angleDeg) {
11534
+ const rad = ((angleDeg - 90) * Math.PI) / 180;
11535
+ return { x: cx + radius * Math.cos(rad), y: cy + radius * Math.sin(rad) };
11536
+ }
11537
+ /**
11538
+ * Build a filled donut/pie slice path between two angles (clockwise, degrees).
11539
+ * Pass `innerRadius = 0` for a full pie slice.
11540
+ */
11541
+ function donutSlicePath(cx, cy, outerRadius, innerRadius, startAngle, endAngle) {
11542
+ const sweep = endAngle - startAngle;
11543
+ // A full circle can't be expressed as a single arc — split into two halves.
11544
+ if (sweep >= 359.999) {
11545
+ const mid = startAngle + 180;
11546
+ return (donutSlicePath(cx, cy, outerRadius, innerRadius, startAngle, mid) +
11547
+ ' ' +
11548
+ donutSlicePath(cx, cy, outerRadius, innerRadius, mid, endAngle));
11549
+ }
11550
+ const largeArc = sweep > 180 ? 1 : 0;
11551
+ const oStart = polarToCartesian(cx, cy, outerRadius, startAngle);
11552
+ const oEnd = polarToCartesian(cx, cy, outerRadius, endAngle);
11553
+ if (innerRadius <= 0) {
11554
+ return (`M${roundTo(cx, 2)} ${roundTo(cy, 2)} ` +
11555
+ `L${roundTo(oStart.x, 2)} ${roundTo(oStart.y, 2)} ` +
11556
+ `A${outerRadius} ${outerRadius} 0 ${largeArc} 1 ${roundTo(oEnd.x, 2)} ${roundTo(oEnd.y, 2)} Z`);
11557
+ }
11558
+ const iEnd = polarToCartesian(cx, cy, innerRadius, endAngle);
11559
+ const iStart = polarToCartesian(cx, cy, innerRadius, startAngle);
11560
+ return (`M${roundTo(oStart.x, 2)} ${roundTo(oStart.y, 2)} ` +
11561
+ `A${outerRadius} ${outerRadius} 0 ${largeArc} 1 ${roundTo(oEnd.x, 2)} ${roundTo(oEnd.y, 2)} ` +
11562
+ `L${roundTo(iEnd.x, 2)} ${roundTo(iEnd.y, 2)} ` +
11563
+ `A${innerRadius} ${innerRadius} 0 ${largeArc} 0 ${roundTo(iStart.x, 2)} ${roundTo(iStart.y, 2)} Z`);
11564
+ }
11565
+ /** Build a stroked arc path (no fill) between two angles — used by the gauge. */
11566
+ function arcPath(cx, cy, radius, startAngle, endAngle) {
11567
+ const start = polarToCartesian(cx, cy, radius, startAngle);
11568
+ const end = polarToCartesian(cx, cy, radius, endAngle);
11569
+ const largeArc = endAngle - startAngle > 180 ? 1 : 0;
11570
+ return (`M${roundTo(start.x, 2)} ${roundTo(start.y, 2)} ` +
11571
+ `A${radius} ${radius} 0 ${largeArc} 1 ${roundTo(end.x, 2)} ${roundTo(end.y, 2)}`);
11572
+ }
11573
+ /**
11574
+ * Locale-aware number formatting via the `Intl` API (SSR-safe).
11575
+ * Falls back to `String(value)` if `Intl` is unavailable or throws.
11576
+ */
11577
+ function formatNumber(value, locale, options) {
11578
+ try {
11579
+ return new Intl.NumberFormat(locale, options).format(value);
11580
+ }
11581
+ catch {
11582
+ return String(value);
11583
+ }
11584
+ }
11585
+
11586
+ let nextLineChartUid = 0;
11587
+ const VIEW_WIDTH$3 = 640;
11588
+ const MARGIN$2 = { top: 12, right: 16, bottom: 28, left: 48 };
11589
+ const MAX_X_LABELS = 12;
11590
+ /**
11591
+ * Accessible line / area chart for time-series and trend data.
11592
+ *
11593
+ * Renders one or more {@link AfChartSeries} over a shared category axis as an
11594
+ * SVG. Geometry is computed with the SSR-safe `chart-geometry` helpers — no DOM,
11595
+ * `window`, or `Date` access — so it renders identically on the server.
11596
+ *
11597
+ * @example Single trend line
11598
+ * <af-line-chart
11599
+ * ariaLabel="Revenue, last 30 days"
11600
+ * [categories]="days"
11601
+ * [series]="[{ name: 'Revenue', values: revenue }]" />
11602
+ *
11603
+ * @example Multi-series area chart
11604
+ * <af-line-chart
11605
+ * ariaLabel="AI cost by origin"
11606
+ * [categories]="weeks"
11607
+ * [series]="[
11608
+ * { name: 'Analyzer', values: analyzer },
11609
+ * { name: 'Resolver', values: resolver },
11610
+ * ]"
11611
+ * [area]="true" />
11612
+ *
11613
+ * @accessibility
11614
+ * - The SVG carries `role="img"` and an `aria-label` describing the chart, with
11615
+ * a pointer to the always-present data-table fallback.
11616
+ * - A visually-hidden {@link AfChartDataTableComponent} mirrors every value so
11617
+ * information is never conveyed by colour alone (WCAG 1.4.1); a toggle reveals it.
11618
+ * - Each series is labelled in the legend, so colour is never the sole carrier.
11619
+ * - Series colours come from the contrast-checked `--color-chart-series-*` tokens.
11620
+ * - All user-facing strings are configurable via {@link AF_CHART_I18N}.
11621
+ */
11622
+ class AfLineChartComponent {
11623
+ i18n = inject(AF_CHART_I18N);
11624
+ margin = MARGIN$2;
11625
+ viewWidth = VIEW_WIDTH$3;
11626
+ tableId = `af-line-chart-${nextLineChartUid++}`;
11627
+ /** Accessible chart label (required by the WAI-ARIA `img` role). */
11628
+ ariaLabel = input.required(...(ngDevMode ? [{ debugName: "ariaLabel" }] : []));
11629
+ /** X-axis category labels (e.g. dates). Series values align 1:1 with these. */
11630
+ categories = input.required(...(ngDevMode ? [{ debugName: "categories" }] : []));
11631
+ /** One or more data series sharing the category axis. */
11632
+ series = input.required(...(ngDevMode ? [{ debugName: "series" }] : []));
11633
+ /** Fill the area beneath each line. */
11634
+ area = input(false, { ...(ngDevMode ? { debugName: "area" } : {}), transform: booleanAttribute });
11635
+ /** Render point markers on each value. */
11636
+ showDots = input(true, { ...(ngDevMode ? { debugName: "showDots" } : {}), transform: booleanAttribute });
11637
+ /** Show the series legend. */
11638
+ showLegend = input(true, { ...(ngDevMode ? { debugName: "showLegend" } : {}), transform: booleanAttribute });
11639
+ /** Chart height in viewBox units (width is fluid). */
11640
+ height = input(280, ...(ngDevMode ? [{ debugName: "height" }] : []));
11641
+ /** Force the y-axis minimum; defaults to a nice value derived from the data. */
11642
+ yMin = input(null, ...(ngDevMode ? [{ debugName: "yMin" }] : []));
11643
+ /** Force the y-axis maximum; defaults to a nice value derived from the data. */
11644
+ yMax = input(null, ...(ngDevMode ? [{ debugName: "yMax" }] : []));
11645
+ /** BCP-47 locale for number formatting (e.g. `'de-DE'`). */
11646
+ locale = input(...(ngDevMode ? [undefined, { debugName: "locale" }] : []));
11647
+ /** `Intl.NumberFormat` options for axis and value formatting. */
11648
+ valueFormat = input(...(ngDevMode ? [undefined, { debugName: "valueFormat" }] : []));
11649
+ /** Whether the data-table fallback starts visible. */
11650
+ showTableInitially = input(false, { ...(ngDevMode ? { debugName: "showTableInitially" } : {}), transform: booleanAttribute });
11651
+ tableOverride = signal(null, ...(ngDevMode ? [{ debugName: "tableOverride" }] : []));
11652
+ tableVisible = computed(() => this.tableOverride() ?? this.showTableInitially(), ...(ngDevMode ? [{ debugName: "tableVisible" }] : []));
11653
+ hasData = computed(() => this.categories().length > 0 &&
11654
+ this.series().some((s) => s.values.some((v) => v != null)), ...(ngDevMode ? [{ debugName: "hasData" }] : []));
11655
+ viewBox = computed(() => `0 0 ${VIEW_WIDTH$3} ${this.height()}`, ...(ngDevMode ? [{ debugName: "viewBox" }] : []));
11656
+ svgAriaLabel = computed(() => `${this.ariaLabel()}. ${this.i18n.tableSuffix}`, ...(ngDevMode ? [{ debugName: "svgAriaLabel" }] : []));
11657
+ /** Resolved colour per series — explicit override or contrast-checked palette token. */
11658
+ seriesColors = computed(() => this.series().map((s, i) => s.color ?? `var(--color-chart-series-${(i % AF_CHART_PALETTE_SIZE) + 1})`), ...(ngDevMode ? [{ debugName: "seriesColors" }] : []));
11659
+ legend = computed(() => this.series().map((s, i) => ({ name: s.name, color: this.seriesColors()[i] })), ...(ngDevMode ? [{ debugName: "legend" }] : []));
11660
+ /** All geometry needed by the template, derived in a single pass. */
11661
+ plot = computed(() => {
11662
+ const categories = this.categories();
11663
+ const series = this.series();
11664
+ const height = this.height();
11665
+ const colors = this.seriesColors();
11666
+ const plotLeft = MARGIN$2.left;
11667
+ const plotRight = VIEW_WIDTH$3 - MARGIN$2.right;
11668
+ const plotTop = MARGIN$2.top;
11669
+ const baseY = height - MARGIN$2.bottom;
11670
+ const plotWidth = plotRight - plotLeft;
11671
+ const allValues = series.flatMap((s) => s.values.filter((v) => v != null));
11672
+ const dataMin = allValues.length ? Math.min(...allValues) : 0;
11673
+ const dataMax = allValues.length ? Math.max(...allValues) : 1;
11674
+ const rawMin = this.yMin() ?? (this.area() ? Math.min(0, dataMin) : dataMin);
11675
+ const rawMax = this.yMax() ?? dataMax;
11676
+ const scale = niceScale(rawMin, rawMax, 5);
11677
+ const yScale = scaleLinear(scale.min, scale.max, baseY, plotTop);
11678
+ const xAt = (i) => categories.length <= 1
11679
+ ? plotLeft + plotWidth / 2
11680
+ : plotLeft + (i / (categories.length - 1)) * plotWidth;
11681
+ const yTicks = scale.ticks.map((value) => ({
11682
+ value,
11683
+ y: roundTo(yScale(value), 2),
11684
+ label: this.format(value),
11685
+ }));
11686
+ const labelStep = Math.max(1, Math.ceil(categories.length / MAX_X_LABELS));
11687
+ const xLabels = categories
11688
+ .map((label, i) => ({ label, x: roundTo(xAt(i), 2), i }))
11689
+ .filter(({ i }) => i % labelStep === 0 || i === categories.length - 1);
11690
+ const seriesGeom = series.map((s, si) => {
11691
+ const segments = [];
11692
+ let current = [];
11693
+ s.values.forEach((v, i) => {
11694
+ if (v == null) {
11695
+ if (current.length)
11696
+ segments.push(current);
11697
+ current = [];
11698
+ return;
11699
+ }
11700
+ current.push({
11701
+ x: roundTo(xAt(i), 2),
11702
+ y: roundTo(yScale(v), 2),
11703
+ value: v,
11704
+ category: categories[i] ?? String(i),
11705
+ series: s.name,
11706
+ });
11707
+ });
11708
+ if (current.length)
11709
+ segments.push(current);
11710
+ return {
11711
+ name: s.name,
11712
+ color: colors[si],
11713
+ lineSegments: segments.map(buildLinePath),
11714
+ areaSegments: segments.map((seg) => buildAreaPath(seg, baseY)),
11715
+ dots: segments.flat(),
11716
+ };
11717
+ });
11718
+ return { baseY: roundTo(baseY, 2), yTicks, xLabels, series: seriesGeom };
11719
+ }, ...(ngDevMode ? [{ debugName: "plot" }] : []));
11720
+ tableModel = computed(() => {
11721
+ const categories = this.categories();
11722
+ const series = this.series();
11723
+ return {
11724
+ caption: this.ariaLabel(),
11725
+ headers: [this.i18n.categoryHeader, ...series.map((s) => s.name)],
11726
+ rows: categories.map((category, i) => [
11727
+ category,
11728
+ ...series.map((s) => {
11729
+ const v = s.values[i];
11730
+ return v == null ? '—' : this.format(v);
11731
+ }),
11732
+ ]),
11733
+ };
11734
+ }, ...(ngDevMode ? [{ debugName: "tableModel" }] : []));
11735
+ toggleTable() {
11736
+ this.tableOverride.set(!this.tableVisible());
11737
+ }
11738
+ formatValue(value) {
11739
+ return this.format(value);
11740
+ }
11741
+ format(value) {
11742
+ return formatNumber(value, this.locale(), this.valueFormat());
11743
+ }
11744
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AfLineChartComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
11745
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.2", type: AfLineChartComponent, isStandalone: true, selector: "af-line-chart", inputs: { ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: true, transformFunction: null }, categories: { classPropertyName: "categories", publicName: "categories", isSignal: true, isRequired: true, transformFunction: null }, series: { classPropertyName: "series", publicName: "series", isSignal: true, isRequired: true, transformFunction: null }, area: { classPropertyName: "area", publicName: "area", isSignal: true, isRequired: false, transformFunction: null }, showDots: { classPropertyName: "showDots", publicName: "showDots", isSignal: true, isRequired: false, transformFunction: null }, showLegend: { classPropertyName: "showLegend", publicName: "showLegend", isSignal: true, isRequired: false, transformFunction: null }, height: { classPropertyName: "height", publicName: "height", isSignal: true, isRequired: false, transformFunction: null }, yMin: { classPropertyName: "yMin", publicName: "yMin", isSignal: true, isRequired: false, transformFunction: null }, yMax: { classPropertyName: "yMax", publicName: "yMax", isSignal: true, isRequired: false, transformFunction: null }, locale: { classPropertyName: "locale", publicName: "locale", isSignal: true, isRequired: false, transformFunction: null }, valueFormat: { classPropertyName: "valueFormat", publicName: "valueFormat", isSignal: true, isRequired: false, transformFunction: null }, showTableInitially: { classPropertyName: "showTableInitially", publicName: "showTableInitially", isSignal: true, isRequired: false, transformFunction: null } }, host: { styleAttribute: "display: block" }, ngImport: i0, template: `
11746
+ <div class="ct-chart" [class.ct-chart--show-table]="tableVisible()">
11747
+ <figure class="ct-chart__figure">
11748
+ @if (hasData()) {
11749
+ <div class="ct-chart__toolbar">
11750
+ <button
11751
+ type="button"
11752
+ class="ct-chart__toggle"
11753
+ [attr.aria-expanded]="tableVisible()"
11754
+ [attr.aria-controls]="tableId"
11755
+ (click)="toggleTable()">
11756
+ {{ tableVisible() ? i18n.hideTable : i18n.showTable }}
11757
+ </button>
11758
+ </div>
11759
+
11760
+ <svg
11761
+ class="ct-chart__svg"
11762
+ [attr.viewBox]="viewBox()"
11763
+ preserveAspectRatio="xMidYMid meet"
11764
+ role="img"
11765
+ [attr.aria-label]="svgAriaLabel()">
11766
+ @for (tick of plot().yTicks; track tick.value) {
11767
+ <line
11768
+ class="ct-chart__grid-line"
11769
+ [attr.x1]="margin.left"
11770
+ [attr.y1]="tick.y"
11771
+ [attr.x2]="viewWidth - margin.right"
11772
+ [attr.y2]="tick.y" />
11773
+ <text
11774
+ class="ct-chart__tick-label"
11775
+ [attr.x]="margin.left - 6"
11776
+ [attr.y]="tick.y"
11777
+ text-anchor="end"
11778
+ dominant-baseline="middle">
11779
+ {{ tick.label }}
11780
+ </text>
11781
+ }
11782
+
11783
+ <line
11784
+ class="ct-chart__axis-line"
11785
+ [attr.x1]="margin.left"
11786
+ [attr.y1]="plot().baseY"
11787
+ [attr.x2]="viewWidth - margin.right"
11788
+ [attr.y2]="plot().baseY" />
11789
+
11790
+ @for (label of plot().xLabels; track $index) {
11791
+ <text
11792
+ class="ct-chart__tick-label"
11793
+ [attr.x]="label.x"
11794
+ [attr.y]="plot().baseY + 18"
11795
+ text-anchor="middle">
11796
+ {{ label.label }}
11797
+ </text>
11798
+ }
11799
+
11800
+ @for (s of plot().series; track s.name) {
11801
+ <g [style.color]="s.color">
11802
+ @if (area()) {
11803
+ @for (seg of s.areaSegments; track $index) {
11804
+ <path class="ct-chart__area" [attr.d]="seg" />
11805
+ }
11806
+ }
11807
+ @for (seg of s.lineSegments; track $index) {
11808
+ <path class="ct-chart__line" [attr.d]="seg" />
11809
+ }
11810
+ @if (showDots()) {
11811
+ @for (dot of s.dots; track $index) {
11812
+ <circle class="ct-chart__dot" [attr.cx]="dot.x" [attr.cy]="dot.y" r="3">
11813
+ <title>{{ dot.series }} — {{ dot.category }}: {{ formatValue(dot.value) }}</title>
11814
+ </circle>
11815
+ }
11816
+ }
11817
+ </g>
11818
+ }
11819
+ </svg>
11820
+
11821
+ @if (showLegend()) {
11822
+ <ul class="ct-chart__legend">
11823
+ @for (item of legend(); track item.name) {
11824
+ <li class="ct-chart__legend-item" [style.color]="item.color">
11825
+ <span
11826
+ class="ct-chart__legend-marker ct-chart__legend-marker--line"
11827
+ aria-hidden="true"></span>
11828
+ <span>{{ item.name }}</span>
11829
+ </li>
11830
+ }
11831
+ </ul>
11832
+ }
11833
+
11834
+ <div class="ct-chart__table-wrap" [id]="tableId">
11835
+ <af-chart-data-table [model]="tableModel()" />
11836
+ </div>
11837
+ } @else {
11838
+ <div class="ct-chart__empty" role="status">{{ i18n.noData }}</div>
11839
+ }
11840
+ </figure>
11841
+ </div>
11842
+ `, isInline: true, dependencies: [{ kind: "component", type: AfChartDataTableComponent, selector: "af-chart-data-table", inputs: ["model"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
11843
+ }
11844
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AfLineChartComponent, decorators: [{
11845
+ type: Component,
11846
+ args: [{
11847
+ selector: 'af-line-chart',
11848
+ changeDetection: ChangeDetectionStrategy.OnPush,
11849
+ imports: [AfChartDataTableComponent],
11850
+ host: { style: 'display: block' },
11851
+ template: `
11852
+ <div class="ct-chart" [class.ct-chart--show-table]="tableVisible()">
11853
+ <figure class="ct-chart__figure">
11854
+ @if (hasData()) {
11855
+ <div class="ct-chart__toolbar">
11856
+ <button
11857
+ type="button"
11858
+ class="ct-chart__toggle"
11859
+ [attr.aria-expanded]="tableVisible()"
11860
+ [attr.aria-controls]="tableId"
11861
+ (click)="toggleTable()">
11862
+ {{ tableVisible() ? i18n.hideTable : i18n.showTable }}
11863
+ </button>
11864
+ </div>
11865
+
11866
+ <svg
11867
+ class="ct-chart__svg"
11868
+ [attr.viewBox]="viewBox()"
11869
+ preserveAspectRatio="xMidYMid meet"
11870
+ role="img"
11871
+ [attr.aria-label]="svgAriaLabel()">
11872
+ @for (tick of plot().yTicks; track tick.value) {
11873
+ <line
11874
+ class="ct-chart__grid-line"
11875
+ [attr.x1]="margin.left"
11876
+ [attr.y1]="tick.y"
11877
+ [attr.x2]="viewWidth - margin.right"
11878
+ [attr.y2]="tick.y" />
11879
+ <text
11880
+ class="ct-chart__tick-label"
11881
+ [attr.x]="margin.left - 6"
11882
+ [attr.y]="tick.y"
11883
+ text-anchor="end"
11884
+ dominant-baseline="middle">
11885
+ {{ tick.label }}
11886
+ </text>
11887
+ }
11888
+
11889
+ <line
11890
+ class="ct-chart__axis-line"
11891
+ [attr.x1]="margin.left"
11892
+ [attr.y1]="plot().baseY"
11893
+ [attr.x2]="viewWidth - margin.right"
11894
+ [attr.y2]="plot().baseY" />
11895
+
11896
+ @for (label of plot().xLabels; track $index) {
11897
+ <text
11898
+ class="ct-chart__tick-label"
11899
+ [attr.x]="label.x"
11900
+ [attr.y]="plot().baseY + 18"
11901
+ text-anchor="middle">
11902
+ {{ label.label }}
11903
+ </text>
11904
+ }
11905
+
11906
+ @for (s of plot().series; track s.name) {
11907
+ <g [style.color]="s.color">
11908
+ @if (area()) {
11909
+ @for (seg of s.areaSegments; track $index) {
11910
+ <path class="ct-chart__area" [attr.d]="seg" />
11911
+ }
11912
+ }
11913
+ @for (seg of s.lineSegments; track $index) {
11914
+ <path class="ct-chart__line" [attr.d]="seg" />
11915
+ }
11916
+ @if (showDots()) {
11917
+ @for (dot of s.dots; track $index) {
11918
+ <circle class="ct-chart__dot" [attr.cx]="dot.x" [attr.cy]="dot.y" r="3">
11919
+ <title>{{ dot.series }} — {{ dot.category }}: {{ formatValue(dot.value) }}</title>
11920
+ </circle>
11921
+ }
11922
+ }
11923
+ </g>
11924
+ }
11925
+ </svg>
11926
+
11927
+ @if (showLegend()) {
11928
+ <ul class="ct-chart__legend">
11929
+ @for (item of legend(); track item.name) {
11930
+ <li class="ct-chart__legend-item" [style.color]="item.color">
11931
+ <span
11932
+ class="ct-chart__legend-marker ct-chart__legend-marker--line"
11933
+ aria-hidden="true"></span>
11934
+ <span>{{ item.name }}</span>
11935
+ </li>
11936
+ }
11937
+ </ul>
11938
+ }
11939
+
11940
+ <div class="ct-chart__table-wrap" [id]="tableId">
11941
+ <af-chart-data-table [model]="tableModel()" />
11942
+ </div>
11943
+ } @else {
11944
+ <div class="ct-chart__empty" role="status">{{ i18n.noData }}</div>
11945
+ }
11946
+ </figure>
11947
+ </div>
11948
+ `,
11949
+ }]
11950
+ }], propDecorators: { ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: true }] }], categories: [{ type: i0.Input, args: [{ isSignal: true, alias: "categories", required: true }] }], series: [{ type: i0.Input, args: [{ isSignal: true, alias: "series", required: true }] }], area: [{ type: i0.Input, args: [{ isSignal: true, alias: "area", required: false }] }], showDots: [{ type: i0.Input, args: [{ isSignal: true, alias: "showDots", required: false }] }], showLegend: [{ type: i0.Input, args: [{ isSignal: true, alias: "showLegend", required: false }] }], height: [{ type: i0.Input, args: [{ isSignal: true, alias: "height", required: false }] }], yMin: [{ type: i0.Input, args: [{ isSignal: true, alias: "yMin", required: false }] }], yMax: [{ type: i0.Input, args: [{ isSignal: true, alias: "yMax", required: false }] }], locale: [{ type: i0.Input, args: [{ isSignal: true, alias: "locale", required: false }] }], valueFormat: [{ type: i0.Input, args: [{ isSignal: true, alias: "valueFormat", required: false }] }], showTableInitially: [{ type: i0.Input, args: [{ isSignal: true, alias: "showTableInitially", required: false }] }] } });
11951
+
11952
+ /**
11953
+ * Test harness for AfLineChartComponent.
11954
+ *
11955
+ * Wraps DOM queries behind a semantic API so tests read intent, not selectors.
11956
+ *
11957
+ * @example
11958
+ * const harness = new AfLineChartHarness(fixture.nativeElement);
11959
+ * expect(harness.getSeriesCount()).toBe(2);
11960
+ * harness.toggleTable();
11961
+ * expect(harness.isTableVisible()).toBe(true);
11962
+ */
11963
+ class AfLineChartHarness {
11964
+ hostEl;
11965
+ constructor(container) {
11966
+ const el = container.querySelector('af-line-chart');
11967
+ if (!el) {
11968
+ throw new Error('AfLineChartHarness: af-line-chart element not found in container.');
11969
+ }
11970
+ this.hostEl = el;
11971
+ }
11972
+ /** Returns the chart `<svg>`, or null when the empty state is shown. */
11973
+ getSvg() {
11974
+ return this.hostEl.querySelector('svg.ct-chart__svg');
11975
+ }
11976
+ /** Returns the SVG's `aria-label`. */
11977
+ getAriaLabel() {
11978
+ return this.getSvg()?.getAttribute('aria-label') ?? null;
11979
+ }
11980
+ /** Returns the number of rendered series groups. */
11981
+ getSeriesCount() {
11982
+ return this.hostEl.querySelectorAll('svg .ct-chart__line').length;
11983
+ }
11984
+ /** Returns all line `<path>` `d` attributes. */
11985
+ getLinePaths() {
11986
+ return Array.from(this.hostEl.querySelectorAll('.ct-chart__line')).map((p) => p.getAttribute('d') ?? '');
11987
+ }
11988
+ /** Returns the number of area fills (0 unless `area` is enabled). */
11989
+ getAreaCount() {
11990
+ return this.hostEl.querySelectorAll('.ct-chart__area').length;
11991
+ }
11992
+ /** Returns the number of point markers. */
11993
+ getDotCount() {
11994
+ return this.hostEl.querySelectorAll('.ct-chart__dot').length;
11995
+ }
11996
+ /** Returns the legend labels in order. */
11997
+ getLegendLabels() {
11998
+ return Array.from(this.hostEl.querySelectorAll('.ct-chart__legend-item')).map((li) => li.textContent?.trim() ?? '');
11999
+ }
12000
+ /** Returns the rendered y-axis tick labels. */
12001
+ getYTickLabels() {
12002
+ return Array.from(this.hostEl.querySelectorAll('.ct-chart__tick-label'))
12003
+ .filter((t) => t.getAttribute('text-anchor') === 'end')
12004
+ .map((t) => t.textContent?.trim() ?? '');
12005
+ }
12006
+ /** Returns whether the empty-state message is shown. */
12007
+ isEmpty() {
12008
+ return this.hostEl.querySelector('.ct-chart__empty') !== null;
12009
+ }
12010
+ /** Returns the accessible data table element (always present when data exists). */
12011
+ getTable() {
12012
+ return this.hostEl.querySelector('table.ct-chart__table');
12013
+ }
12014
+ /** Returns the data-table header cell texts. */
12015
+ getTableHeaders() {
12016
+ return Array.from(this.hostEl.querySelectorAll('.ct-chart__table thead th')).map((th) => th.textContent?.trim() ?? '');
12017
+ }
12018
+ /** Returns the number of data-table body rows. */
12019
+ getTableRowCount() {
12020
+ return this.hostEl.querySelectorAll('.ct-chart__table tbody tr').length;
12021
+ }
12022
+ /** Returns the toggle button that reveals/hides the data table. */
12023
+ getToggle() {
12024
+ return this.hostEl.querySelector('button.ct-chart__toggle');
12025
+ }
12026
+ /** Clicks the data-table toggle button. */
12027
+ toggleTable() {
12028
+ this.getToggle()?.click();
12029
+ }
12030
+ /** Returns whether the data table is visually revealed. */
12031
+ isTableVisible() {
12032
+ return this.hostEl.querySelector('.ct-chart--show-table') !== null;
12033
+ }
12034
+ }
12035
+
12036
+ let nextBarChartUid = 0;
12037
+ const VIEW_WIDTH$2 = 640;
12038
+ const MARGIN$1 = { top: 12, right: 16, bottom: 28, left: 48 };
12039
+ /** Wider left gutter in horizontal mode to fit right-aligned category labels. */
12040
+ const HORIZONTAL_LEFT = 96;
12041
+ /** Fraction of each category band reserved as padding (grouped/stacked, non-histogram). */
12042
+ const BAND_PADDING = 0.2;
12043
+ /**
12044
+ * Accessible bar chart for categorical comparisons and distributions.
12045
+ *
12046
+ * Renders one or more {@link AfChartSeries} over a shared category axis as SVG
12047
+ * `<rect>` bars. Supports grouped and stacked layouts, vertical and horizontal
12048
+ * orientations, and a gap-free histogram mode for distributions. All geometry is
12049
+ * computed with the SSR-safe `chart-geometry` helpers — no DOM, `window`, or
12050
+ * `Date` access — so it renders identically on the server.
12051
+ *
12052
+ * @example Single-series vertical bars
12053
+ * <af-bar-chart
12054
+ * ariaLabel="Tickets by status"
12055
+ * [categories]="statuses"
12056
+ * [series]="[{ name: 'Tickets', values: counts }]" />
12057
+ *
12058
+ * @example Grouped multi-series
12059
+ * <af-bar-chart
12060
+ * ariaLabel="Cost by origin and week"
12061
+ * [categories]="weeks"
12062
+ * [series]="[
12063
+ * { name: 'Analyzer', values: analyzer },
12064
+ * { name: 'Resolver', values: resolver },
12065
+ * ]"
12066
+ * layout="grouped" />
12067
+ *
12068
+ * @example Horizontal ranked list
12069
+ * <af-bar-chart
12070
+ * ariaLabel="Top organisations"
12071
+ * [categories]="orgs"
12072
+ * [series]="[{ name: 'Documents', values: docs }]"
12073
+ * orientation="horizontal" />
12074
+ *
12075
+ * @accessibility
12076
+ * - The SVG carries `role="img"` and an `aria-label` describing the chart, with
12077
+ * a pointer to the always-present data-table fallback.
12078
+ * - A visually-hidden {@link AfChartDataTableComponent} mirrors every value so
12079
+ * information is never conveyed by colour alone (WCAG 1.4.1); a toggle reveals it.
12080
+ * - Each series is labelled in the legend and each bar carries a `<title>`, so
12081
+ * colour is never the sole carrier.
12082
+ * - Series colours come from the contrast-checked `--color-chart-series-*` tokens.
12083
+ * - All user-facing strings are configurable via {@link AF_CHART_I18N}.
12084
+ */
12085
+ class AfBarChartComponent {
12086
+ i18n = inject(AF_CHART_I18N);
12087
+ tableId = `af-bar-chart-${nextBarChartUid++}`;
12088
+ /** Accessible chart label (required by the WAI-ARIA `img` role). */
12089
+ ariaLabel = input.required(...(ngDevMode ? [{ debugName: "ariaLabel" }] : []));
12090
+ /** Category-axis labels. Series values align 1:1 with these. */
12091
+ categories = input.required(...(ngDevMode ? [{ debugName: "categories" }] : []));
12092
+ /** One or more data series sharing the category axis. A `null` value renders no bar. */
12093
+ series = input.required(...(ngDevMode ? [{ debugName: "series" }] : []));
12094
+ /** How multiple series are arranged: side-by-side (`grouped`) or stacked. */
12095
+ layout = input('grouped', ...(ngDevMode ? [{ debugName: "layout" }] : []));
12096
+ /** Bar direction: `vertical` columns or `horizontal` rows (ideal for ranked label lists). */
12097
+ orientation = input('vertical', ...(ngDevMode ? [{ debugName: "orientation" }] : []));
12098
+ /** Render contiguous, gap-free bars for distributions (typically single-series). */
12099
+ histogram = input(false, { ...(ngDevMode ? { debugName: "histogram" } : {}), transform: booleanAttribute });
12100
+ /** Show the series legend. */
12101
+ showLegend = input(true, { ...(ngDevMode ? { debugName: "showLegend" } : {}), transform: booleanAttribute });
12102
+ /** Show the legend even when there is only one series (hidden by default in that case). */
12103
+ showLegendForSingle = input(false, { ...(ngDevMode ? { debugName: "showLegendForSingle" } : {}), transform: booleanAttribute });
12104
+ /** Chart height in viewBox units (width is the fluid 640). */
12105
+ height = input(280, ...(ngDevMode ? [{ debugName: "height" }] : []));
12106
+ /** Force the value-axis maximum; defaults to a nice value derived from the data. */
12107
+ valueMax = input(null, ...(ngDevMode ? [{ debugName: "valueMax" }] : []));
12108
+ /** BCP-47 locale for number formatting (e.g. `'de-DE'`). */
12109
+ locale = input(...(ngDevMode ? [undefined, { debugName: "locale" }] : []));
12110
+ /** `Intl.NumberFormat` options for axis and value formatting. */
12111
+ valueFormat = input(...(ngDevMode ? [undefined, { debugName: "valueFormat" }] : []));
12112
+ /** Whether the data-table fallback starts visible. */
12113
+ showTableInitially = input(false, { ...(ngDevMode ? { debugName: "showTableInitially" } : {}), transform: booleanAttribute });
12114
+ tableOverride = signal(null, ...(ngDevMode ? [{ debugName: "tableOverride" }] : []));
12115
+ tableVisible = computed(() => this.tableOverride() ?? this.showTableInitially(), ...(ngDevMode ? [{ debugName: "tableVisible" }] : []));
12116
+ hasData = computed(() => this.categories().length > 0 &&
12117
+ this.series().some((s) => s.values.some((v) => v != null)), ...(ngDevMode ? [{ debugName: "hasData" }] : []));
12118
+ viewBox = computed(() => `0 0 ${VIEW_WIDTH$2} ${this.height()}`, ...(ngDevMode ? [{ debugName: "viewBox" }] : []));
12119
+ svgAriaLabel = computed(() => `${this.ariaLabel()}. ${this.i18n.tableSuffix}`, ...(ngDevMode ? [{ debugName: "svgAriaLabel" }] : []));
12120
+ /** Resolved colour per series — explicit override or contrast-checked palette token. */
12121
+ seriesColors = computed(() => this.series().map((s, i) => s.color ?? `var(--color-chart-series-${(i % AF_CHART_PALETTE_SIZE) + 1})`), ...(ngDevMode ? [{ debugName: "seriesColors" }] : []));
12122
+ /** Whether the legend should render — hidden for a single series unless opted in. */
12123
+ legendVisible = computed(() => this.showLegend() && (this.series().length > 1 || this.showLegendForSingle()), ...(ngDevMode ? [{ debugName: "legendVisible" }] : []));
12124
+ legend = computed(() => this.series().map((s, i) => ({ name: s.name, color: this.seriesColors()[i] })), ...(ngDevMode ? [{ debugName: "legend" }] : []));
12125
+ /** All geometry needed by the template, derived in a single pass. */
12126
+ plot = computed(() => {
12127
+ const categories = this.categories();
12128
+ const series = this.series();
12129
+ const colors = this.seriesColors();
12130
+ const height = this.height();
12131
+ const horizontal = this.orientation() === 'horizontal';
12132
+ const stacked = this.layout() === 'stacked';
12133
+ const histogram = this.histogram();
12134
+ // Resolve the plot box; horizontal needs a wider gutter for category labels.
12135
+ const marginLeft = horizontal ? HORIZONTAL_LEFT : MARGIN$1.left;
12136
+ const plotLeft = marginLeft;
12137
+ const plotRight = VIEW_WIDTH$2 - MARGIN$1.right;
12138
+ const plotTop = MARGIN$1.top;
12139
+ const plotBottom = height - MARGIN$1.bottom;
12140
+ const plotWidth = plotRight - plotLeft;
12141
+ const plotHeight = plotBottom - plotTop;
12142
+ const scale = this.valueScale(series, stacked);
12143
+ // Value-axis pixel scale runs along x (horizontal) or y (vertical, inverted).
12144
+ const valueScale = horizontal
12145
+ ? scaleLinear(scale.min, scale.max, plotLeft, plotRight)
12146
+ : scaleLinear(scale.min, scale.max, plotBottom, plotTop);
12147
+ const zeroPx = roundTo(valueScale(0), 2);
12148
+ const n = categories.length;
12149
+ const bandSpan = (horizontal ? plotHeight : plotWidth) / Math.max(n, 1);
12150
+ const bandStart = horizontal ? plotTop : plotLeft;
12151
+ const padding = histogram ? 0 : bandSpan * BAND_PADDING;
12152
+ const innerSpan = bandSpan - padding;
12153
+ const seriesGeom = this.buildSeries(series, categories, colors, { stacked, horizontal, valueScale, zeroPx, bandStart, bandSpan, padding, innerSpan });
12154
+ const valueTicks = this.buildValueTicks(scale.ticks, {
12155
+ horizontal,
12156
+ valueScale,
12157
+ plotLeft,
12158
+ plotRight,
12159
+ plotTop,
12160
+ plotBottom,
12161
+ });
12162
+ const categoryLabels = this.buildCategoryLabels(categories, {
12163
+ horizontal,
12164
+ bandStart,
12165
+ bandSpan,
12166
+ plotBottom,
12167
+ marginLeft,
12168
+ });
12169
+ const axisLine = horizontal
12170
+ ? { x1: zeroPx, y1: roundTo(plotTop, 2), x2: zeroPx, y2: roundTo(plotBottom, 2) }
12171
+ : { x1: roundTo(plotLeft, 2), y1: zeroPx, x2: roundTo(plotRight, 2), y2: zeroPx };
12172
+ return {
12173
+ series: seriesGeom,
12174
+ valueTicks,
12175
+ categoryLabels,
12176
+ axisLine,
12177
+ valueLabelAnchor: horizontal ? 'middle' : 'end',
12178
+ categoryLabelAnchor: horizontal ? 'end' : 'middle',
12179
+ };
12180
+ }, ...(ngDevMode ? [{ debugName: "plot" }] : []));
12181
+ tableModel = computed(() => {
12182
+ const categories = this.categories();
12183
+ const series = this.series();
12184
+ const single = series.length === 1;
12185
+ const headers = single
12186
+ ? [this.i18n.categoryHeader, this.i18n.valueHeader]
12187
+ : [this.i18n.categoryHeader, ...series.map((s) => s.name)];
12188
+ return {
12189
+ caption: this.ariaLabel(),
12190
+ headers,
12191
+ rows: categories.map((category, i) => [
12192
+ category,
12193
+ ...series.map((s) => {
12194
+ const v = s.values[i];
12195
+ return v == null ? '—' : this.format(v);
12196
+ }),
12197
+ ]),
12198
+ };
12199
+ }, ...(ngDevMode ? [{ debugName: "tableModel" }] : []));
12200
+ toggleTable() {
12201
+ this.tableOverride.set(!this.tableVisible());
12202
+ }
12203
+ /**
12204
+ * Compute the value-axis domain and ticks. The domain always includes 0; the
12205
+ * max defaults to the data max (or the largest stack total in stacked mode).
12206
+ */
12207
+ valueScale(series, stacked) {
12208
+ const categoryCount = this.categories().length;
12209
+ const allValues = series.flatMap((s) => s.values.filter((v) => v != null));
12210
+ const dataMin = allValues.length ? Math.min(...allValues) : 0;
12211
+ const dataMax = allValues.length ? Math.max(...allValues) : 1;
12212
+ // Stacked bars accumulate positive and negative values into separate stacks,
12213
+ // so the axis must reach the largest positive stack *and* the smallest
12214
+ // negative stack — not just the largest/smallest single value.
12215
+ let maxStackTotal = dataMax;
12216
+ let minStackTotal = dataMin;
12217
+ if (stacked) {
12218
+ maxStackTotal = 0;
12219
+ minStackTotal = 0;
12220
+ for (let i = 0; i < categoryCount; i++) {
12221
+ let pos = 0;
12222
+ let neg = 0;
12223
+ for (const s of series) {
12224
+ const v = s.values[i];
12225
+ if (v == null)
12226
+ continue;
12227
+ if (v > 0)
12228
+ pos += v;
12229
+ else
12230
+ neg += v;
12231
+ }
12232
+ if (pos > maxStackTotal)
12233
+ maxStackTotal = pos;
12234
+ if (neg < minStackTotal)
12235
+ minStackTotal = neg;
12236
+ }
12237
+ }
12238
+ // The value axis always includes the zero baseline so bars grow from an
12239
+ // on-canvas origin — even when every value (or stack total) is negative.
12240
+ const rawMin = Math.min(0, stacked ? minStackTotal : dataMin);
12241
+ const rawMax = this.valueMax() ?? Math.max(0, stacked ? maxStackTotal : dataMax);
12242
+ return niceScale(rawMin, rawMax, 5);
12243
+ }
12244
+ /** Build the per-series bar geometry for every layout/orientation combination. */
12245
+ buildSeries(series, categories, colors, ctx) {
12246
+ const { stacked, horizontal, valueScale, zeroPx, bandStart, bandSpan, padding, innerSpan } = ctx;
12247
+ const seriesCount = series.length;
12248
+ // Grouped sub-bars split the inner band evenly and touch each other.
12249
+ const subWidth = stacked ? innerSpan : innerSpan / Math.max(seriesCount, 1);
12250
+ // Running positive/negative stack offsets per category (value units).
12251
+ const posOffset = new Array(categories.length).fill(0);
12252
+ const negOffset = new Array(categories.length).fill(0);
12253
+ return series.map((s, si) => {
12254
+ const bars = [];
12255
+ categories.forEach((category, ci) => {
12256
+ const value = s.values[ci];
12257
+ if (value == null)
12258
+ return;
12259
+ const bandOrigin = bandStart + ci * bandSpan + padding / 2;
12260
+ let startPx;
12261
+ let endPx;
12262
+ let cross;
12263
+ if (stacked) {
12264
+ const base = value >= 0 ? posOffset[ci] : negOffset[ci];
12265
+ const next = base + value;
12266
+ startPx = roundTo(valueScale(base), 2);
12267
+ endPx = roundTo(valueScale(next), 2);
12268
+ if (value >= 0)
12269
+ posOffset[ci] = next;
12270
+ else
12271
+ negOffset[ci] = next;
12272
+ cross = bandOrigin;
12273
+ }
12274
+ else {
12275
+ startPx = zeroPx;
12276
+ endPx = roundTo(valueScale(value), 2);
12277
+ cross = bandOrigin + si * subWidth;
12278
+ }
12279
+ bars.push(this.makeBar(horizontal, startPx, endPx, cross, subWidth, {
12280
+ series: s.name,
12281
+ category,
12282
+ value,
12283
+ }));
12284
+ });
12285
+ return { name: s.name, color: colors[si], bars };
12286
+ });
12287
+ }
12288
+ /** Assemble one bar rect from value-axis endpoints and the cross-axis band slot. */
12289
+ makeBar(horizontal, startPx, endPx, cross, thickness, meta) {
12290
+ const title = `${meta.series} — ${meta.category}: ${this.format(meta.value)}`;
12291
+ const t = roundTo(Math.max(0, thickness), 2);
12292
+ const c = roundTo(cross, 2);
12293
+ const extent = roundTo(Math.max(0, Math.abs(endPx - startPx)), 2);
12294
+ if (horizontal) {
12295
+ const x = roundTo(Math.min(startPx, endPx), 2);
12296
+ return { x, y: c, width: extent, height: t, title };
12297
+ }
12298
+ const y = roundTo(Math.min(startPx, endPx), 2);
12299
+ return { x: c, y, width: t, height: extent, title };
12300
+ }
12301
+ /** Build value-axis grid lines + tick labels for the active orientation. */
12302
+ buildValueTicks(ticks, ctx) {
12303
+ const { horizontal, valueScale, plotLeft, plotRight, plotTop, plotBottom } = ctx;
12304
+ return ticks.map((value) => {
12305
+ const p = roundTo(valueScale(value), 2);
12306
+ const label = this.format(value);
12307
+ if (horizontal) {
12308
+ return {
12309
+ value,
12310
+ label,
12311
+ x1: p,
12312
+ y1: roundTo(plotTop, 2),
12313
+ x2: p,
12314
+ y2: roundTo(plotBottom, 2),
12315
+ labelX: p,
12316
+ labelY: roundTo(plotBottom + 18, 2),
12317
+ };
12318
+ }
12319
+ return {
12320
+ value,
12321
+ label,
12322
+ x1: roundTo(plotLeft, 2),
12323
+ y1: p,
12324
+ x2: roundTo(plotRight, 2),
12325
+ y2: p,
12326
+ labelX: roundTo(plotLeft - 6, 2),
12327
+ labelY: p,
12328
+ };
12329
+ });
12330
+ }
12331
+ /** Build category-axis labels centred on each band, anchored per orientation. */
12332
+ buildCategoryLabels(categories, ctx) {
12333
+ const { horizontal, bandStart, bandSpan, plotBottom, marginLeft } = ctx;
12334
+ return categories.map((label, i) => {
12335
+ const center = bandStart + i * bandSpan + bandSpan / 2;
12336
+ if (horizontal) {
12337
+ return { label, x: roundTo(marginLeft - 8, 2), y: roundTo(center, 2) };
12338
+ }
12339
+ return { label, x: roundTo(center, 2), y: roundTo(plotBottom + 18, 2) };
12340
+ });
12341
+ }
12342
+ format(value) {
12343
+ return formatNumber(value, this.locale(), this.valueFormat());
12344
+ }
12345
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AfBarChartComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
12346
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.2", type: AfBarChartComponent, isStandalone: true, selector: "af-bar-chart", inputs: { ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: true, transformFunction: null }, categories: { classPropertyName: "categories", publicName: "categories", isSignal: true, isRequired: true, transformFunction: null }, series: { classPropertyName: "series", publicName: "series", isSignal: true, isRequired: true, transformFunction: null }, layout: { classPropertyName: "layout", publicName: "layout", isSignal: true, isRequired: false, transformFunction: null }, orientation: { classPropertyName: "orientation", publicName: "orientation", isSignal: true, isRequired: false, transformFunction: null }, histogram: { classPropertyName: "histogram", publicName: "histogram", isSignal: true, isRequired: false, transformFunction: null }, showLegend: { classPropertyName: "showLegend", publicName: "showLegend", isSignal: true, isRequired: false, transformFunction: null }, showLegendForSingle: { classPropertyName: "showLegendForSingle", publicName: "showLegendForSingle", isSignal: true, isRequired: false, transformFunction: null }, height: { classPropertyName: "height", publicName: "height", isSignal: true, isRequired: false, transformFunction: null }, valueMax: { classPropertyName: "valueMax", publicName: "valueMax", isSignal: true, isRequired: false, transformFunction: null }, locale: { classPropertyName: "locale", publicName: "locale", isSignal: true, isRequired: false, transformFunction: null }, valueFormat: { classPropertyName: "valueFormat", publicName: "valueFormat", isSignal: true, isRequired: false, transformFunction: null }, showTableInitially: { classPropertyName: "showTableInitially", publicName: "showTableInitially", isSignal: true, isRequired: false, transformFunction: null } }, host: { styleAttribute: "display: block" }, ngImport: i0, template: `
12347
+ <div class="ct-chart" [class.ct-chart--show-table]="tableVisible()">
12348
+ <figure class="ct-chart__figure">
12349
+ @if (hasData()) {
12350
+ <div class="ct-chart__toolbar">
12351
+ <button
12352
+ type="button"
12353
+ class="ct-chart__toggle"
12354
+ [attr.aria-expanded]="tableVisible()"
12355
+ [attr.aria-controls]="tableId"
12356
+ (click)="toggleTable()">
12357
+ {{ tableVisible() ? i18n.hideTable : i18n.showTable }}
12358
+ </button>
12359
+ </div>
12360
+
12361
+ <svg
12362
+ class="ct-chart__svg"
12363
+ [attr.viewBox]="viewBox()"
12364
+ preserveAspectRatio="xMidYMid meet"
12365
+ role="img"
12366
+ [attr.aria-label]="svgAriaLabel()">
12367
+ <g class="ct-chart__value-ticks">
12368
+ @for (tick of plot().valueTicks; track tick.value) {
12369
+ <line
12370
+ class="ct-chart__grid-line"
12371
+ [attr.x1]="tick.x1"
12372
+ [attr.y1]="tick.y1"
12373
+ [attr.x2]="tick.x2"
12374
+ [attr.y2]="tick.y2" />
12375
+ <text
12376
+ class="ct-chart__tick-label"
12377
+ [attr.x]="tick.labelX"
12378
+ [attr.y]="tick.labelY"
12379
+ [attr.text-anchor]="plot().valueLabelAnchor"
12380
+ dominant-baseline="middle">
12381
+ {{ tick.label }}
12382
+ </text>
12383
+ }
12384
+ </g>
12385
+
12386
+ <line
12387
+ class="ct-chart__axis-line"
12388
+ [attr.x1]="plot().axisLine.x1"
12389
+ [attr.y1]="plot().axisLine.y1"
12390
+ [attr.x2]="plot().axisLine.x2"
12391
+ [attr.y2]="plot().axisLine.y2" />
12392
+
12393
+ <g class="ct-chart__category-labels">
12394
+ @for (label of plot().categoryLabels; track $index) {
12395
+ <text
12396
+ class="ct-chart__tick-label"
12397
+ [attr.x]="label.x"
12398
+ [attr.y]="label.y"
12399
+ [attr.text-anchor]="plot().categoryLabelAnchor"
12400
+ dominant-baseline="middle">
12401
+ {{ label.label }}
12402
+ </text>
12403
+ }
12404
+ </g>
12405
+
12406
+ @for (s of plot().series; track s.name) {
12407
+ <g [style.color]="s.color">
12408
+ @for (bar of s.bars; track $index) {
12409
+ <rect
12410
+ class="ct-chart__bar"
12411
+ [attr.x]="bar.x"
12412
+ [attr.y]="bar.y"
12413
+ [attr.width]="bar.width"
12414
+ [attr.height]="bar.height">
12415
+ <title>{{ bar.title }}</title>
12416
+ </rect>
12417
+ }
12418
+ </g>
12419
+ }
12420
+ </svg>
12421
+
12422
+ @if (legendVisible()) {
12423
+ <ul class="ct-chart__legend">
12424
+ @for (item of legend(); track item.name) {
12425
+ <li class="ct-chart__legend-item" [style.color]="item.color">
12426
+ <span class="ct-chart__legend-marker" aria-hidden="true"></span>
12427
+ <span>{{ item.name }}</span>
12428
+ </li>
12429
+ }
12430
+ </ul>
12431
+ }
12432
+
12433
+ <div class="ct-chart__table-wrap" [id]="tableId">
12434
+ <af-chart-data-table [model]="tableModel()" />
12435
+ </div>
12436
+ } @else {
12437
+ <div class="ct-chart__empty" role="status">{{ i18n.noData }}</div>
12438
+ }
12439
+ </figure>
12440
+ </div>
12441
+ `, isInline: true, dependencies: [{ kind: "component", type: AfChartDataTableComponent, selector: "af-chart-data-table", inputs: ["model"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
12442
+ }
12443
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AfBarChartComponent, decorators: [{
12444
+ type: Component,
12445
+ args: [{
12446
+ selector: 'af-bar-chart',
12447
+ changeDetection: ChangeDetectionStrategy.OnPush,
12448
+ imports: [AfChartDataTableComponent],
12449
+ host: { style: 'display: block' },
12450
+ template: `
12451
+ <div class="ct-chart" [class.ct-chart--show-table]="tableVisible()">
12452
+ <figure class="ct-chart__figure">
12453
+ @if (hasData()) {
12454
+ <div class="ct-chart__toolbar">
12455
+ <button
12456
+ type="button"
12457
+ class="ct-chart__toggle"
12458
+ [attr.aria-expanded]="tableVisible()"
12459
+ [attr.aria-controls]="tableId"
12460
+ (click)="toggleTable()">
12461
+ {{ tableVisible() ? i18n.hideTable : i18n.showTable }}
12462
+ </button>
12463
+ </div>
12464
+
12465
+ <svg
12466
+ class="ct-chart__svg"
12467
+ [attr.viewBox]="viewBox()"
12468
+ preserveAspectRatio="xMidYMid meet"
12469
+ role="img"
12470
+ [attr.aria-label]="svgAriaLabel()">
12471
+ <g class="ct-chart__value-ticks">
12472
+ @for (tick of plot().valueTicks; track tick.value) {
12473
+ <line
12474
+ class="ct-chart__grid-line"
12475
+ [attr.x1]="tick.x1"
12476
+ [attr.y1]="tick.y1"
12477
+ [attr.x2]="tick.x2"
12478
+ [attr.y2]="tick.y2" />
12479
+ <text
12480
+ class="ct-chart__tick-label"
12481
+ [attr.x]="tick.labelX"
12482
+ [attr.y]="tick.labelY"
12483
+ [attr.text-anchor]="plot().valueLabelAnchor"
12484
+ dominant-baseline="middle">
12485
+ {{ tick.label }}
12486
+ </text>
12487
+ }
12488
+ </g>
12489
+
12490
+ <line
12491
+ class="ct-chart__axis-line"
12492
+ [attr.x1]="plot().axisLine.x1"
12493
+ [attr.y1]="plot().axisLine.y1"
12494
+ [attr.x2]="plot().axisLine.x2"
12495
+ [attr.y2]="plot().axisLine.y2" />
12496
+
12497
+ <g class="ct-chart__category-labels">
12498
+ @for (label of plot().categoryLabels; track $index) {
12499
+ <text
12500
+ class="ct-chart__tick-label"
12501
+ [attr.x]="label.x"
12502
+ [attr.y]="label.y"
12503
+ [attr.text-anchor]="plot().categoryLabelAnchor"
12504
+ dominant-baseline="middle">
12505
+ {{ label.label }}
12506
+ </text>
12507
+ }
12508
+ </g>
12509
+
12510
+ @for (s of plot().series; track s.name) {
12511
+ <g [style.color]="s.color">
12512
+ @for (bar of s.bars; track $index) {
12513
+ <rect
12514
+ class="ct-chart__bar"
12515
+ [attr.x]="bar.x"
12516
+ [attr.y]="bar.y"
12517
+ [attr.width]="bar.width"
12518
+ [attr.height]="bar.height">
12519
+ <title>{{ bar.title }}</title>
12520
+ </rect>
12521
+ }
12522
+ </g>
12523
+ }
12524
+ </svg>
12525
+
12526
+ @if (legendVisible()) {
12527
+ <ul class="ct-chart__legend">
12528
+ @for (item of legend(); track item.name) {
12529
+ <li class="ct-chart__legend-item" [style.color]="item.color">
12530
+ <span class="ct-chart__legend-marker" aria-hidden="true"></span>
12531
+ <span>{{ item.name }}</span>
12532
+ </li>
12533
+ }
12534
+ </ul>
12535
+ }
12536
+
12537
+ <div class="ct-chart__table-wrap" [id]="tableId">
12538
+ <af-chart-data-table [model]="tableModel()" />
12539
+ </div>
12540
+ } @else {
12541
+ <div class="ct-chart__empty" role="status">{{ i18n.noData }}</div>
12542
+ }
12543
+ </figure>
12544
+ </div>
12545
+ `,
12546
+ }]
12547
+ }], propDecorators: { ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: true }] }], categories: [{ type: i0.Input, args: [{ isSignal: true, alias: "categories", required: true }] }], series: [{ type: i0.Input, args: [{ isSignal: true, alias: "series", required: true }] }], layout: [{ type: i0.Input, args: [{ isSignal: true, alias: "layout", required: false }] }], orientation: [{ type: i0.Input, args: [{ isSignal: true, alias: "orientation", required: false }] }], histogram: [{ type: i0.Input, args: [{ isSignal: true, alias: "histogram", required: false }] }], showLegend: [{ type: i0.Input, args: [{ isSignal: true, alias: "showLegend", required: false }] }], showLegendForSingle: [{ type: i0.Input, args: [{ isSignal: true, alias: "showLegendForSingle", required: false }] }], height: [{ type: i0.Input, args: [{ isSignal: true, alias: "height", required: false }] }], valueMax: [{ type: i0.Input, args: [{ isSignal: true, alias: "valueMax", required: false }] }], locale: [{ type: i0.Input, args: [{ isSignal: true, alias: "locale", required: false }] }], valueFormat: [{ type: i0.Input, args: [{ isSignal: true, alias: "valueFormat", required: false }] }], showTableInitially: [{ type: i0.Input, args: [{ isSignal: true, alias: "showTableInitially", required: false }] }] } });
12548
+
12549
+ /**
12550
+ * Test harness for AfBarChartComponent.
12551
+ *
12552
+ * Wraps DOM queries behind a semantic API so tests read intent, not selectors.
12553
+ *
12554
+ * @example
12555
+ * const harness = new AfBarChartHarness(fixture.nativeElement);
12556
+ * expect(harness.getBarCount()).toBe(5);
12557
+ * harness.toggleTable();
12558
+ * expect(harness.isTableVisible()).toBe(true);
12559
+ */
12560
+ class AfBarChartHarness {
12561
+ hostEl;
12562
+ constructor(container) {
12563
+ const el = container.querySelector('af-bar-chart');
12564
+ if (!el) {
12565
+ throw new Error('AfBarChartHarness: af-bar-chart element not found in container.');
12566
+ }
12567
+ this.hostEl = el;
12568
+ }
12569
+ /** Returns the chart `<svg>`, or null when the empty state is shown. */
12570
+ getSvg() {
12571
+ return this.hostEl.querySelector('svg.ct-chart__svg');
12572
+ }
12573
+ /** Returns the SVG's `aria-label`. */
12574
+ getAriaLabel() {
12575
+ return this.getSvg()?.getAttribute('aria-label') ?? null;
12576
+ }
12577
+ /** Returns the total number of rendered bar rects. */
12578
+ getBarCount() {
12579
+ return this.getBars().length;
12580
+ }
12581
+ /** Returns all bar `<rect>` elements in DOM order. */
12582
+ getBars() {
12583
+ return Array.from(this.hostEl.querySelectorAll('rect.ct-chart__bar'));
12584
+ }
12585
+ /** Returns the legend labels in order. */
12586
+ getLegendLabels() {
12587
+ return Array.from(this.hostEl.querySelectorAll('.ct-chart__legend-item')).map((li) => li.textContent?.trim() ?? '');
12588
+ }
12589
+ /** Returns the rendered value-axis tick labels (orientation-independent). */
12590
+ getValueTickLabels() {
12591
+ return Array.from(this.hostEl.querySelectorAll('.ct-chart__value-ticks .ct-chart__tick-label')).map((t) => t.textContent?.trim() ?? '');
12592
+ }
12593
+ /** Returns the rendered category-axis labels in order. */
12594
+ getCategoryLabels() {
12595
+ return Array.from(this.hostEl.querySelectorAll('.ct-chart__category-labels .ct-chart__tick-label')).map((t) => t.textContent?.trim() ?? '');
12596
+ }
12597
+ /** Returns whether the empty-state message is shown. */
12598
+ isEmpty() {
12599
+ return this.hostEl.querySelector('.ct-chart__empty') !== null;
12600
+ }
12601
+ /** Returns the accessible data table element (always present when data exists). */
12602
+ getTable() {
12603
+ return this.hostEl.querySelector('table.ct-chart__table');
12604
+ }
12605
+ /** Returns the data-table header cell texts. */
12606
+ getTableHeaders() {
12607
+ return Array.from(this.hostEl.querySelectorAll('.ct-chart__table thead th')).map((th) => th.textContent?.trim() ?? '');
12608
+ }
12609
+ /** Returns the number of data-table body rows. */
12610
+ getTableRowCount() {
12611
+ return this.hostEl.querySelectorAll('.ct-chart__table tbody tr').length;
12612
+ }
12613
+ /** Returns the toggle button that reveals/hides the data table. */
12614
+ getToggle() {
12615
+ return this.hostEl.querySelector('button.ct-chart__toggle');
12616
+ }
12617
+ /** Clicks the data-table toggle button. */
12618
+ toggleTable() {
12619
+ this.getToggle()?.click();
12620
+ }
12621
+ /** Returns whether the data table is visually revealed. */
12622
+ isTableVisible() {
12623
+ return this.hostEl.querySelector('.ct-chart--show-table') !== null;
12624
+ }
12625
+ }
12626
+
12627
+ let nextDonutChartUid = 0;
12628
+ const VIEW_WIDTH$1 = 640;
12629
+ const MARGIN = 16;
12630
+ /**
12631
+ * Accessible donut / pie chart for part-to-whole data.
12632
+ *
12633
+ * Renders a set of {@link AfChartDatum} slices as an SVG ring (or full pie when
12634
+ * `innerRadiusRatio` is 0). Arc geometry is computed with the SSR-safe
12635
+ * `chart-geometry` helpers — no DOM, `window`, `Date` or `Math.random` access —
12636
+ * so it renders identically on the server.
12637
+ *
12638
+ * @example Donut with a centre total
12639
+ * <af-donut-chart
12640
+ * ariaLabel="Contract mix"
12641
+ * [data]="[
12642
+ * { label: 'Enterprise', value: 60 },
12643
+ * { label: 'Team', value: 40 },
12644
+ * ]"
12645
+ * centerLabel="Total" />
12646
+ *
12647
+ * @example Full pie
12648
+ * <af-donut-chart
12649
+ * ariaLabel="AI cost by origin"
12650
+ * [data]="costs"
12651
+ * [innerRadiusRatio]="0" />
12652
+ *
12653
+ * @accessibility
12654
+ * - The SVG carries `role="img"` and an `aria-label` describing the chart, with
12655
+ * a pointer to the always-present data-table fallback.
12656
+ * - A visually-hidden {@link AfChartDataTableComponent} mirrors every slice's
12657
+ * exact value and percentage, so information is never conveyed by colour alone
12658
+ * (WCAG 1.4.1); a toggle reveals it.
12659
+ * - The legend lists every slice label (including zero-value data), so colour is
12660
+ * never the sole carrier, and each slice exposes a native `<title>` tooltip.
12661
+ * - Slice colours come from the contrast-checked `--color-chart-series-*` tokens.
12662
+ * - All user-facing strings are configurable via {@link AF_CHART_I18N}.
12663
+ */
12664
+ class AfDonutChartComponent {
12665
+ i18n = inject(AF_CHART_I18N);
12666
+ tableId = `af-donut-chart-${nextDonutChartUid++}`;
12667
+ /** Accessible chart label (required by the WAI-ARIA `img` role). */
12668
+ ariaLabel = input.required(...(ngDevMode ? [{ debugName: "ariaLabel" }] : []));
12669
+ /** Slices of the ring. Negative values clamp to 0; zero-value slices are
12670
+ * omitted from the ring but still listed in the legend and data table. */
12671
+ data = input.required(...(ngDevMode ? [{ debugName: "data" }] : []));
12672
+ /** Hole size as a fraction of the radius: `0` → full pie, `~0.6` → donut.
12673
+ * Clamped to `[0, 0.9]`. */
12674
+ innerRadiusRatio = input(0.6, ...(ngDevMode ? [{ debugName: "innerRadiusRatio" }] : []));
12675
+ /** Small caption rendered inside the donut hole (e.g. `'Total'`). */
12676
+ centerLabel = input('', ...(ngDevMode ? [{ debugName: "centerLabel" }] : []));
12677
+ /** Big text inside the hole; defaults to the formatted sum of values. */
12678
+ centerValue = input('', ...(ngDevMode ? [{ debugName: "centerValue" }] : []));
12679
+ /** Show the slice legend. */
12680
+ showLegend = input(true, { ...(ngDevMode ? { debugName: "showLegend" } : {}), transform: booleanAttribute });
12681
+ /** Show each slice's percentage share next to its legend label. */
12682
+ showPercentInLegend = input(true, { ...(ngDevMode ? { debugName: "showPercentInLegend" } : {}), transform: booleanAttribute });
12683
+ /** Chart height in viewBox units (width is fluid). */
12684
+ height = input(260, ...(ngDevMode ? [{ debugName: "height" }] : []));
12685
+ /** BCP-47 locale for number formatting (e.g. `'de-DE'`). */
12686
+ locale = input(...(ngDevMode ? [undefined, { debugName: "locale" }] : []));
12687
+ /** `Intl.NumberFormat` options for value formatting. */
12688
+ valueFormat = input(...(ngDevMode ? [undefined, { debugName: "valueFormat" }] : []));
12689
+ /** Whether the data-table fallback starts visible. */
12690
+ showTableInitially = input(false, { ...(ngDevMode ? { debugName: "showTableInitially" } : {}), transform: booleanAttribute });
12691
+ tableOverride = signal(null, ...(ngDevMode ? [{ debugName: "tableOverride" }] : []));
12692
+ tableVisible = computed(() => this.tableOverride() ?? this.showTableInitially(), ...(ngDevMode ? [{ debugName: "tableVisible" }] : []));
12693
+ /** Clamped magnitudes aligned 1:1 with `data()` (negatives → 0). */
12694
+ values = computed(() => this.data().map((d) => Math.max(0, d.value)), ...(ngDevMode ? [{ debugName: "values" }] : []));
12695
+ total = computed(() => this.values().reduce((sum, v) => sum + v, 0), ...(ngDevMode ? [{ debugName: "total" }] : []));
12696
+ hasData = computed(() => this.total() > 0, ...(ngDevMode ? [{ debugName: "hasData" }] : []));
12697
+ viewBox = computed(() => `0 0 ${VIEW_WIDTH$1} ${this.height()}`, ...(ngDevMode ? [{ debugName: "viewBox" }] : []));
12698
+ svgAriaLabel = computed(() => `${this.ariaLabel()}. ${this.i18n.tableSuffix}`, ...(ngDevMode ? [{ debugName: "svgAriaLabel" }] : []));
12699
+ /** Resolved colour per datum — explicit override or contrast-checked palette
12700
+ * token — keyed by original index so slices and legend never diverge. */
12701
+ colors = computed(() => this.data().map((d, i) => d.color ?? `var(--color-chart-series-${(i % AF_CHART_PALETTE_SIZE) + 1})`), ...(ngDevMode ? [{ debugName: "colors" }] : []));
12702
+ /** Formatted shares aligned 1:1 with `data()` (e.g. `'40%'`). */
12703
+ percents = computed(() => {
12704
+ const total = this.total() || 1;
12705
+ return this.values().map((v) => this.formatPercent((v / total) * 100));
12706
+ }, ...(ngDevMode ? [{ debugName: "percents" }] : []));
12707
+ /** Centre value text: explicit override or the formatted total. */
12708
+ centerValueText = computed(() => this.centerValue() || this.format(this.total()), ...(ngDevMode ? [{ debugName: "centerValueText" }] : []));
12709
+ legend = computed(() => this.data().map((d, i) => ({
12710
+ label: d.label,
12711
+ color: this.colors()[i],
12712
+ percent: this.percents()[i],
12713
+ })), ...(ngDevMode ? [{ debugName: "legend" }] : []));
12714
+ /** All ring geometry derived in a single pass; zero-value slices are skipped. */
12715
+ plot = computed(() => {
12716
+ const data = this.data();
12717
+ const values = this.values();
12718
+ const colors = this.colors();
12719
+ const percents = this.percents();
12720
+ const total = this.total() || 1;
12721
+ const height = this.height();
12722
+ const cx = VIEW_WIDTH$1 / 2;
12723
+ const cy = height / 2;
12724
+ const outerRadius = Math.min(VIEW_WIDTH$1, height) / 2 - MARGIN;
12725
+ const innerRadius = outerRadius * clamp(this.innerRadiusRatio(), 0, 0.9);
12726
+ const slices = [];
12727
+ let startAngle = 0;
12728
+ data.forEach((d, i) => {
12729
+ const value = values[i];
12730
+ const sweep = (360 * value) / total;
12731
+ if (value > 0) {
12732
+ slices.push({
12733
+ label: d.label,
12734
+ color: colors[i],
12735
+ path: donutSlicePath(cx, cy, outerRadius, innerRadius, startAngle, startAngle + sweep),
12736
+ value: this.format(value),
12737
+ percent: percents[i],
12738
+ });
12739
+ }
12740
+ startAngle += sweep;
12741
+ });
12742
+ return {
12743
+ slices,
12744
+ cx,
12745
+ cy,
12746
+ labelY: cy + 22,
12747
+ showCenter: innerRadius > 0,
12748
+ };
12749
+ }, ...(ngDevMode ? [{ debugName: "plot" }] : []));
12750
+ tableModel = computed(() => {
12751
+ const data = this.data();
12752
+ const values = this.values();
12753
+ const percents = this.percents();
12754
+ return {
12755
+ caption: this.ariaLabel(),
12756
+ headers: [this.i18n.categoryHeader, this.i18n.valueHeader, this.i18n.percentHeader],
12757
+ rows: data.map((d, i) => [d.label, this.format(values[i]), percents[i]]),
12758
+ };
12759
+ }, ...(ngDevMode ? [{ debugName: "tableModel" }] : []));
12760
+ toggleTable() {
12761
+ this.tableOverride.set(!this.tableVisible());
12762
+ }
12763
+ /** Format a percentage to at most one fraction digit, suffixed with `%`. */
12764
+ formatPercent(value) {
12765
+ return `${formatNumber(value, this.locale(), { maximumFractionDigits: 1 })}%`;
12766
+ }
12767
+ format(value) {
12768
+ return formatNumber(value, this.locale(), this.valueFormat());
12769
+ }
12770
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AfDonutChartComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
12771
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.2", type: AfDonutChartComponent, isStandalone: true, selector: "af-donut-chart", inputs: { ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: true, transformFunction: null }, data: { classPropertyName: "data", publicName: "data", isSignal: true, isRequired: true, transformFunction: null }, innerRadiusRatio: { classPropertyName: "innerRadiusRatio", publicName: "innerRadiusRatio", isSignal: true, isRequired: false, transformFunction: null }, centerLabel: { classPropertyName: "centerLabel", publicName: "centerLabel", isSignal: true, isRequired: false, transformFunction: null }, centerValue: { classPropertyName: "centerValue", publicName: "centerValue", isSignal: true, isRequired: false, transformFunction: null }, showLegend: { classPropertyName: "showLegend", publicName: "showLegend", isSignal: true, isRequired: false, transformFunction: null }, showPercentInLegend: { classPropertyName: "showPercentInLegend", publicName: "showPercentInLegend", isSignal: true, isRequired: false, transformFunction: null }, height: { classPropertyName: "height", publicName: "height", isSignal: true, isRequired: false, transformFunction: null }, locale: { classPropertyName: "locale", publicName: "locale", isSignal: true, isRequired: false, transformFunction: null }, valueFormat: { classPropertyName: "valueFormat", publicName: "valueFormat", isSignal: true, isRequired: false, transformFunction: null }, showTableInitially: { classPropertyName: "showTableInitially", publicName: "showTableInitially", isSignal: true, isRequired: false, transformFunction: null } }, host: { styleAttribute: "display: block" }, ngImport: i0, template: `
12772
+ <div class="ct-chart" [class.ct-chart--show-table]="tableVisible()">
12773
+ <figure class="ct-chart__figure">
12774
+ @if (hasData()) {
12775
+ <div class="ct-chart__toolbar">
12776
+ <button
12777
+ type="button"
12778
+ class="ct-chart__toggle"
12779
+ [attr.aria-expanded]="tableVisible()"
12780
+ [attr.aria-controls]="tableId"
12781
+ (click)="toggleTable()">
12782
+ {{ tableVisible() ? i18n.hideTable : i18n.showTable }}
12783
+ </button>
12784
+ </div>
12785
+
12786
+ <svg
12787
+ class="ct-chart__svg"
12788
+ [attr.viewBox]="viewBox()"
12789
+ preserveAspectRatio="xMidYMid meet"
12790
+ role="img"
12791
+ [attr.aria-label]="svgAriaLabel()">
12792
+ @for (slice of plot().slices; track slice.label) {
12793
+ <path class="ct-chart__slice" [attr.d]="slice.path" [style.color]="slice.color">
12794
+ <title>{{ slice.label }}: {{ slice.value }} ({{ slice.percent }})</title>
12795
+ </path>
12796
+ }
12797
+
12798
+ @if (plot().showCenter) {
12799
+ <text
12800
+ class="ct-chart__donut-value"
12801
+ [attr.x]="plot().cx"
12802
+ [attr.y]="plot().cy"
12803
+ text-anchor="middle"
12804
+ dominant-baseline="middle">
12805
+ {{ centerValueText() }}
12806
+ </text>
12807
+ @if (centerLabel()) {
12808
+ <text
12809
+ class="ct-chart__donut-label"
12810
+ [attr.x]="plot().cx"
12811
+ [attr.y]="plot().labelY"
12812
+ text-anchor="middle"
12813
+ dominant-baseline="middle">
12814
+ {{ centerLabel() }}
12815
+ </text>
12816
+ }
12817
+ }
12818
+ </svg>
12819
+
12820
+ @if (showLegend()) {
12821
+ <ul class="ct-chart__legend">
12822
+ @for (item of legend(); track item.label) {
12823
+ <li class="ct-chart__legend-item">
12824
+ <span
12825
+ class="ct-chart__legend-marker"
12826
+ [style.color]="item.color"
12827
+ aria-hidden="true"></span>
12828
+ <span>{{ item.label }}</span>
12829
+ @if (showPercentInLegend()) {
12830
+ <span>{{ item.percent }}</span>
12831
+ }
12832
+ </li>
12833
+ }
12834
+ </ul>
12835
+ }
12836
+
12837
+ <div class="ct-chart__table-wrap" [id]="tableId">
12838
+ <af-chart-data-table [model]="tableModel()" />
12839
+ </div>
12840
+ } @else {
12841
+ <div class="ct-chart__empty" role="status">{{ i18n.noData }}</div>
12842
+ }
12843
+ </figure>
12844
+ </div>
12845
+ `, isInline: true, dependencies: [{ kind: "component", type: AfChartDataTableComponent, selector: "af-chart-data-table", inputs: ["model"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
12846
+ }
12847
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AfDonutChartComponent, decorators: [{
12848
+ type: Component,
12849
+ args: [{
12850
+ selector: 'af-donut-chart',
12851
+ changeDetection: ChangeDetectionStrategy.OnPush,
12852
+ imports: [AfChartDataTableComponent],
12853
+ host: { style: 'display: block' },
12854
+ template: `
12855
+ <div class="ct-chart" [class.ct-chart--show-table]="tableVisible()">
12856
+ <figure class="ct-chart__figure">
12857
+ @if (hasData()) {
12858
+ <div class="ct-chart__toolbar">
12859
+ <button
12860
+ type="button"
12861
+ class="ct-chart__toggle"
12862
+ [attr.aria-expanded]="tableVisible()"
12863
+ [attr.aria-controls]="tableId"
12864
+ (click)="toggleTable()">
12865
+ {{ tableVisible() ? i18n.hideTable : i18n.showTable }}
12866
+ </button>
12867
+ </div>
12868
+
12869
+ <svg
12870
+ class="ct-chart__svg"
12871
+ [attr.viewBox]="viewBox()"
12872
+ preserveAspectRatio="xMidYMid meet"
12873
+ role="img"
12874
+ [attr.aria-label]="svgAriaLabel()">
12875
+ @for (slice of plot().slices; track slice.label) {
12876
+ <path class="ct-chart__slice" [attr.d]="slice.path" [style.color]="slice.color">
12877
+ <title>{{ slice.label }}: {{ slice.value }} ({{ slice.percent }})</title>
12878
+ </path>
12879
+ }
12880
+
12881
+ @if (plot().showCenter) {
12882
+ <text
12883
+ class="ct-chart__donut-value"
12884
+ [attr.x]="plot().cx"
12885
+ [attr.y]="plot().cy"
12886
+ text-anchor="middle"
12887
+ dominant-baseline="middle">
12888
+ {{ centerValueText() }}
12889
+ </text>
12890
+ @if (centerLabel()) {
12891
+ <text
12892
+ class="ct-chart__donut-label"
12893
+ [attr.x]="plot().cx"
12894
+ [attr.y]="plot().labelY"
12895
+ text-anchor="middle"
12896
+ dominant-baseline="middle">
12897
+ {{ centerLabel() }}
12898
+ </text>
12899
+ }
12900
+ }
12901
+ </svg>
12902
+
12903
+ @if (showLegend()) {
12904
+ <ul class="ct-chart__legend">
12905
+ @for (item of legend(); track item.label) {
12906
+ <li class="ct-chart__legend-item">
12907
+ <span
12908
+ class="ct-chart__legend-marker"
12909
+ [style.color]="item.color"
12910
+ aria-hidden="true"></span>
12911
+ <span>{{ item.label }}</span>
12912
+ @if (showPercentInLegend()) {
12913
+ <span>{{ item.percent }}</span>
12914
+ }
12915
+ </li>
12916
+ }
12917
+ </ul>
12918
+ }
12919
+
12920
+ <div class="ct-chart__table-wrap" [id]="tableId">
12921
+ <af-chart-data-table [model]="tableModel()" />
12922
+ </div>
12923
+ } @else {
12924
+ <div class="ct-chart__empty" role="status">{{ i18n.noData }}</div>
12925
+ }
12926
+ </figure>
12927
+ </div>
12928
+ `,
12929
+ }]
12930
+ }], propDecorators: { ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: true }] }], data: [{ type: i0.Input, args: [{ isSignal: true, alias: "data", required: true }] }], innerRadiusRatio: [{ type: i0.Input, args: [{ isSignal: true, alias: "innerRadiusRatio", required: false }] }], centerLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "centerLabel", required: false }] }], centerValue: [{ type: i0.Input, args: [{ isSignal: true, alias: "centerValue", required: false }] }], showLegend: [{ type: i0.Input, args: [{ isSignal: true, alias: "showLegend", required: false }] }], showPercentInLegend: [{ type: i0.Input, args: [{ isSignal: true, alias: "showPercentInLegend", required: false }] }], height: [{ type: i0.Input, args: [{ isSignal: true, alias: "height", required: false }] }], locale: [{ type: i0.Input, args: [{ isSignal: true, alias: "locale", required: false }] }], valueFormat: [{ type: i0.Input, args: [{ isSignal: true, alias: "valueFormat", required: false }] }], showTableInitially: [{ type: i0.Input, args: [{ isSignal: true, alias: "showTableInitially", required: false }] }] } });
12931
+
12932
+ /**
12933
+ * Test harness for AfDonutChartComponent.
12934
+ *
12935
+ * Wraps DOM queries behind a semantic API so tests read intent, not selectors.
12936
+ *
12937
+ * @example
12938
+ * const harness = new AfDonutChartHarness(fixture.nativeElement);
12939
+ * expect(harness.getSliceCount()).toBe(2);
12940
+ * harness.toggleTable();
12941
+ * expect(harness.isTableVisible()).toBe(true);
12942
+ */
12943
+ class AfDonutChartHarness {
12944
+ hostEl;
12945
+ constructor(container) {
12946
+ const el = container.querySelector('af-donut-chart');
12947
+ if (!el) {
12948
+ throw new Error('AfDonutChartHarness: af-donut-chart element not found in container.');
12949
+ }
12950
+ this.hostEl = el;
12951
+ }
12952
+ /** Returns the chart `<svg>`, or null when the empty state is shown. */
12953
+ getSvg() {
12954
+ return this.hostEl.querySelector('svg.ct-chart__svg');
12955
+ }
12956
+ /** Returns the SVG's `aria-label`. */
12957
+ getAriaLabel() {
12958
+ return this.getSvg()?.getAttribute('aria-label') ?? null;
12959
+ }
12960
+ /** Returns the number of rendered (non-zero) ring slices. */
12961
+ getSliceCount() {
12962
+ return this.hostEl.querySelectorAll('.ct-chart__slice').length;
12963
+ }
12964
+ /** Returns all slice `<path>` `d` attributes. */
12965
+ getSlicePaths() {
12966
+ return Array.from(this.hostEl.querySelectorAll('.ct-chart__slice')).map((p) => p.getAttribute('d') ?? '');
12967
+ }
12968
+ /** Returns the big centre value text, or null in pie mode (no centre text). */
12969
+ getCenterValue() {
12970
+ return this.hostEl.querySelector('.ct-chart__donut-value')?.textContent?.trim() ?? null;
12971
+ }
12972
+ /** Returns the small centre caption text, or null when none is rendered. */
12973
+ getCenterLabel() {
12974
+ return this.hostEl.querySelector('.ct-chart__donut-label')?.textContent?.trim() ?? null;
12975
+ }
12976
+ /** Returns the legend item texts in order (label plus optional percent). */
12977
+ getLegendLabels() {
12978
+ return Array.from(this.hostEl.querySelectorAll('.ct-chart__legend-item')).map((li) => li.textContent?.trim().replace(/\s+/g, ' ') ?? '');
12979
+ }
12980
+ /** Returns whether the empty-state message is shown. */
12981
+ isEmpty() {
12982
+ return this.hostEl.querySelector('.ct-chart__empty') !== null;
12983
+ }
12984
+ /** Returns the accessible data table element (always present when data exists). */
12985
+ getTable() {
12986
+ return this.hostEl.querySelector('table.ct-chart__table');
12987
+ }
12988
+ /** Returns the data-table header cell texts. */
12989
+ getTableHeaders() {
12990
+ return Array.from(this.hostEl.querySelectorAll('.ct-chart__table thead th')).map((th) => th.textContent?.trim() ?? '');
12991
+ }
12992
+ /** Returns the number of data-table body rows. */
12993
+ getTableRowCount() {
12994
+ return this.hostEl.querySelectorAll('.ct-chart__table tbody tr').length;
12995
+ }
12996
+ /** Returns the toggle button that reveals/hides the data table. */
12997
+ getToggle() {
12998
+ return this.hostEl.querySelector('button.ct-chart__toggle');
12999
+ }
13000
+ /** Clicks the data-table toggle button. */
13001
+ toggleTable() {
13002
+ this.getToggle()?.click();
13003
+ }
13004
+ /** Returns whether the data table is visually revealed. */
13005
+ isTableVisible() {
13006
+ return this.hostEl.querySelector('.ct-chart--show-table') !== null;
13007
+ }
13008
+ }
13009
+
13010
+ /** Inset (in viewBox units) so the stroke and the last-point dot are never clipped. */
13011
+ const PAD$1 = 2;
13012
+ /** Default series colour token used when no explicit `color` is provided. */
13013
+ const DEFAULT_COLOR = 'var(--color-chart-series-1)';
13014
+ /** Fallback summary template used when {@link AF_CHART_I18N} omits `sparklineSummary`. */
13015
+ const DEFAULT_SUMMARY = '{label}: {count} points, min {min}, max {max}, latest {last}.';
13016
+ /**
13017
+ * Tiny inline trend line (sparkline) for KPI tiles and dense tables.
13018
+ *
13019
+ * Renders a compact SVG line — optionally filled and with a marker on the final
13020
+ * point — at a fixed small size (it does not stretch to its container). Geometry
13021
+ * is computed with the SSR-safe `chart-geometry` helpers — no DOM, `window` or
13022
+ * `Date` access — so it renders identically on the server.
13023
+ *
13024
+ * Because a sparkline is too small for axes, a legend or a visible toggle, the
13025
+ * trend is summarised in the SVG's `aria-label` and the exact values are always
13026
+ * available through a visually-hidden data-table fallback.
13027
+ *
13028
+ * @example KPI tile trend
13029
+ * <af-sparkline ariaLabel="Sign-ups, last 14 days" [values]="signups" />
13030
+ *
13031
+ * @example Filled sparkline without the end marker
13032
+ * <af-sparkline
13033
+ * ariaLabel="Latency, last hour"
13034
+ * [values]="latency"
13035
+ * [area]="true"
13036
+ * [showLastDot]="false" />
13037
+ *
13038
+ * @accessibility
13039
+ * - The SVG carries `role="img"` and an `aria-label` that conveys the trend
13040
+ * (point count, min, max and latest value) plus a pointer to the data table.
13041
+ * - A visually-hidden {@link AfChartDataTableComponent} mirrors every value so
13042
+ * information is never conveyed by colour or shape alone (WCAG 1.4.1).
13043
+ * - The series colour defaults to the contrast-checked `--color-chart-series-1`
13044
+ * token; colour is never the sole carrier of meaning.
13045
+ * - All user-facing strings are configurable via {@link AF_CHART_I18N}.
13046
+ */
13047
+ class AfSparklineComponent {
13048
+ i18n = inject(AF_CHART_I18N);
13049
+ /** Accessible chart label (required by the WAI-ARIA `img` role). */
13050
+ ariaLabel = input.required(...(ngDevMode ? [{ debugName: "ariaLabel" }] : []));
13051
+ /** The numeric series to plot. `null` entries are skipped in the geometry. */
13052
+ values = input.required(...(ngDevMode ? [{ debugName: "values" }] : []));
13053
+ /** Optional labels for the data-table fallback; falls back to 1-based indices. */
13054
+ categories = input([], ...(ngDevMode ? [{ debugName: "categories" }] : []));
13055
+ /** Fill the area beneath the line. */
13056
+ area = input(false, { ...(ngDevMode ? { debugName: "area" } : {}), transform: booleanAttribute });
13057
+ /** Render a marker on the final point. */
13058
+ showLastDot = input(true, { ...(ngDevMode ? { debugName: "showLastDot" } : {}), transform: booleanAttribute });
13059
+ /** Explicit CSS colour; defaults to `--color-chart-series-1`. */
13060
+ color = input(...(ngDevMode ? [undefined, { debugName: "color" }] : []));
13061
+ /** SVG width in viewBox units (the sparkline keeps this compact size). */
13062
+ width = input(120, ...(ngDevMode ? [{ debugName: "width" }] : []));
13063
+ /** SVG height in viewBox units (the sparkline keeps this compact size). */
13064
+ height = input(32, ...(ngDevMode ? [{ debugName: "height" }] : []));
13065
+ /** BCP-47 locale for number formatting (e.g. `'de-DE'`). */
13066
+ locale = input(...(ngDevMode ? [undefined, { debugName: "locale" }] : []));
13067
+ /** `Intl.NumberFormat` options for the aria-label and data-table values. */
13068
+ valueFormat = input(...(ngDevMode ? [undefined, { debugName: "valueFormat" }] : []));
13069
+ /** Numeric values with `null`/`NaN` entries removed — drives geometry and the summary. */
13070
+ numericValues = computed(() => this.values().filter((v) => v != null && !Number.isNaN(v)), ...(ngDevMode ? [{ debugName: "numericValues" }] : []));
13071
+ hasData = computed(() => this.numericValues().length > 0, ...(ngDevMode ? [{ debugName: "hasData" }] : []));
13072
+ resolvedColor = computed(() => this.color() || DEFAULT_COLOR, ...(ngDevMode ? [{ debugName: "resolvedColor" }] : []));
13073
+ viewBox = computed(() => `0 0 ${this.width()} ${this.height()}`, ...(ngDevMode ? [{ debugName: "viewBox" }] : []));
13074
+ /** Trend-describing label: count, min, max and latest value, plus the table pointer. */
13075
+ svgAriaLabel = computed(() => {
13076
+ const values = this.numericValues();
13077
+ const count = values.length;
13078
+ const summary = (this.i18n.sparklineSummary ?? DEFAULT_SUMMARY)
13079
+ .replace('{label}', this.ariaLabel())
13080
+ .replace('{count}', String(count))
13081
+ .replace('{min}', this.format(Math.min(...values)))
13082
+ .replace('{max}', this.format(Math.max(...values)))
13083
+ .replace('{last}', this.format(values[count - 1]));
13084
+ return `${summary} ${this.i18n.tableSuffix}`;
13085
+ }, ...(ngDevMode ? [{ debugName: "svgAriaLabel" }] : []));
13086
+ /** All geometry needed by the template, derived in a single pass. */
13087
+ plot = computed(() => {
13088
+ const values = this.numericValues();
13089
+ const width = this.width();
13090
+ const height = this.height();
13091
+ const left = PAD$1;
13092
+ const right = width - PAD$1;
13093
+ const top = PAD$1;
13094
+ const bottom = height - PAD$1;
13095
+ const dataMin = Math.min(...values);
13096
+ const dataMax = Math.max(...values);
13097
+ const yScale = scaleLinear(dataMin, dataMax, bottom, top);
13098
+ const xAt = (i) => values.length <= 1
13099
+ ? (left + right) / 2
13100
+ : left + (i / (values.length - 1)) * (right - left);
13101
+ const points = values.map((v, i) => ({
13102
+ x: roundTo(xAt(i), 2),
13103
+ y: roundTo(yScale(v), 2),
13104
+ }));
13105
+ return {
13106
+ line: buildLinePath(points),
13107
+ area: this.area() ? buildAreaPath(points, bottom) : '',
13108
+ lastDot: points[points.length - 1] ?? null,
13109
+ };
13110
+ }, ...(ngDevMode ? [{ debugName: "plot" }] : []));
13111
+ tableModel = computed(() => {
13112
+ const values = this.values();
13113
+ const categories = this.categories();
13114
+ return {
13115
+ caption: this.ariaLabel(),
13116
+ headers: [this.i18n.categoryHeader, this.i18n.valueHeader],
13117
+ rows: values.map((v, i) => [
13118
+ categories[i] ?? String(i + 1),
13119
+ v == null ? '—' : this.format(v),
13120
+ ]),
13121
+ };
13122
+ }, ...(ngDevMode ? [{ debugName: "tableModel" }] : []));
13123
+ format(value) {
13124
+ return formatNumber(value, this.locale(), this.valueFormat());
13125
+ }
13126
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AfSparklineComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
13127
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.2", type: AfSparklineComponent, isStandalone: true, selector: "af-sparkline", inputs: { ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: true, transformFunction: null }, values: { classPropertyName: "values", publicName: "values", isSignal: true, isRequired: true, transformFunction: null }, categories: { classPropertyName: "categories", publicName: "categories", isSignal: true, isRequired: false, transformFunction: null }, area: { classPropertyName: "area", publicName: "area", isSignal: true, isRequired: false, transformFunction: null }, showLastDot: { classPropertyName: "showLastDot", publicName: "showLastDot", isSignal: true, isRequired: false, transformFunction: null }, color: { classPropertyName: "color", publicName: "color", isSignal: true, isRequired: false, transformFunction: null }, width: { classPropertyName: "width", publicName: "width", isSignal: true, isRequired: false, transformFunction: null }, height: { classPropertyName: "height", publicName: "height", isSignal: true, isRequired: false, transformFunction: null }, locale: { classPropertyName: "locale", publicName: "locale", isSignal: true, isRequired: false, transformFunction: null }, valueFormat: { classPropertyName: "valueFormat", publicName: "valueFormat", isSignal: true, isRequired: false, transformFunction: null } }, host: { styleAttribute: "display: inline-block" }, ngImport: i0, template: `
13128
+ <div class="ct-chart ct-chart--sparkline">
13129
+ @if (hasData()) {
13130
+ <svg
13131
+ class="ct-chart__svg"
13132
+ [attr.width]="width()"
13133
+ [attr.height]="height()"
13134
+ [attr.viewBox]="viewBox()"
13135
+ preserveAspectRatio="xMidYMid meet"
13136
+ role="img"
13137
+ [attr.aria-label]="svgAriaLabel()">
13138
+ <g [style.color]="resolvedColor()">
13139
+ @if (area()) {
13140
+ <path class="ct-chart__area" [attr.d]="plot().area" />
13141
+ }
13142
+ <path class="ct-chart__line" [attr.d]="plot().line" />
13143
+ @if (showLastDot() && plot().lastDot; as dot) {
13144
+ <circle class="ct-chart__dot" [attr.cx]="dot.x" [attr.cy]="dot.y" r="2.5" />
13145
+ }
13146
+ </g>
13147
+ </svg>
13148
+
13149
+ <div class="ct-chart__table-wrap">
13150
+ <af-chart-data-table [model]="tableModel()" />
13151
+ </div>
13152
+ } @else {
13153
+ <span class="ct-chart__empty" role="status">{{ i18n.noData }}</span>
13154
+ }
13155
+ </div>
13156
+ `, isInline: true, dependencies: [{ kind: "component", type: AfChartDataTableComponent, selector: "af-chart-data-table", inputs: ["model"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
13157
+ }
13158
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AfSparklineComponent, decorators: [{
13159
+ type: Component,
13160
+ args: [{
13161
+ selector: 'af-sparkline',
13162
+ changeDetection: ChangeDetectionStrategy.OnPush,
13163
+ imports: [AfChartDataTableComponent],
13164
+ host: { style: 'display: inline-block' },
13165
+ template: `
13166
+ <div class="ct-chart ct-chart--sparkline">
13167
+ @if (hasData()) {
13168
+ <svg
13169
+ class="ct-chart__svg"
13170
+ [attr.width]="width()"
13171
+ [attr.height]="height()"
13172
+ [attr.viewBox]="viewBox()"
13173
+ preserveAspectRatio="xMidYMid meet"
13174
+ role="img"
13175
+ [attr.aria-label]="svgAriaLabel()">
13176
+ <g [style.color]="resolvedColor()">
13177
+ @if (area()) {
13178
+ <path class="ct-chart__area" [attr.d]="plot().area" />
13179
+ }
13180
+ <path class="ct-chart__line" [attr.d]="plot().line" />
13181
+ @if (showLastDot() && plot().lastDot; as dot) {
13182
+ <circle class="ct-chart__dot" [attr.cx]="dot.x" [attr.cy]="dot.y" r="2.5" />
13183
+ }
13184
+ </g>
13185
+ </svg>
13186
+
13187
+ <div class="ct-chart__table-wrap">
13188
+ <af-chart-data-table [model]="tableModel()" />
13189
+ </div>
13190
+ } @else {
13191
+ <span class="ct-chart__empty" role="status">{{ i18n.noData }}</span>
13192
+ }
13193
+ </div>
13194
+ `,
13195
+ }]
13196
+ }], propDecorators: { ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: true }] }], values: [{ type: i0.Input, args: [{ isSignal: true, alias: "values", required: true }] }], categories: [{ type: i0.Input, args: [{ isSignal: true, alias: "categories", required: false }] }], area: [{ type: i0.Input, args: [{ isSignal: true, alias: "area", required: false }] }], showLastDot: [{ type: i0.Input, args: [{ isSignal: true, alias: "showLastDot", required: false }] }], color: [{ type: i0.Input, args: [{ isSignal: true, alias: "color", required: false }] }], width: [{ type: i0.Input, args: [{ isSignal: true, alias: "width", required: false }] }], height: [{ type: i0.Input, args: [{ isSignal: true, alias: "height", required: false }] }], locale: [{ type: i0.Input, args: [{ isSignal: true, alias: "locale", required: false }] }], valueFormat: [{ type: i0.Input, args: [{ isSignal: true, alias: "valueFormat", required: false }] }] } });
13197
+
13198
+ /**
13199
+ * Test harness for AfSparklineComponent.
13200
+ *
13201
+ * Wraps DOM queries behind a semantic API so tests read intent, not selectors.
13202
+ *
13203
+ * @example
13204
+ * const harness = new AfSparklineHarness(fixture.nativeElement);
13205
+ * expect(harness.getLinePath()).toMatch(/^M/);
13206
+ * expect(harness.getDotCount()).toBe(1);
13207
+ */
13208
+ class AfSparklineHarness {
13209
+ hostEl;
13210
+ constructor(container) {
13211
+ const el = container.querySelector('af-sparkline');
13212
+ if (!el) {
13213
+ throw new Error('AfSparklineHarness: af-sparkline element not found in container.');
13214
+ }
13215
+ this.hostEl = el;
13216
+ }
13217
+ /** Returns the sparkline `<svg>`, or null when the empty state is shown. */
13218
+ getSvg() {
13219
+ return this.hostEl.querySelector('svg.ct-chart__svg');
13220
+ }
13221
+ /** Returns the SVG's `aria-label`. */
13222
+ getAriaLabel() {
13223
+ return this.getSvg()?.getAttribute('aria-label') ?? null;
13224
+ }
13225
+ /** Returns the line `<path>` `d` attribute, or null when empty. */
13226
+ getLinePath() {
13227
+ return this.hostEl.querySelector('.ct-chart__line')?.getAttribute('d') ?? null;
13228
+ }
13229
+ /** Returns whether an area fill is rendered. */
13230
+ hasArea() {
13231
+ return this.hostEl.querySelector('.ct-chart__area') !== null;
13232
+ }
13233
+ /** Returns the number of point markers (0 or 1). */
13234
+ getDotCount() {
13235
+ return this.hostEl.querySelectorAll('.ct-chart__dot').length;
13236
+ }
13237
+ /** Returns whether the empty-state message is shown. */
13238
+ isEmpty() {
13239
+ return this.hostEl.querySelector('.ct-chart__empty') !== null;
13240
+ }
13241
+ /** Returns the accessible data table element (always present when data exists). */
13242
+ getTable() {
13243
+ return this.hostEl.querySelector('table.ct-chart__table');
13244
+ }
13245
+ /** Returns the number of data-table body rows. */
13246
+ getTableRowCount() {
13247
+ return this.hostEl.querySelectorAll('.ct-chart__table tbody tr').length;
13248
+ }
13249
+ }
13250
+
13251
+ /** Inset (in viewBox units) so the arc stroke is never clipped at the viewBox edge. */
13252
+ const PAD = 4;
13253
+ /** Fixed viewBox width — the gauge keeps a square-ish aspect and scales fluidly. */
13254
+ const VIEW_WIDTH = 240;
13255
+ /** A full sweep can't be drawn as one arc; clamp the end angle just shy of 360°. */
13256
+ const MAX_RING_ANGLE = 359.999;
13257
+ /**
13258
+ * Accessible single-value gauge for progress, compliance and utilisation metrics.
13259
+ *
13260
+ * Renders one metric as an SVG ring (full 360° track) or bottom-opening semi
13261
+ * gauge, with the value drawn as a coloured arc over a muted track and the
13262
+ * formatted number at the centre. Geometry is computed with the SSR-safe
13263
+ * `chart-geometry` helpers — no DOM, `window` or `Date` access — so it renders
13264
+ * identically on the server.
13265
+ *
13266
+ * The arc colour follows {@link AfGaugeThreshold} bands (the highest `from` ≤
13267
+ * value wins) or the explicit {@link status} input when no band matches.
13268
+ *
13269
+ * @example Compliance ring
13270
+ * <af-gauge ariaLabel="Compliance score" [value]="82" valueText="82%" caption="Compliance" />
13271
+ *
13272
+ * @example Utilisation with thresholds
13273
+ * <af-gauge
13274
+ * ariaLabel="Quota utilisation"
13275
+ * [value]="95"
13276
+ * valueText="95%"
13277
+ * [thresholds]="[
13278
+ * { from: 0, status: 'success' },
13279
+ * { from: 80, status: 'warning' },
13280
+ * { from: 90, status: 'danger' },
13281
+ * ]" />
13282
+ *
13283
+ * @accessibility
13284
+ * - Because a gauge conveys a single metric, the root element uses the WAI-ARIA
13285
+ * `meter` role with `aria-valuenow` / `aria-valuemin` / `aria-valuemax` and an
13286
+ * `aria-valuetext` carrying the human-readable value (e.g. `"82%"`). This is
13287
+ * more semantic than the data-table fallback used by the multi-value charts,
13288
+ * so no table is rendered here.
13289
+ * - The SVG is purely decorative (`aria-hidden="true"`, `focusable="false"`);
13290
+ * the meter role carries everything assistive technology needs.
13291
+ * - The centre value text mirrors `aria-valuetext`, so colour is never the sole
13292
+ * carrier of meaning (WCAG 1.4.1); the status colours come from the
13293
+ * contrast-checked `--color-state-*` tokens.
13294
+ * - The empty/invalid state (`max <= min`) drops the `meter` role and exposes a
13295
+ * `role="status"` message instead, so the meter is never invalid.
13296
+ * - All user-facing strings are configurable via {@link AF_CHART_I18N}.
13297
+ */
13298
+ class AfGaugeComponent {
13299
+ i18n = inject(AF_CHART_I18N);
13300
+ /** Accessible label for the gauge (required by the WAI-ARIA `meter` role). */
13301
+ ariaLabel = input.required(...(ngDevMode ? [{ debugName: "ariaLabel" }] : []));
13302
+ /**
13303
+ * The metric value. Clamped into `[min, max]` for the drawn arc and
13304
+ * `aria-valuenow`; `aria-valuetext` reports the raw value unchanged.
13305
+ */
13306
+ value = input.required(...(ngDevMode ? [{ debugName: "value" }] : []));
13307
+ /** Lower bound of the gauge scale. */
13308
+ min = input(0, ...(ngDevMode ? [{ debugName: "min" }] : []));
13309
+ /** Upper bound of the gauge scale. */
13310
+ max = input(100, ...(ngDevMode ? [{ debugName: "max" }] : []));
13311
+ /**
13312
+ * Colour threshold bands. The band with the highest `from` ≤ `value` selects
13313
+ * the arc's `status`; if none match (or the list is empty), {@link status} is used.
13314
+ */
13315
+ thresholds = input([], ...(ngDevMode ? [{ debugName: "thresholds" }] : []));
13316
+ /** Explicit colour bucket; overridden by a matching {@link thresholds} band. */
13317
+ status = input('default', ...(ngDevMode ? [{ debugName: "status" }] : []));
13318
+ /** `'ring'` = full 360° track; `'semi'` = bottom-opening 180° half gauge. */
13319
+ shape = input('ring', ...(ngDevMode ? [{ debugName: "shape" }] : []));
13320
+ /** Centre/`aria-valuetext` override; defaults to the locale-formatted `value`. */
13321
+ valueText = input('', ...(ngDevMode ? [{ debugName: "valueText" }] : []));
13322
+ /** Small caption rendered beneath the value (e.g. the metric name). */
13323
+ caption = input('', ...(ngDevMode ? [{ debugName: "caption" }] : []));
13324
+ /** Arc thickness in viewBox units; drives both the arc radius and the rendered stroke. */
13325
+ strokeWidth = input(14, ...(ngDevMode ? [{ debugName: "strokeWidth" }] : []));
13326
+ /** Render the centre value (and caption) text. */
13327
+ showValue = input(true, { ...(ngDevMode ? { debugName: "showValue" } : {}), transform: booleanAttribute });
13328
+ /** BCP-47 locale for the default value formatting (e.g. `'de-DE'`). */
13329
+ locale = input(...(ngDevMode ? [undefined, { debugName: "locale" }] : []));
13330
+ /** `Intl.NumberFormat` options for the default value formatting. */
13331
+ valueFormat = input(...(ngDevMode ? [undefined, { debugName: "valueFormat" }] : []));
13332
+ /** Gauge height in viewBox units (width scales fluidly to the container). */
13333
+ height = input(200, ...(ngDevMode ? [{ debugName: "height" }] : []));
13334
+ /** True when the scale is degenerate (`max <= min`) — renders the empty state. */
13335
+ isEmpty = computed(() => this.max() <= this.min(), ...(ngDevMode ? [{ debugName: "isEmpty" }] : []));
13336
+ viewBox = computed(() => `0 0 ${VIEW_WIDTH} ${this.height()}`, ...(ngDevMode ? [{ debugName: "viewBox" }] : []));
13337
+ /** Human-readable value: the explicit override or the locale-formatted number. */
13338
+ valueDisplay = computed(() => this.valueText() || formatNumber(this.value(), this.locale(), this.valueFormat()), ...(ngDevMode ? [{ debugName: "valueDisplay" }] : []));
13339
+ /**
13340
+ * `value` clamped into `[min, max]` for `aria-valuenow`. The WAI-ARIA `meter`
13341
+ * role requires `valuenow ∈ [valuemin, valuemax]`; `aria-valuetext` keeps the
13342
+ * raw reading so assistive tech still announces an out-of-range value verbatim.
13343
+ */
13344
+ clampedValue = computed(() => clamp(this.value(), this.min(), this.max()), ...(ngDevMode ? [{ debugName: "clampedValue" }] : []));
13345
+ /** Arc thickness as a CSS length, fed to the `--ct-chart-gauge-width` token. */
13346
+ gaugeWidth = computed(() => `${this.strokeWidth()}px`, ...(ngDevMode ? [{ debugName: "gaugeWidth" }] : []));
13347
+ /**
13348
+ * Resolved status bucket: the highest threshold band whose `from <= value`,
13349
+ * falling back to the explicit {@link status} input.
13350
+ */
13351
+ resolvedStatus = computed(() => {
13352
+ const value = this.value();
13353
+ const sorted = [...this.thresholds()].sort((a, b) => a.from - b.from);
13354
+ let match = null;
13355
+ for (const band of sorted) {
13356
+ if (band.from <= value)
13357
+ match = band.status;
13358
+ }
13359
+ return match ?? this.status();
13360
+ }, ...(ngDevMode ? [{ debugName: "resolvedStatus" }] : []));
13361
+ valueClass = computed(() => `ct-chart__gauge-value ct-chart__gauge-value--${this.resolvedStatus()}`, ...(ngDevMode ? [{ debugName: "valueClass" }] : []));
13362
+ /** All geometry needed by the template, derived in a single pass. */
13363
+ plot = computed(() => {
13364
+ const height = this.height();
13365
+ const isSemi = this.shape() === 'semi';
13366
+ const cx = VIEW_WIDTH / 2;
13367
+ const radius = Math.min(VIEW_WIDTH, height) / 2 - this.strokeWidth() / 2 - PAD;
13368
+ const span = this.max() - this.min();
13369
+ const fraction = span > 0 ? clamp((this.value() - this.min()) / span, 0, 1) : 0;
13370
+ if (isSemi) {
13371
+ // Bottom-opening half gauge: angles increase clockwise from -90° (9 o'clock)
13372
+ // over the top (0°) to +90° (3 o'clock). Centre the arc vertically.
13373
+ const cy = height / 2 + radius / 2;
13374
+ const valueEnd = -90 + 180 * fraction;
13375
+ return {
13376
+ cx: roundTo(cx, 2),
13377
+ cy: roundTo(cy, 2),
13378
+ track: arcPath(cx, cy, radius, -90, 90),
13379
+ value: fraction > 0 ? arcPath(cx, cy, radius, -90, valueEnd) : '',
13380
+ textY: roundTo(cy - radius / 6, 2),
13381
+ };
13382
+ }
13383
+ // Full ring: track is a closed circle; value sweeps clockwise from 12 o'clock.
13384
+ const cy = height / 2;
13385
+ const valueEnd = Math.min(360 * fraction, MAX_RING_ANGLE);
13386
+ return {
13387
+ cx: roundTo(cx, 2),
13388
+ cy: roundTo(cy, 2),
13389
+ track: arcPath(cx, cy, radius, 0, MAX_RING_ANGLE),
13390
+ value: fraction > 0 ? arcPath(cx, cy, radius, 0, valueEnd) : '',
13391
+ textY: roundTo(cy, 2),
13392
+ };
13393
+ }, ...(ngDevMode ? [{ debugName: "plot" }] : []));
13394
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AfGaugeComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
13395
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.2", type: AfGaugeComponent, isStandalone: true, selector: "af-gauge", inputs: { ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: true, transformFunction: null }, value: { classPropertyName: "value", publicName: "value", isSignal: true, isRequired: true, transformFunction: null }, min: { classPropertyName: "min", publicName: "min", isSignal: true, isRequired: false, transformFunction: null }, max: { classPropertyName: "max", publicName: "max", isSignal: true, isRequired: false, transformFunction: null }, thresholds: { classPropertyName: "thresholds", publicName: "thresholds", isSignal: true, isRequired: false, transformFunction: null }, status: { classPropertyName: "status", publicName: "status", isSignal: true, isRequired: false, transformFunction: null }, shape: { classPropertyName: "shape", publicName: "shape", isSignal: true, isRequired: false, transformFunction: null }, valueText: { classPropertyName: "valueText", publicName: "valueText", isSignal: true, isRequired: false, transformFunction: null }, caption: { classPropertyName: "caption", publicName: "caption", isSignal: true, isRequired: false, transformFunction: null }, strokeWidth: { classPropertyName: "strokeWidth", publicName: "strokeWidth", isSignal: true, isRequired: false, transformFunction: null }, showValue: { classPropertyName: "showValue", publicName: "showValue", isSignal: true, isRequired: false, transformFunction: null }, locale: { classPropertyName: "locale", publicName: "locale", isSignal: true, isRequired: false, transformFunction: null }, valueFormat: { classPropertyName: "valueFormat", publicName: "valueFormat", isSignal: true, isRequired: false, transformFunction: null }, height: { classPropertyName: "height", publicName: "height", isSignal: true, isRequired: false, transformFunction: null } }, host: { styleAttribute: "display: block" }, ngImport: i0, template: `
13396
+ <div
13397
+ class="ct-chart"
13398
+ [style.--ct-chart-gauge-width]="gaugeWidth()"
13399
+ [attr.role]="isEmpty() ? null : 'meter'"
13400
+ [attr.aria-label]="isEmpty() ? null : ariaLabel()"
13401
+ [attr.aria-valuenow]="isEmpty() ? null : clampedValue()"
13402
+ [attr.aria-valuemin]="isEmpty() ? null : min()"
13403
+ [attr.aria-valuemax]="isEmpty() ? null : max()"
13404
+ [attr.aria-valuetext]="isEmpty() ? null : valueDisplay()">
13405
+ <figure class="ct-chart__figure">
13406
+ @if (isEmpty()) {
13407
+ <div class="ct-chart__empty" role="status">{{ i18n.noData }}</div>
13408
+ } @else {
13409
+ <svg
13410
+ class="ct-chart__svg"
13411
+ [attr.viewBox]="viewBox()"
13412
+ preserveAspectRatio="xMidYMid meet"
13413
+ aria-hidden="true"
13414
+ focusable="false">
13415
+ <path class="ct-chart__gauge-track" [attr.d]="plot().track" />
13416
+ @if (plot().value) {
13417
+ <path [class]="valueClass()" [attr.d]="plot().value" />
13418
+ }
13419
+ @if (showValue()) {
13420
+ <text
13421
+ class="ct-chart__gauge-text"
13422
+ [attr.x]="plot().cx"
13423
+ [attr.y]="plot().textY"
13424
+ text-anchor="middle"
13425
+ dominant-baseline="middle">
13426
+ {{ valueDisplay() }}
13427
+ </text>
13428
+ @if (caption()) {
13429
+ <text
13430
+ class="ct-chart__gauge-caption"
13431
+ [attr.x]="plot().cx"
13432
+ [attr.y]="plot().textY + 22"
13433
+ text-anchor="middle"
13434
+ dominant-baseline="middle">
13435
+ {{ caption() }}
13436
+ </text>
13437
+ }
13438
+ }
13439
+ </svg>
13440
+ }
13441
+ </figure>
13442
+ </div>
13443
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
13444
+ }
13445
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AfGaugeComponent, decorators: [{
13446
+ type: Component,
13447
+ args: [{
13448
+ selector: 'af-gauge',
13449
+ changeDetection: ChangeDetectionStrategy.OnPush,
13450
+ host: { style: 'display: block' },
13451
+ template: `
13452
+ <div
13453
+ class="ct-chart"
13454
+ [style.--ct-chart-gauge-width]="gaugeWidth()"
13455
+ [attr.role]="isEmpty() ? null : 'meter'"
13456
+ [attr.aria-label]="isEmpty() ? null : ariaLabel()"
13457
+ [attr.aria-valuenow]="isEmpty() ? null : clampedValue()"
13458
+ [attr.aria-valuemin]="isEmpty() ? null : min()"
13459
+ [attr.aria-valuemax]="isEmpty() ? null : max()"
13460
+ [attr.aria-valuetext]="isEmpty() ? null : valueDisplay()">
13461
+ <figure class="ct-chart__figure">
13462
+ @if (isEmpty()) {
13463
+ <div class="ct-chart__empty" role="status">{{ i18n.noData }}</div>
13464
+ } @else {
13465
+ <svg
13466
+ class="ct-chart__svg"
13467
+ [attr.viewBox]="viewBox()"
13468
+ preserveAspectRatio="xMidYMid meet"
13469
+ aria-hidden="true"
13470
+ focusable="false">
13471
+ <path class="ct-chart__gauge-track" [attr.d]="plot().track" />
13472
+ @if (plot().value) {
13473
+ <path [class]="valueClass()" [attr.d]="plot().value" />
13474
+ }
13475
+ @if (showValue()) {
13476
+ <text
13477
+ class="ct-chart__gauge-text"
13478
+ [attr.x]="plot().cx"
13479
+ [attr.y]="plot().textY"
13480
+ text-anchor="middle"
13481
+ dominant-baseline="middle">
13482
+ {{ valueDisplay() }}
13483
+ </text>
13484
+ @if (caption()) {
13485
+ <text
13486
+ class="ct-chart__gauge-caption"
13487
+ [attr.x]="plot().cx"
13488
+ [attr.y]="plot().textY + 22"
13489
+ text-anchor="middle"
13490
+ dominant-baseline="middle">
13491
+ {{ caption() }}
13492
+ </text>
13493
+ }
13494
+ }
13495
+ </svg>
13496
+ }
13497
+ </figure>
13498
+ </div>
13499
+ `,
13500
+ }]
13501
+ }], propDecorators: { ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: true }] }], value: [{ type: i0.Input, args: [{ isSignal: true, alias: "value", required: true }] }], min: [{ type: i0.Input, args: [{ isSignal: true, alias: "min", required: false }] }], max: [{ type: i0.Input, args: [{ isSignal: true, alias: "max", required: false }] }], thresholds: [{ type: i0.Input, args: [{ isSignal: true, alias: "thresholds", required: false }] }], status: [{ type: i0.Input, args: [{ isSignal: true, alias: "status", required: false }] }], shape: [{ type: i0.Input, args: [{ isSignal: true, alias: "shape", required: false }] }], valueText: [{ type: i0.Input, args: [{ isSignal: true, alias: "valueText", required: false }] }], caption: [{ type: i0.Input, args: [{ isSignal: true, alias: "caption", required: false }] }], strokeWidth: [{ type: i0.Input, args: [{ isSignal: true, alias: "strokeWidth", required: false }] }], showValue: [{ type: i0.Input, args: [{ isSignal: true, alias: "showValue", required: false }] }], locale: [{ type: i0.Input, args: [{ isSignal: true, alias: "locale", required: false }] }], valueFormat: [{ type: i0.Input, args: [{ isSignal: true, alias: "valueFormat", required: false }] }], height: [{ type: i0.Input, args: [{ isSignal: true, alias: "height", required: false }] }] } });
13502
+
13503
+ /**
13504
+ * Test harness for AfGaugeComponent.
13505
+ *
13506
+ * Wraps DOM queries behind a semantic API so tests read intent, not selectors.
13507
+ *
13508
+ * @example
13509
+ * const harness = new AfGaugeHarness(fixture.nativeElement);
13510
+ * expect(harness.getRole()).toBe('meter');
13511
+ * expect(harness.getAriaValueNow()).toBe('82');
13512
+ */
13513
+ class AfGaugeHarness {
13514
+ hostEl;
13515
+ constructor(container) {
13516
+ const el = container.querySelector('af-gauge');
13517
+ if (!el) {
13518
+ throw new Error('AfGaugeHarness: af-gauge element not found in container.');
13519
+ }
13520
+ this.hostEl = el;
13521
+ }
13522
+ /** Returns the `.ct-chart` root that carries the `meter` role and value attributes. */
13523
+ getRoot() {
13524
+ return this.hostEl.querySelector('.ct-chart');
13525
+ }
13526
+ /** Returns the root's `role` (`'meter'` with data, null in the empty state). */
13527
+ getRole() {
13528
+ return this.getRoot()?.getAttribute('role') ?? null;
13529
+ }
13530
+ /** Returns the `aria-valuenow` of the meter root. */
13531
+ getAriaValueNow() {
13532
+ return this.getRoot()?.getAttribute('aria-valuenow') ?? null;
13533
+ }
13534
+ /** Returns the `aria-valuemin` of the meter root. */
13535
+ getAriaValueMin() {
13536
+ return this.getRoot()?.getAttribute('aria-valuemin') ?? null;
13537
+ }
13538
+ /** Returns the `aria-valuemax` of the meter root. */
13539
+ getAriaValueMax() {
13540
+ return this.getRoot()?.getAttribute('aria-valuemax') ?? null;
13541
+ }
13542
+ /** Returns the human-readable `aria-valuetext` of the meter root. */
13543
+ getAriaValueText() {
13544
+ return this.getRoot()?.getAttribute('aria-valuetext') ?? null;
13545
+ }
13546
+ /** Returns the `aria-label` of the meter root. */
13547
+ getAriaLabel() {
13548
+ return this.getRoot()?.getAttribute('aria-label') ?? null;
13549
+ }
13550
+ /** Returns the decorative `<svg>`, or null when the empty state is shown. */
13551
+ getSvg() {
13552
+ return this.hostEl.querySelector('svg.ct-chart__svg');
13553
+ }
13554
+ /**
13555
+ * Returns the `--ct-chart-gauge-width` custom property (e.g. `'14px'`) that
13556
+ * drives the rendered arc thickness, or null when unset.
13557
+ */
13558
+ getGaugeWidth() {
13559
+ return this.getRoot()?.style.getPropertyValue('--ct-chart-gauge-width').trim() || null;
13560
+ }
13561
+ /** Returns the track arc `<path>`. */
13562
+ getTrackPath() {
13563
+ return this.hostEl.querySelector('path.ct-chart__gauge-track');
13564
+ }
13565
+ /** Returns the value arc `<path>` (absent when the value fraction is zero). */
13566
+ getValuePath() {
13567
+ return this.hostEl.querySelector('path.ct-chart__gauge-value');
13568
+ }
13569
+ /** Returns the `--status` modifier on the value arc (e.g. `'success'`), or null. */
13570
+ getValueStatusClass() {
13571
+ const path = this.getValuePath();
13572
+ if (!path)
13573
+ return null;
13574
+ const modifier = Array.from(path.classList).find((c) => c.startsWith('ct-chart__gauge-value--'));
13575
+ return modifier ? modifier.replace('ct-chart__gauge-value--', '') : null;
13576
+ }
13577
+ /** Returns the centre value text, or null when the value text is hidden. */
13578
+ getCenterText() {
13579
+ return this.hostEl.querySelector('.ct-chart__gauge-text')?.textContent?.trim() ?? null;
13580
+ }
13581
+ /** Returns the caption text below the value, or null when absent. */
13582
+ getCaption() {
13583
+ return this.hostEl.querySelector('.ct-chart__gauge-caption')?.textContent?.trim() ?? null;
13584
+ }
13585
+ /** Returns whether the empty-state message is shown. */
13586
+ isEmpty() {
13587
+ return this.hostEl.querySelector('.ct-chart__empty') !== null;
13588
+ }
13589
+ }
13590
+
11306
13591
  // Components
11307
13592
 
11308
13593
  /**
@@ -11341,5 +13626,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
11341
13626
  * Generated bundle index. Do not edit.
11342
13627
  */
11343
13628
 
11344
- export { AF_ACCORDION_I18N, AF_ALERT_I18N, AF_INPUT_I18N, AF_SELECT_I18N, AF_SELECT_MENU_I18N, AF_TREE_I18N, AVATAR_SEED_PALETTE_SIZE, AfAccordionComponent, AfAccordionHarness, AfAccordionItemComponent, AfAccordionItemHarness, AfAlertComponent, AfAlertHarness, AfAppShellComponent, AfAppShellPageHeaderComponent, AfAppShellV2Component, AfAppShellV2ToolbarComponent, AfAvatarComponent, AfBadgeComponent, AfBadgeHarness, AfBannerComponent, AfBreadcrumbsComponent, AfButtonComponent, AfButtonHarness, AfCardComponent, AfCellDefDirective, AfCheckboxComponent, AfChipComponent, AfChipInputComponent, AfComboboxComponent, AfDataTableComponent, AfDatepickerComponent, AfDividerComponent, AfDrawerComponent, AfDropdownComponent, AfEmptyStateComponent, AfFieldComponent, AfFileUploadComponent, AfFormatLabelPipe, AfIconComponent, AfInputComponent, AfInputHarness, AfModalComponent, AfNavItemComponent, AfNavTabsComponent, AfNavbarComponent, AfPaginationComponent, AfPopoverComponent, AfPopoverTriggerDirective, AfProgressBarComponent, AfRadioComponent, AfRadioGroupComponent, AfSelectComponent, AfSelectHarness, AfSelectMenuComponent, AfSelectMenuHarness, AfSidebarComponent, AfSkeletonComponent, AfSkipLinkComponent, AfSliderComponent, AfSpinnerComponent, AfSwitchComponent, AfTabPanelComponent, AfTableBodyComponent, AfTableCellComponent, AfTableComponent, AfTableHeaderCellComponent, AfTableHeaderComponent, AfTableRowComponent, AfTabsComponent, AfTextareaComponent, AfToastContainerComponent, AfToastService, AfToggleGroupComponent, AfToolbarComponent, AfTooltipDirective, AfTreeComponent, AfTreeHarness, AfTreeNodeHarness };
13629
+ export { AF_ACCORDION_I18N, AF_ALERT_I18N, AF_CHART_I18N, AF_CHART_PALETTE_SIZE, AF_INPUT_I18N, AF_SELECT_I18N, AF_SELECT_MENU_I18N, AF_TREE_I18N, AVATAR_SEED_PALETTE_SIZE, AfAccordionComponent, AfAccordionHarness, AfAccordionItemComponent, AfAccordionItemHarness, AfAlertComponent, AfAlertHarness, AfAppShellComponent, AfAppShellPageHeaderComponent, AfAppShellV2Component, AfAppShellV2ToolbarComponent, AfAvatarComponent, AfBadgeComponent, AfBadgeHarness, AfBannerComponent, AfBarChartComponent, AfBarChartHarness, AfBreadcrumbsComponent, AfButtonComponent, AfButtonHarness, AfCardComponent, AfCellDefDirective, AfChartDataTableComponent, AfCheckboxComponent, AfChipComponent, AfChipInputComponent, AfComboboxComponent, AfDataTableComponent, AfDatepickerComponent, AfDividerComponent, AfDonutChartComponent, AfDonutChartHarness, AfDrawerComponent, AfDropdownComponent, AfEmptyStateComponent, AfFieldComponent, AfFileUploadComponent, AfFormatLabelPipe, AfGaugeComponent, AfGaugeHarness, AfIconComponent, AfInputComponent, AfInputHarness, AfLineChartComponent, AfLineChartHarness, AfModalComponent, AfNavItemComponent, AfNavTabsComponent, AfNavbarComponent, AfPaginationComponent, AfPopoverComponent, AfPopoverTriggerDirective, AfProgressBarComponent, AfRadioComponent, AfRadioGroupComponent, AfSelectComponent, AfSelectHarness, AfSelectMenuComponent, AfSelectMenuHarness, AfSidebarComponent, AfSkeletonComponent, AfSkipLinkComponent, AfSliderComponent, AfSparklineComponent, AfSparklineHarness, AfSpinnerComponent, AfSwitchComponent, AfTabPanelComponent, AfTableBodyComponent, AfTableCellComponent, AfTableComponent, AfTableHeaderCellComponent, AfTableHeaderComponent, AfTableRowComponent, AfTabsComponent, AfTextareaComponent, AfToastContainerComponent, AfToastService, AfToggleGroupComponent, AfToolbarComponent, AfTooltipDirective, AfTreeComponent, AfTreeHarness, AfTreeNodeHarness };
11345
13630
  //# sourceMappingURL=neuravision-ng-construct.mjs.map