@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
|