@porscheinformatik/clr-addons
Version:
Addon components for Clarity Angular
2,719 lines • 222 kB
JavaScript
import * as i0 from '@angular/core';
import { input, viewChild, computed, signal, Directive, ChangeDetectionStrategy, Component, Injectable, inject, output, Renderer2, DestroyRef, DOCUMENT, Inject, ElementRef, contentChild, NgModule } from '@angular/core';
import { format, select, scaleBand, scaleLinear, max, axisBottom, axisLeft, group, sum, scalePoint, min, line, curveMonotoneX, area, axisRight, arc, pie, color } from 'd3';
import * as i1 from '@clr/angular';
import { ClrAlertModule, ClrDropdownModule, ClrIcon, ClrSignpostContent, ClrStartDateInput, ClrEndDateInput, ClrSignpostModule } from '@clr/angular';
import * as i3 from '@clr/angular/icon';
import { ClarityIcons, downloadIcon } from '@clr/angular/icon';
import * as i2 from '@clr/angular/popover/common';
import { NgxSkeletonLoaderComponent } from 'ngx-skeleton-loader';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ReplaySubject, skip } from 'rxjs';
import { map, distinctUntilChanged, debounceTime } from 'rxjs/operators';
import * as i8 from '@angular/common';
import { CommonModule, DecimalPipe } from '@angular/common';
/*
* Copyright (c) 2018-2026 Porsche Informatik. All Rights Reserved.
* This software is released under MIT license.
* The full license information can be found in LICENSE in the root directory of this project.
*/
const TOO_MANY_ITEMS_MESSAGE = 'too many items';
const TOO_MANY_ITEMS_GROUPED_MESSAGE = 'too many items';
const TOO_MANY_ITEMS_ALERT_TYPE = 'warning';
const NO_ITEMS_MESSAGE = 'no items';
const NO_ITEMS_ALERT_TYPE = 'info';
/**
* Converts a chart color value to a CSS-compatible string.
*
* Supports:
* - Hex values: '#e57200' → returned as-is
* - CSS custom props: '--cds-global-color-lavender-1000' → wrapped in var(...)
* - Any other string: 'rgb(...)' / 'hsl(...)' → returned as-is
*/
function toChartColor(color) {
if (!color) {
return '';
}
return color.startsWith('--') ? `var(${color})` : color;
}
class TextRenderer {
constructor() {
this.cachedFont = '';
this.cachedEllipsisWidth = 0;
this.canvas = document.createElement('canvas');
const ctx = this.canvas.getContext('2d');
if (!ctx) {
throw new Error('2D context error');
}
this.context = ctx;
}
render(text, availableHeight, availableWidth, fontSize = '12px', fontFamily = 'Arial') {
const font = `${fontSize} ${fontFamily}`;
if (this.cachedFont !== font) {
this.context.font = font;
this.cachedEllipsisWidth = this.context.measureText('...').width;
this.cachedFont = font;
}
else {
this.context.font = font;
}
const textMetrics = this.context.measureText(text);
const textWidth = textMetrics.width;
const textHeight = Math.abs(textMetrics.actualBoundingBoxAscent) + Math.abs(textMetrics.actualBoundingBoxDescent);
if (textWidth <= availableWidth && textHeight <= availableHeight) {
return text;
}
let remainingWidth = availableWidth - this.cachedEllipsisWidth;
let truncatedText = '';
for (const char of text) {
const charWidth = this.context.measureText(char).width;
if (remainingWidth - charWidth >= 0) {
truncatedText += char;
remainingWidth -= charWidth;
}
else {
break;
}
}
truncatedText = truncatedText.trim();
if (truncatedText.length === 0) {
return '';
}
return truncatedText + '...';
}
}
const percentage = (value, total) => {
if (total <= 0) {
return 0;
}
return Math.min(100, Math.max(0, Math.round((value / total) * 100)));
};
const d3percentFormat = (f => (d) => `${f(d)}%`)(format('.1f'));
/*
* Copyright (c) 2018-2026 Porsche Informatik. All Rights Reserved.
* This software is released under MIT license.
* The full license information can be found in LICENSE in the root directory of this project.
*/
/**
* Abstract base class shared by all chart components.
* ...
*/
class ChartBase {
constructor() {
// ── Common Input ───────────────────────────────────────────────────────────
/** Whether the chart is in loading state (shows skeleton). */
this.loading = input(false, ...(ngDevMode ? [{ debugName: "loading" }] : /* istanbul ignore next */ []));
// ── View references ────────────────────────────────────────────────────────
/** Reference to the `<svg #chart>` element in the component template. */
this.chartRef = viewChild('chart', ...(ngDevMode ? [{ debugName: "chartRef" }] : /* istanbul ignore next */ []));
/** Reference to the `<div #container>` host element in the component template. */
this.containerRef = viewChild('container', ...(ngDevMode ? [{ debugName: "containerRef" }] : /* istanbul ignore next */ []));
/** Exposes the raw SVG element for the chart-export button. */
this.svgElement = computed(() => this.chartRef()?.nativeElement, ...(ngDevMode ? [{ debugName: "svgElement" }] : /* istanbul ignore next */ []));
// ── Tooltip State ──────────────────────────────────────────────────────────
this.selectedItem = signal(undefined, ...(ngDevMode ? [{ debugName: "selectedItem" }] : /* istanbul ignore next */ []));
this.tooltipPosition = signal(undefined, ...(ngDevMode ? [{ debugName: "tooltipPosition" }] : /* istanbul ignore next */ []));
}
// ── Lifecycle ──────────────────────────────────────────────────────────────
/** Schedules the first render after the view is ready. */
ngAfterViewInit() {
requestAnimationFrame(() => this.updateChart());
}
// ── Common Implementations ─────────────────────────────────────────────────
/** Returns the pixel dimensions of the container div. */
getContainerDimensions() {
const el = this.containerRef().nativeElement;
return { width: el.clientWidth, height: el.clientHeight };
}
/**
* Clears the active tooltip. Override in subclasses that have additional
* selection state
*/
resetTooltip() {
if (this.selectedItem() || this.tooltipPosition()) {
this.selectedItem.set(undefined);
this.tooltipPosition.set(undefined);
}
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: ChartBase, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.2.0", version: "21.2.18", type: ChartBase, isStandalone: true, inputs: { loading: { classPropertyName: "loading", publicName: "loading", isSignal: true, isRequired: false, transformFunction: null } }, viewQueries: [{ propertyName: "chartRef", first: true, predicate: ["chart"], descendants: true, isSignal: true }, { propertyName: "containerRef", first: true, predicate: ["container"], descendants: true, isSignal: true }], ngImport: i0 }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: ChartBase, decorators: [{
type: Directive
}], propDecorators: { loading: [{ type: i0.Input, args: [{ isSignal: true, alias: "loading", required: false }] }], chartRef: [{ type: i0.ViewChild, args: ['chart', { isSignal: true }] }], containerRef: [{ type: i0.ViewChild, args: ['container', { isSignal: true }] }] } });
class ChartAlertOverlayComponent {
constructor() {
this.alertMessage = input.required(...(ngDevMode ? [{ debugName: "alertMessage" }] : /* istanbul ignore next */ []));
this.alertType = input('info', ...(ngDevMode ? [{ debugName: "alertType" }] : /* istanbul ignore next */ []));
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: ChartAlertOverlayComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.2.18", type: ChartAlertOverlayComponent, isStandalone: true, selector: "cng-chart-alert-overlay", inputs: { alertMessage: { classPropertyName: "alertMessage", publicName: "alertMessage", isSignal: true, isRequired: true, transformFunction: null }, alertType: { classPropertyName: "alertType", publicName: "alertType", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: `
<clr-alert [clrAlertSizeSmall]="true" [clrAlertClosable]="false" [clrAlertType]="alertType()">
<clr-alert-item>
<span class="alert-text">
{{ alertMessage() }}
</span>
</clr-alert-item>
</clr-alert>
<div class="overlay"></div>
`, isInline: true, styles: [":host{position:absolute;top:0;left:0;right:0;pointer-events:none;z-index:1}:host clr-alert{pointer-events:auto}:host clr-alert ::ng-deep .alert{margin-bottom:0}:host .overlay{height:2rem;background:linear-gradient(to bottom,#fff,#fff0)}\n"], dependencies: [{ kind: "ngmodule", type: ClrAlertModule }, { kind: "component", type: i1.ClrAlert, selector: "clr-alert", inputs: ["clrAlertSizeSmall", "clrAlertClosable", "clrAlertAppLevel", "clrCloseButtonAriaLabel", "clrAlertLightweight", "clrAlertType", "clrAlertIcon", "clrAlertClosed"], outputs: ["clrAlertClosedChange"] }, { kind: "component", type: i1.ClrAlertItem, selector: "clr-alert-item" }, { kind: "directive", type: i1.ClrAlertText, selector: ".alert-text" }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: ChartAlertOverlayComponent, decorators: [{
type: Component,
args: [{ selector: 'cng-chart-alert-overlay', template: `
<clr-alert [clrAlertSizeSmall]="true" [clrAlertClosable]="false" [clrAlertType]="alertType()">
<clr-alert-item>
<span class="alert-text">
{{ alertMessage() }}
</span>
</clr-alert-item>
</clr-alert>
<div class="overlay"></div>
`, changeDetection: ChangeDetectionStrategy.OnPush, imports: [ClrAlertModule], styles: [":host{position:absolute;top:0;left:0;right:0;pointer-events:none;z-index:1}:host clr-alert{pointer-events:auto}:host clr-alert ::ng-deep .alert{margin-bottom:0}:host .overlay{height:2rem;background:linear-gradient(to bottom,#fff,#fff0)}\n"] }]
}], propDecorators: { alertMessage: [{ type: i0.Input, args: [{ isSignal: true, alias: "alertMessage", required: true }] }], alertType: [{ type: i0.Input, args: [{ isSignal: true, alias: "alertType", required: false }] }] } });
// ── Legend layout constants (mirror chart-legend.component styles) ─────────────
const LEGEND_PADDING_TOP = 12;
const LEGEND_PADDING_H = 4;
const LEGEND_ITEM_HEIGHT = 22;
const LEGEND_COLOR_SIZE = 10;
const LEGEND_FONT_SIZE = 11;
const LEGEND_TEXT_COLOR = '#666666';
/** Approximate pixel width reserved per legend item (color square + gap + label). */
const LEGEND_APPROX_ITEM_WIDTH = 130;
class ChartExportService {
exportSvg(svgEl, filename, legendItems) {
const root = this.buildExportSvg(svgEl, legendItems);
const svgStr = new XMLSerializer().serializeToString(root);
this.download(new Blob([svgStr], { type: 'image/svg+xml;charset=utf-8' }), `${filename}.svg`);
}
exportPng(svgEl, filename, legendItems) {
const root = this.buildExportSvg(svgEl, legendItems);
this.toCanvas(root).then(canvas => canvas.toBlob(blob => this.download(blob, `${filename}.png`), 'image/png'));
}
// ── Core builder ──────────────────────────────────────────────────────────────
buildExportSvg(svgEl, legendItems) {
const w = svgEl.clientWidth || Number(svgEl.getAttribute('width')) || 800;
const chartH = svgEl.clientHeight || Number(svgEl.getAttribute('height')) || 600;
const fontFamily = this.getDocumentFontFamily();
const itemsToRender = legendItems?.length ? legendItems : [];
const legendH = itemsToRender.length
? LEGEND_PADDING_TOP +
Math.ceil(itemsToRender.length / this.legendItemsPerRow(w, itemsToRender)) * LEGEND_ITEM_HEIGHT
: 0;
const totalH = chartH + legendH;
const ns = 'http://www.w3.org/2000/svg';
// Root SVG
const root = document.createElementNS(ns, 'svg');
root.setAttribute('xmlns', ns);
root.setAttribute('width', String(w));
root.setAttribute('height', String(totalH));
root.style.background = '#ffffff';
if (fontFamily) {
root.setAttribute('font-family', fontFamily);
}
// Chart contents – clone, resolve CSS vars, inline fonts, then wrap in <g>
const chartClone = svgEl.cloneNode(true);
chartClone.querySelectorAll('.domain').forEach(el => el.remove());
this.resolveStyleVariables(chartClone);
this.inlineFontFamily(chartClone, fontFamily);
const chartGroup = document.createElementNS(ns, 'g');
Array.from(chartClone.childNodes).forEach(n => chartGroup.appendChild(n.cloneNode(true)));
root.appendChild(chartGroup);
// Legend group below the chart
if (itemsToRender.length) {
root.appendChild(this.buildLegendGroup(itemsToRender, w, chartH, fontFamily));
}
return root;
}
// ── Legend SVG builder ────────────────────────────────────────────────────────
buildLegendGroup(items, width, offsetY, fontFamily) {
const ns = 'http://www.w3.org/2000/svg';
const g = document.createElementNS(ns, 'g');
g.setAttribute('transform', `translate(0,${offsetY + LEGEND_PADDING_TOP})`);
const perRow = this.legendItemsPerRow(width, items);
const colWidth = Math.floor((width - 2 * LEGEND_PADDING_H) / perRow);
let col = 0;
let row = 0;
for (const item of items) {
const x = LEGEND_PADDING_H + col * colWidth;
const y = row * LEGEND_ITEM_HEIGHT;
// Color square
const rect = document.createElementNS(ns, 'rect');
rect.setAttribute('x', String(x));
rect.setAttribute('y', String(y));
rect.setAttribute('width', String(LEGEND_COLOR_SIZE));
rect.setAttribute('height', String(LEGEND_COLOR_SIZE));
rect.setAttribute('rx', '2');
rect.setAttribute('ry', '2');
rect.setAttribute('fill', this.resolveColor(item.color));
g.appendChild(rect);
// Label text – vertically centred with the square
const text = document.createElementNS(ns, 'text');
text.setAttribute('x', String(x + LEGEND_COLOR_SIZE + 5));
text.setAttribute('y', String(y + LEGEND_COLOR_SIZE - 1));
text.setAttribute('font-size', String(LEGEND_FONT_SIZE));
text.setAttribute('fill', LEGEND_TEXT_COLOR);
if (fontFamily) {
text.setAttribute('font-family', fontFamily);
}
text.textContent = item.label;
g.appendChild(text);
col++;
if (col >= perRow) {
col = 0;
row++;
}
}
return g;
}
legendItemsPerRow(width, items) {
const usable = width - 2 * LEGEND_PADDING_H;
const perRow = Math.max(1, Math.floor(usable / LEGEND_APPROX_ITEM_WIDTH));
return Math.min(perRow, items.length);
}
// ── Canvas / PNG ──────────────────────────────────────────────────────────────
toCanvas(svgEl) {
return new Promise(resolve => {
const w = Number(svgEl.getAttribute('width')) || 800;
const h = Number(svgEl.getAttribute('height')) || 600;
const url = URL.createObjectURL(new Blob([new XMLSerializer().serializeToString(svgEl)], { type: 'image/svg+xml;charset=utf-8' }));
const img = new Image();
img.onload = () => {
const scale = 2; // 2× for retina quality
const canvas = document.createElement('canvas');
canvas.width = w * scale;
canvas.height = h * scale;
const ctx = canvas.getContext('2d');
ctx.scale(scale, scale);
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, w, h);
ctx.drawImage(img, 0, 0, w, h);
URL.revokeObjectURL(url);
resolve(canvas);
};
img.src = url;
});
}
// ── CSS variable resolution ───────────────────────────────────────────────────
/** Recursively resolves `var(--x)` references in inline style and presentation attributes. */
resolveStyleVariables(el) {
const style = el.getAttribute('style');
if (style) {
el.setAttribute('style', this.resolveVarsInString(style));
}
for (const attr of ['fill', 'stroke', 'color', 'background-color']) {
const val = el.getAttribute(attr);
if (val) {
el.setAttribute(attr, this.resolveVarsInString(val));
}
}
// Also resolve CSS var() in inline style properties directly set via D3 .style()
const inlineStyle = el.style;
if (inlineStyle) {
const fill = inlineStyle.fill;
if (fill?.includes('var(')) {
inlineStyle.fill = this.resolveVarsInString(fill);
}
const stroke = inlineStyle.stroke;
if (stroke?.includes('var(')) {
inlineStyle.stroke = this.resolveVarsInString(stroke);
}
}
Array.from(el.children).forEach(child => this.resolveStyleVariables(child));
}
/** Inlines `font-family` on every `<text>` / `<tspan>` element that doesn't already have one. */
inlineFontFamily(el, fontFamily) {
if (!fontFamily) {
return;
}
if (el.tagName === 'text' || el.tagName === 'tspan') {
if (!el.getAttribute('font-family')) {
el.setAttribute('font-family', fontFamily);
}
}
Array.from(el.children).forEach(child => this.inlineFontFamily(child, fontFamily));
}
resolveVarsInString(value) {
return value.replace(/var\(\s*(--[^,)]+?)\s*(?:,\s*([^)]+?))?\s*\)/g, (_match, varName, fallback) => {
const computed = getComputedStyle(document.documentElement).getPropertyValue(varName.trim()).trim();
return computed || fallback?.trim() || '';
});
}
resolveColor(color) {
if (!color) {
return '#cccccc';
}
if (color.startsWith('--')) {
const computed = getComputedStyle(document.documentElement).getPropertyValue(color).trim();
return computed || '#cccccc';
}
// Handle var(...) wrapper
if (color.startsWith('var(')) {
return this.resolveVarsInString(color) || '#cccccc';
}
return color;
}
getDocumentFontFamily() {
return getComputedStyle(document.body).fontFamily || 'sans-serif';
}
// ── Download ──────────────────────────────────────────────────────────────────
download(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 100);
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: ChartExportService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: ChartExportService, providedIn: 'root' }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: ChartExportService, decorators: [{
type: Injectable,
args: [{ providedIn: 'root' }]
}] });
ClarityIcons.addIcons(downloadIcon);
class ChartExportButtonComponent {
constructor() {
this.svgRef = input(undefined, ...(ngDevMode ? [{ debugName: "svgRef" }] : /* istanbul ignore next */ []));
this.filename = input('chart', ...(ngDevMode ? [{ debugName: "filename" }] : /* istanbul ignore next */ []));
this.buttonTitle = input('Export', ...(ngDevMode ? [{ debugName: "buttonTitle" }] : /* istanbul ignore next */ []));
/** Legend items to include below the chart in the exported file. */
this.legendItems = input(undefined, ...(ngDevMode ? [{ debugName: "legendItems" }] : /* istanbul ignore next */ []));
this.exportService = inject(ChartExportService);
}
export(format) {
const svg = this.svgRef();
if (!svg) {
return;
}
switch (format) {
case 'svg':
this.exportService.exportSvg(svg, this.filename(), this.legendItems());
break;
case 'png':
this.exportService.exportPng(svg, this.filename(), this.legendItems());
break;
}
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: ChartExportButtonComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.2.18", type: ChartExportButtonComponent, isStandalone: true, selector: "cng-chart-export-button", inputs: { svgRef: { classPropertyName: "svgRef", publicName: "svgRef", isSignal: true, isRequired: false, transformFunction: null }, filename: { classPropertyName: "filename", publicName: "filename", isSignal: true, isRequired: false, transformFunction: null }, buttonTitle: { classPropertyName: "buttonTitle", publicName: "buttonTitle", isSignal: true, isRequired: false, transformFunction: null }, legendItems: { classPropertyName: "legendItems", publicName: "legendItems", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: `
<clr-dropdown>
<button class="btn btn-sm btn-icon btn-link export-trigger" clrDropdownTrigger title="Export chart">
<cds-icon shape="download" size="16"></cds-icon>
{{ buttonTitle() }}
</button>
<clr-dropdown-menu *clrIfOpen clrPosition="bottom-left">
<button clrDropdownItem (click)="export('svg')">SVG</button>
<button clrDropdownItem (click)="export('png')">PNG</button>
</clr-dropdown-menu>
</clr-dropdown>
`, isInline: true, styles: [":host{position:absolute;top:15px;right:15px;z-index:10;opacity:1;pointer-events:auto}\n"], dependencies: [{ kind: "ngmodule", type: ClrDropdownModule }, { kind: "component", type: i1.ClrDropdown, selector: "clr-dropdown", inputs: ["clrCloseMenuOnItemClick"] }, { kind: "component", type: i1.ClrDropdownMenu, selector: "clr-dropdown-menu", inputs: ["clrPosition"] }, { kind: "directive", type: i1.ClrDropdownTrigger, selector: "[clrDropdownTrigger],[clrDropdownToggle]" }, { kind: "directive", type: i1.ClrDropdownItem, selector: "[clrDropdownItem]", inputs: ["clrDisabled", "id"] }, { kind: "directive", type: i2.ClrIfOpen, selector: "[clrIfOpen]", inputs: ["clrIfOpen"], outputs: ["clrIfOpenChange"] }, { kind: "component", type: i3.ClrIcon, selector: "clr-icon, cds-icon", inputs: ["shape", "size", "direction", "flip", "solid", "status", "inverse", "badge"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: ChartExportButtonComponent, decorators: [{
type: Component,
args: [{ selector: 'cng-chart-export-button', template: `
<clr-dropdown>
<button class="btn btn-sm btn-icon btn-link export-trigger" clrDropdownTrigger title="Export chart">
<cds-icon shape="download" size="16"></cds-icon>
{{ buttonTitle() }}
</button>
<clr-dropdown-menu *clrIfOpen clrPosition="bottom-left">
<button clrDropdownItem (click)="export('svg')">SVG</button>
<button clrDropdownItem (click)="export('png')">PNG</button>
</clr-dropdown-menu>
</clr-dropdown>
`, imports: [ClrDropdownModule, ClrIcon], changeDetection: ChangeDetectionStrategy.OnPush, styles: [":host{position:absolute;top:15px;right:15px;z-index:10;opacity:1;pointer-events:auto}\n"] }]
}], propDecorators: { svgRef: [{ type: i0.Input, args: [{ isSignal: true, alias: "svgRef", required: false }] }], filename: [{ type: i0.Input, args: [{ isSignal: true, alias: "filename", required: false }] }], buttonTitle: [{ type: i0.Input, args: [{ isSignal: true, alias: "buttonTitle", required: false }] }], legendItems: [{ type: i0.Input, args: [{ isSignal: true, alias: "legendItems", required: false }] }] } });
class ChartLegendComponent {
constructor() {
this.items = input.required(...(ngDevMode ? [{ debugName: "items" }] : /* istanbul ignore next */ []));
this.toChartColor = toChartColor;
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: ChartLegendComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.18", type: ChartLegendComponent, isStandalone: true, selector: "cng-chart-legend", inputs: { items: { classPropertyName: "items", publicName: "items", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0, template: `
<div class="chart-legend">
@for (item of items(); track item.label) {
<div class="legend-item">
<span class="legend-color-square" [style.background-color]="toChartColor(item.color)"></span>
<span class="legend-label">{{ item.label }}</span>
</div>
}
</div>
`, isInline: true, styles: [":host{display:block}.chart-legend{display:flex;flex-wrap:wrap;gap:.25rem 1rem;padding:.5rem 0 .25rem;font-size:11px;color:var(--cds-global-color-construction-400, #666)}.legend-item{display:flex;align-items:center;gap:.35rem}.legend-color-square{width:10px;height:10px;border-radius:2px;flex-shrink:0}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: ChartLegendComponent, decorators: [{
type: Component,
args: [{ selector: 'cng-chart-legend', template: `
<div class="chart-legend">
@for (item of items(); track item.label) {
<div class="legend-item">
<span class="legend-color-square" [style.background-color]="toChartColor(item.color)"></span>
<span class="legend-label">{{ item.label }}</span>
</div>
}
</div>
`, changeDetection: ChangeDetectionStrategy.OnPush, styles: [":host{display:block}.chart-legend{display:flex;flex-wrap:wrap;gap:.25rem 1rem;padding:.5rem 0 .25rem;font-size:11px;color:var(--cds-global-color-construction-400, #666)}.legend-item{display:flex;align-items:center;gap:.35rem}.legend-color-square{width:10px;height:10px;border-radius:2px;flex-shrink:0}\n"] }]
}], propDecorators: { items: [{ type: i0.Input, args: [{ isSignal: true, alias: "items", required: true }] }] } });
class ChartSkeletonComponent {
constructor() {
this.skeletonType = input('loading', ...(ngDevMode ? [{ debugName: "skeletonType" }] : /* istanbul ignore next */ []));
this.orientation = input.required(...(ngDevMode ? [{ debugName: "orientation" }] : /* istanbul ignore next */ []));
this.animationStyle = computed(() => (this.skeletonType() === 'loading' ? 'pulse' : undefined), ...(ngDevMode ? [{ debugName: "animationStyle" }] : /* istanbul ignore next */ []));
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: ChartSkeletonComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.18", type: ChartSkeletonComponent, isStandalone: true, selector: "cng-bar-chart-skeleton", inputs: { skeletonType: { classPropertyName: "skeletonType", publicName: "skeletonType", isSignal: true, isRequired: false, transformFunction: null }, orientation: { classPropertyName: "orientation", publicName: "orientation", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0, template: "<div class=\"chart-skeleton\" [class.placeholder-skeleton]=\"skeletonType() === 'placeholder'\">\n <div\n class=\"skeleton-bars\"\n [class.horizontal]=\"orientation() === 'horizontal'\"\n [class.vertical]=\"orientation() === 'vertical'\"\n >\n @for (_item of [1, 2, 3, 4, 5, 6]; track $index) {\n <div class=\"skeleton-bar bar-{{ $index + 1 }}\">\n <ngx-skeleton-loader\n [appearance]=\"'circle'\"\n [count]=\"1\"\n [animation]=\"animationStyle()\"\n [theme]=\"{\n width: '100%',\n height: '100%',\n 'border-radius': 'var(--clr-base-border-radius-xxs)',\n }\"\n />\n </div>\n }\n </div>\n</div>\n", styles: [".chart-skeleton{width:100%;height:100%}.placeholder-skeleton ::ng-deep .skeleton-loader{cursor:default}.skeleton-bars{display:flex;gap:var(--clr-base-gap-l);width:100%;height:100%;padding:var(--cds-global-layout-space-xs) var(--cds-global-layout-space-xl)}.skeleton-bars.vertical{flex-direction:row;align-items:flex-end;justify-content:center}.skeleton-bars.vertical .skeleton-bar{width:var(--cds-global-layout-space-md)}.skeleton-bars.vertical .skeleton-bar.bar-1{height:60%}.skeleton-bars.vertical .skeleton-bar.bar-2{height:95%}.skeleton-bars.vertical .skeleton-bar.bar-3{height:40%}.skeleton-bars.vertical .skeleton-bar.bar-4{height:75%}.skeleton-bars.vertical .skeleton-bar.bar-5{height:50%}.skeleton-bars.vertical .skeleton-bar.bar-6{height:25%}.skeleton-bars.horizontal{flex-direction:column;justify-content:center}.skeleton-bars.horizontal .skeleton-bar{height:var(--cds-global-layout-space-md)}.skeleton-bars.horizontal .skeleton-bar.bar-1{width:60%}.skeleton-bars.horizontal .skeleton-bar.bar-2{width:95%}.skeleton-bars.horizontal .skeleton-bar.bar-3{width:40%}.skeleton-bars.horizontal .skeleton-bar.bar-4{width:75%}.skeleton-bars.horizontal .skeleton-bar.bar-5{width:50%}.skeleton-bars.horizontal .skeleton-bar.bar-6{width:25%}\n"], dependencies: [{ kind: "component", type: NgxSkeletonLoaderComponent, selector: "ngx-skeleton-loader", inputs: ["count", "loadingText", "appearance", "animation", "ariaLabel", "theme", "size", "measureUnit"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: ChartSkeletonComponent, decorators: [{
type: Component,
args: [{ selector: 'cng-bar-chart-skeleton', changeDetection: ChangeDetectionStrategy.OnPush, imports: [NgxSkeletonLoaderComponent], template: "<div class=\"chart-skeleton\" [class.placeholder-skeleton]=\"skeletonType() === 'placeholder'\">\n <div\n class=\"skeleton-bars\"\n [class.horizontal]=\"orientation() === 'horizontal'\"\n [class.vertical]=\"orientation() === 'vertical'\"\n >\n @for (_item of [1, 2, 3, 4, 5, 6]; track $index) {\n <div class=\"skeleton-bar bar-{{ $index + 1 }}\">\n <ngx-skeleton-loader\n [appearance]=\"'circle'\"\n [count]=\"1\"\n [animation]=\"animationStyle()\"\n [theme]=\"{\n width: '100%',\n height: '100%',\n 'border-radius': 'var(--clr-base-border-radius-xxs)',\n }\"\n />\n </div>\n }\n </div>\n</div>\n", styles: [".chart-skeleton{width:100%;height:100%}.placeholder-skeleton ::ng-deep .skeleton-loader{cursor:default}.skeleton-bars{display:flex;gap:var(--clr-base-gap-l);width:100%;height:100%;padding:var(--cds-global-layout-space-xs) var(--cds-global-layout-space-xl)}.skeleton-bars.vertical{flex-direction:row;align-items:flex-end;justify-content:center}.skeleton-bars.vertical .skeleton-bar{width:var(--cds-global-layout-space-md)}.skeleton-bars.vertical .skeleton-bar.bar-1{height:60%}.skeleton-bars.vertical .skeleton-bar.bar-2{height:95%}.skeleton-bars.vertical .skeleton-bar.bar-3{height:40%}.skeleton-bars.vertical .skeleton-bar.bar-4{height:75%}.skeleton-bars.vertical .skeleton-bar.bar-5{height:50%}.skeleton-bars.vertical .skeleton-bar.bar-6{height:25%}.skeleton-bars.horizontal{flex-direction:column;justify-content:center}.skeleton-bars.horizontal .skeleton-bar{height:var(--cds-global-layout-space-md)}.skeleton-bars.horizontal .skeleton-bar.bar-1{width:60%}.skeleton-bars.horizontal .skeleton-bar.bar-2{width:95%}.skeleton-bars.horizontal .skeleton-bar.bar-3{width:40%}.skeleton-bars.horizontal .skeleton-bar.bar-4{width:75%}.skeleton-bars.horizontal .skeleton-bar.bar-5{width:50%}.skeleton-bars.horizontal .skeleton-bar.bar-6{width:25%}\n"] }]
}], propDecorators: { skeletonType: [{ type: i0.Input, args: [{ isSignal: true, alias: "skeletonType", required: false }] }], orientation: [{ type: i0.Input, args: [{ isSignal: true, alias: "orientation", required: true }] }] } });
class ChartTooltipComponent {
constructor() {
this.tooltipPosition = input.required(...(ngDevMode ? [{ debugName: "tooltipPosition" }] : /* istanbul ignore next */ []));
this.tooltipOrientation = input('top', ...(ngDevMode ? [{ debugName: "tooltipOrientation" }] : /* istanbul ignore next */ []));
this.squareColor = input.required(...(ngDevMode ? [{ debugName: "squareColor" }] : /* istanbul ignore next */ []));
this.tooltipClickable = input(true, ...(ngDevMode ? [{ debugName: "tooltipClickable" }] : /* istanbul ignore next */ []));
this.tooltipClosed = output();
this.tooltipHeaderClicked = output();
this.toChartColor = toChartColor;
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: ChartTooltipComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.18", type: ChartTooltipComponent, isStandalone: true, selector: "cng-chart-tooltip", inputs: { tooltipPosition: { classPropertyName: "tooltipPosition", publicName: "tooltipPosition", isSignal: true, isRequired: true, transformFunction: null }, tooltipOrientation: { classPropertyName: "tooltipOrientation", publicName: "tooltipOrientation", isSignal: true, isRequired: false, transformFunction: null }, squareColor: { classPropertyName: "squareColor", publicName: "squareColor", isSignal: true, isRequired: true, transformFunction: null }, tooltipClickable: { classPropertyName: "tooltipClickable", publicName: "tooltipClickable", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { tooltipClosed: "tooltipClosed", tooltipHeaderClicked: "tooltipHeaderClicked" }, ngImport: i0, template: "<div\n class=\"tooltip-container\"\n [class.orientation-bottom]=\"tooltipOrientation() === 'bottom'\"\n (click)=\"$event.stopPropagation()\"\n [style.left.px]=\"tooltipPosition().x\"\n [style.top.px]=\"tooltipPosition().y\"\n>\n <div class=\"tooltip-header\">\n <div class=\"color-label-wrapper\">\n @if (squareColor()) {\n <div class=\"color-square\" [style.background-color]=\"toChartColor(squareColor())\"></div>\n }\n <h5\n [class.has-more-info]=\"tooltipClickable()\"\n (click)=\"$event.stopPropagation(); tooltipClickable() && tooltipHeaderClicked.emit()\"\n >\n <ng-content select=\"cng-title\" />\n </h5>\n </div>\n <cds-icon shape=\"times\" class=\"tooltip-close-btn\" (click)=\"$event.stopPropagation(); tooltipClosed.emit()\" />\n </div>\n <div class=\"tooltip-content\">\n <ng-content />\n </div>\n <hr />\n <div class=\"tooltip-footer\"><ng-content select=\"cng-footer\" /></div>\n</div>\n", styles: [".tooltip-container{position:absolute;background:var(--cds-global-color-gray-0, #fff);border:1px solid var(--cds-global-color-gray-400, #ccc);border-radius:4px;box-shadow:0 2px 8px #00000026;padding:12px 16px;z-index:1000;min-width:150px;transform:translate(-50%,-100%) translateY(-10px)}.tooltip-container.orientation-bottom{transform:translate(-50%) translateY(10px)}.tooltip-header{display:flex;flex-direction:row;justify-content:space-between;gap:8px}.tooltip-header h5{margin:0;font-size:14px;font-weight:600;color:var(--cds-global-color-gray-1000, #000);flex-grow:1;max-width:200px}.tooltip-close-btn{cursor:pointer;color:var(--clr-close-color)}.tooltip-close-btn:hover,.tooltip-close-btn:focus{color:var(--clr-close-color-hover)}.color-label-wrapper{display:flex;flex-direction:row;gap:8px}.tooltip-content{font-size:12px;color:var(--cds-global-color-gray-700, #565656)}hr:has(+.tooltip-footer:empty){display:none}hr{margin:6px -16px}\n"], dependencies: [{ kind: "component", type: ClrIcon, selector: "clr-icon, cds-icon", inputs: ["shape", "size", "direction", "flip", "solid", "status", "inverse", "badge"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: ChartTooltipComponent, decorators: [{
type: Component,
args: [{ selector: 'cng-chart-tooltip', changeDetection: ChangeDetectionStrategy.OnPush, imports: [ClrIcon], template: "<div\n class=\"tooltip-container\"\n [class.orientation-bottom]=\"tooltipOrientation() === 'bottom'\"\n (click)=\"$event.stopPropagation()\"\n [style.left.px]=\"tooltipPosition().x\"\n [style.top.px]=\"tooltipPosition().y\"\n>\n <div class=\"tooltip-header\">\n <div class=\"color-label-wrapper\">\n @if (squareColor()) {\n <div class=\"color-square\" [style.background-color]=\"toChartColor(squareColor())\"></div>\n }\n <h5\n [class.has-more-info]=\"tooltipClickable()\"\n (click)=\"$event.stopPropagation(); tooltipClickable() && tooltipHeaderClicked.emit()\"\n >\n <ng-content select=\"cng-title\" />\n </h5>\n </div>\n <cds-icon shape=\"times\" class=\"tooltip-close-btn\" (click)=\"$event.stopPropagation(); tooltipClosed.emit()\" />\n </div>\n <div class=\"tooltip-content\">\n <ng-content />\n </div>\n <hr />\n <div class=\"tooltip-footer\"><ng-content select=\"cng-footer\" /></div>\n</div>\n", styles: [".tooltip-container{position:absolute;background:var(--cds-global-color-gray-0, #fff);border:1px solid var(--cds-global-color-gray-400, #ccc);border-radius:4px;box-shadow:0 2px 8px #00000026;padding:12px 16px;z-index:1000;min-width:150px;transform:translate(-50%,-100%) translateY(-10px)}.tooltip-container.orientation-bottom{transform:translate(-50%) translateY(10px)}.tooltip-header{display:flex;flex-direction:row;justify-content:space-between;gap:8px}.tooltip-header h5{margin:0;font-size:14px;font-weight:600;color:var(--cds-global-color-gray-1000, #000);flex-grow:1;max-width:200px}.tooltip-close-btn{cursor:pointer;color:var(--clr-close-color)}.tooltip-close-btn:hover,.tooltip-close-btn:focus{color:var(--clr-close-color-hover)}.color-label-wrapper{display:flex;flex-direction:row;gap:8px}.tooltip-content{font-size:12px;color:var(--cds-global-color-gray-700, #565656)}hr:has(+.tooltip-footer:empty){display:none}hr{margin:6px -16px}\n"] }]
}], propDecorators: { tooltipPosition: [{ type: i0.Input, args: [{ isSignal: true, alias: "tooltipPosition", required: true }] }], tooltipOrientation: [{ type: i0.Input, args: [{ isSignal: true, alias: "tooltipOrientation", required: false }] }], squareColor: [{ type: i0.Input, args: [{ isSignal: true, alias: "squareColor", required: true }] }], tooltipClickable: [{ type: i0.Input, args: [{ isSignal: true, alias: "tooltipClickable", required: false }] }], tooltipClosed: [{ type: i0.Output, args: ["tooltipClosed"] }], tooltipHeaderClicked: [{ type: i0.Output, args: ["tooltipHeaderClicked"] }] } });
// Clarity actually has an "outside-click" directive, but they don't export it 🤷
class OutsideClickDirective {
constructor() {
this.outsideClick = output({ alias: 'cngOutsideClick' });
this.renderer = inject(Renderer2);
this.destroyRef = inject(DestroyRef);
}
ngAfterViewInit() {
const listener = this.renderer.listen('document', 'click', () => this.outsideClick.emit());
this.destroyRef.onDestroy(listener);
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: OutsideClickDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.2.18", type: OutsideClickDirective, isStandalone: true, selector: "[cngOutsideClick]", outputs: { outsideClick: "cngOutsideClick" }, ngImport: i0 }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: OutsideClickDirective, decorators: [{
type: Directive,
args: [{
selector: '[cngOutsideClick]',
}]
}], propDecorators: { outsideClick: [{ type: i0.Output, args: ["cngOutsideClick"] }] } });
/**
* Enum for the Screen size in pixels, defined by clarity here https://vmware.github.io/clarity/documentation/v0.12/grid
* We consider screens above 1200px to be a desktop, between 576px and 1199px to be a tablet and below 576px to be a phone.
*/
var ScreenWidth;
(function (ScreenWidth) {
ScreenWidth[ScreenWidth["EXTRA_LARGE"] = 1200] = "EXTRA_LARGE";
ScreenWidth[ScreenWidth["LARGE"] = 992] = "LARGE";
ScreenWidth[ScreenWidth["MEDIUM"] = 768] = "MEDIUM";
ScreenWidth[ScreenWidth["SMALL"] = 576] = "SMALL";
ScreenWidth[ScreenWidth["EXTRA_SMALL"] = 575] = "EXTRA_SMALL";
})(ScreenWidth || (ScreenWidth = {}));
var ScreenOrientation;
(function (ScreenOrientation) {
ScreenOrientation["PORTRAIT"] = "PORTRAIT";
ScreenOrientation["LANDSCAPE"] = "LANDSCAPE";
})(ScreenOrientation || (ScreenOrientation = {}));
const DATEPICKER_ENABLE_BREAKPOINT = 768;
const MOBILE_USERAGENT_REGEX = /Mobi/i;
class ScreenStateService {
constructor(_document) {
this._document = _document;
this.isUserAgentMobile = false;
this.screenWidthSubject = new ReplaySubject(1);
this.screenOrientationSubject = new ReplaySubject(1);
window.addEventListener('resize', () => {
this.onResize();
});
window.addEventListener('orientationchange', () => {
this.onOrientationChange();
});
this.determineMobileUserAgent();
// Execute them once for initial values
this.onResize();
this.onOrientationChange();
}
getScreenWidthChanged() {
return this.screenWidthSubject;
}
getMobileStateChanged() {
return this.getScreenWidthChanged().pipe(map(screenWidth => screenWidth < DATEPICKER_ENABLE_BREAKPOINT && this.isUserAgentMobile), distinctUntilChanged());
}
getScreenOrientationChanged() {
return this.screenOrientationSubject;
}
onResize() {
const windowWidth = window.innerWidth;
this.screenWidthSubject.next(windowWidth);
}
onOrientationChange() {
const prevOrientation = this.screenOrientation;
const orientation = window.orientation;
// Probably not always correct since this depends on the devices default orientation.
// Better way to identify this would probably be comparing height & width of the screen.
if (orientation === -90 || orientation === 90) {
this.screenOrientation = ScreenOrientation.LANDSCAPE;
}
else {
this.screenOrientation = ScreenOrientation.PORTRAIT;
}
if (this.screenOrientation !== prevOrientation) {
this.screenOrientationSubject.next(this.screenOrientation);
}
}
determineMobileUserAgent() {
if (this._document) {
this.isUserAgentMobile = MOBILE_USERAGENT_REGEX.test(this._document.defaultView.navigator.userAgent);
}
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: ScreenStateService, deps: [{ token: DOCUMENT }], target: i0.ɵɵFactoryTarget.Injectable }); }
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: ScreenStateService, providedIn: 'root' }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: ScreenStateService, decorators: [{
type: Injectable,
args: [{ providedIn: 'root' }]
}], ctorParameters: () => [{ type: Document, decorators: [{
type: Inject,
args: [DOCUMENT]
}] }] });
class WindowResizeDirective {
constructor() {
this.debounce = input(250, ...(ngDevMode ? [{ debugName: "debounce" }] : /* istanbul ignore next */ []));
this.includeFirst = input(false, ...(ngDevMode ? [{ debugName: "includeFirst" }] : /* istanbul ignore next */ []));
this.windowResize = output({ alias: 'cngWindowResize' });
this.screenStateService = inject(ScreenStateService);
this.destroyRef = inject(DestroyRef);
}
ngOnInit() {
const skipCount = this.includeFirst() ? 0 : 1;
this.screenStateService
.getScreenWidthChanged()
.pipe(takeUntilDestroyed(this.destroyRef), skip(skipCount), debounceTime(this.debounce()))
.subscribe(width => this.windowResize.emit(width));
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: WindowResizeDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.18", type: WindowResizeDirective, isStandalone: true, selector: "[cngWindowResize]", inputs: { debounce: { classPropertyName: "debounce", publicName: "debounce", isSignal: true, isRequired: false, transformFunction: null }, includeFirst: { classPropertyName: "includeFirst", publicName: "includeFirst", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { windowResize: "cngWindowResize" }, ngImport: i0 }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: WindowResizeDirective, decorators: [{
type: Directive,
args: [{
selector: '[cngWindowResize]',
}]
}], propDecorators: { debounce: [{ type: i0.Input, args: [{ isSignal: true, alias: "debounce", required: false }] }], includeFirst: [{ type: i0.Input, args: [{ isSignal: true, alias: "includeFirst", required: false }] }], windowResize: [{ type: i0.Output, args: ["cngWindowResize"] }] } });
class BarChartComponent extends ChartBase {
static { this.HORIZONTAL_BAR_MIN_HEIGHT_PX = 25; }
constructor() {
super();
this.data = input.required(...(ngDevMode ? [{ debugName: "data" }] : /* istanbul ignore next */ []));
this.stacks = input(undefined, ...(ngDevMode ? [{ debugName: "stacks" }] : /* istanbul ignore next */ []));
this.orientation = input.required(...(ngDevMode ? [{ debugName: "orientation" }] : /* istanbul ignore next */ []));
this.tooltipOrientation = input('top', ...(ngDevMode ? [{ debugName: "tooltipOrientation" }] : /* istanbul ignore next */ []));
this.barSizePx = input(15, ...(ngDevMode ? [{ debugName: "barSizePx" }] : /* istanbul ignore next */ []));
this.barAreaSizePx = input(40, ...(ngDevMode ? [{ debugName: "barAreaSizePx" }] : /* istanbul ignore next */ []));
this.tooltipPercentOfTotal = input('of total', ...(ngDevMode ? [{ debugName: "tooltipPercentOfTotal" }] : /* istanbul ignore next */ []));
this.tooltipPercentOf = input('of', ...(ngDevMode ? [{ debugName: "tooltipPercentOf" }] : /* istanbul ignore next */ []));
this.noItemsMessage = input(NO_ITEMS_MESSAGE, ...(ngDevMode ? [{ debugName: "noItemsMessage" }] : /* istanbul ignore next */ []));
this.tooManyItemsMessage = input(TOO_MANY_ITEMS_MESSAGE, ...(ngDevMode ? [{ debugName: "tooManyItemsMessage" }] : /* istanbul ignore next */ []));
this.tooManyItemsGroupedMessage = input(TOO_MANY_ITEMS_GROUPED_MESSAGE, ...(ngDevMode ? [{ debugName: "tooManyItemsGroupedMessage" }] : /* istanbul ignore next */ []));
this.showLegend = input(true, ...(ngDevMode ? [{ debugName: "showLegend" }] : /* istanbul ignore next */ []));
this.showExportButton = input(false, ...(ngDevMode ? [{ debugName: "showExportButton" }] : /* istanbul ignore next */ []));
this.exportButtonTitle = input('Export', ...(ngDevMode ? [{ debugName: "exportButtonTitle" }] : /* istanbul ignore next */ []));
this.exportFilename = input('bar-chart', ...(ngDevMode ? [{ debugName: "exportFilename" }] : /* istanbul ignore next */ []));
/** Optional label rendered below the X axis. */
this.xAxisLabel = input('', ...(ngDevMode ? [{ debugName: "xAxisLabel" }] : /* istanbul ignore next */ []));
/** Optional label rendered rotated to the left of the Y axis. */
this.yAxisLabel = input('', ...(ngDevMode ? [{ debugName: "yAxisLabel" }] : /* istanbul ignore next */ []));
this.valueClicked = output();
this.textRenderer = new TextRenderer();
this.MARGIN = { top: 10, right: 20, bottom: 30, left: 65 };
this.toChartColor = toChartColor;
this.total = computed(() => this.data().reduce((acc, v) => acc + v.value, 0), ...(ngDevMode ? [{ debugName: "total" }] : /* istanbul ignore next */ []));
this.totalByStack = computed(() => {
const totals = {};
for (const d of this.slicedDataPoints()) {
totals[d.stackKey] = (totals[d.stackKey] ?? 0) + d.value;
}
return totals;
}, ...(ngDevMode ? [{ debugName: "totalByStack" }] : /* istanbul ignore next */ []));
this.keysByStack = computed(() => {
const keys = {};
for (const d of this.slicedDataPoints()) {
if (!keys[d.stackKey]) {
keys[d.stackKey] = [];
}
keys[d.stackKey].push(d.key);
}
return keys;
}, ...(ngDevMode ? [{ debugName: "keysByStack" }] : /* istanbul ignore next */ []));
// Count the total amount of bars by either the stacKey (if stacked) or the key (if not stacked)
this.totalBarCount = computed(() => new Set(this.data()
.filter(d => d.value > 0)
.map(d => d.stackKey ?? d.key)).size, ...(ngDevMode ? [{ debugName: "totalBarCount" }] : /* istanbul ignore next */ []));
this.showingBarCount = computed(() => new Set(this.slicedDataPoints()
.filter(d => d.value > 0)
.map(d => d.stackKey)).size, ...(ngDevMode ? [{ debugName: "showingBarCount" }] : /* istanbul ignore next */ []));
this.alertMessageAndType = computed(() => {
if (this.loading()) {
return undefined;
}
if (!this.showingBarCount()) {
return [this.noItemsMessage(), NO_ITEMS_ALERT_TYPE];
}
else if (this.totalBarCount() !== this.showingBarCount()) {
return [
this.stacks() ? this.tooManyItemsGroupedMessage() : this.tooManyItemsMessage(),
TOO_MANY_ITEMS_ALERT_TYPE,
];
}
return undefined;
}, ...(ngDevMode ? [{ debugName: "alertMessageAndType" }] : /* istanbul ignore next */ []));
this.legendItems = computed(() => {
if (!this.showLegend() || !this.data()?.length) {
return [];
}
if (this.stacks()?.length) {
// Stacked: one legend entry per distinct label (= each layer in the stack)
const seen = new Set();
const items = [];
for (const item of this.data()) {
const label = item.fullLabel ?? item.label;
if (!seen.has(label)) {
seen.add(label);
items.push({ label, color: item.color });
}
}
return items;
}
// Non-stacked: one entry per bar
return this.data().map(item => ({ label: item.fullLabel ?? item.label, color: item.color }));
}, ...(ngDevMode ? [{ debugName: "legendItems" }] : /* istanbul ignore next */ []));
/** Computed values used by the tooltip to avoid inline logic in the template. */
this.tooltipKey = computed(() => {
const item = this.selectedItem();
if (!item) {
return undefined;
}
return this.stacks()?.length ? (this.keysByStack()[item.stackKey] ?? []) : [item.key];
}, ...(ngDevMode ? [{ debugName: "tooltipKey" }] : /* istanbul ignore next */ []));
this.tooltipLabel = computed(() => {
const item = this.selectedItem();
if (!item) {
return undefined;
}
return this.stacks()?.find(stack => stack.stackKey === item.stackKey)?.label ?? item.fullLabel ?? item.label;
}, ...(ngDevMode ? [{ debugName: "tooltipLabel" }] : /* istanbul ignore next */ []));
this.tooltipValue = computed(() => {
const item = this.selectedItem();
if (!item) {
return undefined;
}
return this.stacks()?.length ? (this.totalByStack()[item.stackKey] ?? 0) : item.value;
}, ...(ngDevMode ? [{ debugName: "tooltipValue" }] : /* istanbul ignore next */ []));
/** Slices belonging to the currently selected stack – used by the tooltip @for loop. */
this.selectedStackSlices = computed(() => {
const item = this.selectedItem();
if (!item || !this.stacks()?.length) {
return [];
}
return this.slicedDataPoints().filter(d => d.stackKey === item.stackKey);
}, ...(ngDevMode ? [{ debugName: "selectedStackSlices" }] : /* istanbul ignore next */ []));
this.maxAmountOfItems = signal(undefined, ...(ngDevMode ? [{ debugName: "maxAmountOfItems" }] : /* istanbul ignore next */ []));
this.slicedDataPoints = signal([], ...(ngDevMode ? [{ debugName: "slicedDataPoints" }] : /* istanbul ignore next */ []));
this.barSelection = null;
this.labelSelection = null;
}
ngOnChanges(_changes) {
if (!this.svg) {
return;
}
requestAnimationFrame(() => this.updateChart());
}
ngAfterViewInit() {
this.createChart();
super.ngAfterViewInit(); // schedules the initial requestAnimationFrame(() => updateChart())
}
createChart() {
const element = this.chartRef().nativeElement;
this.svg = select(element);
}
updateChart() {
this.svg.selectAll('*').remove();
if (this.loading()) {
this.slicedDataPoints.set([]);
return;
}
const { width: containerWidth, height: containerHeight } = this.getContainerDimensions();
const extraBottom = this.xAxisLabel() ? 16 : 0;
const extraLeft = this.yAxisLabel() ? 16 : 0;
const leftMargin = (this.orientation() === 'horizontal' ? this.MARGIN.left : 30) + extraLeft;
const width = containerWidth - leftMargin - this.MARGIN.right;
const height = containerHeight - this.MARGIN.top - (this.MARGIN.bottom + extraBottom);
this.maxAmountOfItems.set(this.getMaxAmountOfItems(width, height));
if (!this.data()?.length) {
this.slicedDataPoints.set([]);
return;
}
const stacks = this.stackItems(this.data());
const slicedStacks = stacks.slice(0, this.maxAmountOfItems());
const flatStacks = slicedStacks.flat();
this.slicedDataPoints.set(flatStacks);
const slicedStackKeys = new Set(slicedStacks.map(stack => stack[0].stackKey));
const labels = this.stacks()?.length
? this.stacks()
.filter(stack => slicedStackKeys.has(stack.stackKey))
.map(stack => ({ stackKey: stack.stackKey, label: stack.label }))
: flatStacks.map(d => ({ stackKey: d.stackKey, label: d.label }));
const g = this.svg
.attr('width', width)
.attr('height', height)
.append('g')
.attr('transform', `translate(${leftMargin},${this.MARGIN.top})`);
if (this.orientation() === 'vertical') {
this.createVerticalChart(g, labels, width, height, this.xAxisLabel(), this.yAxisLabel(), leftMargin);
}
else {
this.createHorizontalChart(g, labels, width, height, this.xAxisLabel(), this.yAxisLabel(), leftMargin);
}
this.styleGridLines(g);
}
createVerticalChart(g, labels, width, height, xAxisLabel, yAxisLabel, leftMargin) {
const keys = labels.map(d => d.stackKey);
const x = scaleBand().domain(keys).range([0, width]);
const y = scaleLinear()
.domain([0, max(this.slicedDataPoints(), (d) => d.stackValue1) || 0])
.nice()
.range([height, 0]);
// X Axis
const labelSelection = g
.append('g')
.attr('transform', `translate(0,${height})`)
.call(axisBottom(x))
.selectAll('text');
this.labelSelection = labelSelection
.data(labels)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.call(this.addTextCommonInfo)
.style('cursor', 'pointer')
.call(this.addTextAndTitle.bind(this), x.bandwidth())
.call(this.addTextHoverHandlers.bind(this))
.call(this.addTextClickHandler.bind(this));
// Y Axis
const tickValues = y.ticks(5).filter((tick) => Number.isInteger(tick));
const yAxis = g
.append('g')
.call(axisLeft(y).tickValues(tickValues).tickSize(-width).tickFormat(format('~s')))
.selectAll('text');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
yAxis.call(this.addTextCommonInfo);
this.createBarSelectionGroups(g);
// Add highlight rect (behind)
this.addVerticalBarRectangle(x, y, 2)
.attr('class', 'bar-highlight')
.attr('fill', '#fff')
.style('stroke', (d) => toChartColor(d.color))
.attr('stroke-width', 2)
.style('opacity', 0); // hidden by default;
// Add main bar rect (on top)
this.addVerticalBarRectangle(x, y)
.attr('class', 'bar')
.style('fill', (d) => toChartColor(d.color));
this.appendAxisLabel(g, xAxisLabel, width / 2, height + 40);
this.appendAxisLabel(g, yAxisLabel, -height / 2, -(leftMargin - 10), 'rotate(-90)');
}
createHorizontalChart(g, labels, width, height, xAxisLabel, yAxisLabel, leftMargin) {
const x = scaleLinear()
.domain([0, max(this.slicedDataPoints(), (d) => d.stackValue1) || 0])
.nice()
.range([0, width]);
const keys = labels.map(d => d.stackKey);
const y = scaleBand().domain(keys).range([0, height]);
// X Axis
const tickValues = x.ticks(5).filter((tick) => Number.isInteger(tick));
const xAxis = g
.append('g')
.attr('transform', `translate(0,${height})`)
.call(axisBottom(x).tickValues(tickValues).tickSize(-height).tickFormat(format('~s')))
.selectAll('text');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
xAxis.call(this.addTextCommonInfo);
// Y Axis
const labelSelection = g
.append('g')
.attr('transform', `translate(0,0)`)
.call(axisLeft(y))
.selectAll('text');
this.labelSelection = labelSelection
.data(labels)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.call(this.addTextCommonInfo)
.style('cursor', 'pointer')
.call(this.addTextAndTitle.bind(this), this.MARGIN.left - 10)
.call(this.addTextHoverHandlers.bind(this))
.call(this.addTextClickHandler.bind(this));
this.createBarSelectionGroups(g);
// Add highlight rect (behind)
this.addHorizontalBarRectangle(x, y, 2)
.attr('class', 'bar-highlight')
.attr('fill', '#fff')
.style('stroke', (d) => toChartColor(d.color))
.attr('stroke-width', 2)
.style('opacity', 0); // hidden by default;
// Add main bar rect (on top)
this.addHorizontalBarRectangle(x, y)
.attr('class', 'bar')
.style('fill', (d) => toChartColor(d.color));
// X axis description label (value axis at bottom)
this.appendAxisLabel(g, xAxisLabel, width / 2, height + 40);
// Y axis description label: centred in the extra band reserved for it
this.appendAxisLabel(g, yAxisLabel, -height / 2, -((this.MARGIN.left + leftMargin) / 2), 'rotate(-90)');
}
createBarSelectionGroups(g) {
this.barSelection = g
.selectAll('.bar-group')
.data(this.slicedDataPoints(), (d) => d.key)
.join('g')
.attr('class', 'bar-group')
.style('cursor', 'pointer')
.on('mouseover', (_e, d) => this.setHoverStylesByStackKey(d.stackKey, d.key, true))
.on('mouseout', (_e, d) => this.setHoverStylesByStackKey(d.stackKey, d.key, false))
.call(this.addBarClickHandler.bind(this));
}
addTextCommonInfo(g) {
g.style('font-size', '11px').style('fill', 'var(--cds-global-color-construction-400, #666)');
}
addTextHoverHandlers(g) {
g.on('mouseover', (_e, d) => this.setHoverStylesByStackKey(d.stackKey, undefined, true)) //
.on('mouseout', (_e, d) => this.setHoverStylesByStackKey(d.stackKey, undefined, false));
}
setHoverStylesByStackKey(stackKey, key, isHover) {
// When hovering a stack section, we also need to highlight the label, but not the other sections of the same stack.
// But when hovering a label, we need to highlight all sections of the stack.
const barGroup = this.barSelection.filter((d) => d.stackKey === stackKey && (key == null || d.key === key));
barGroup.select('.bar').style('mix-blend-mode', isHover ? 'multiply' : 'unset');
barGroup.select('.bar-highlight').style('opacity', isHover ? '1' : '0');
this.labelSelection
.filter((d) => d.stackKey === stackKey)
.style('font-weight', isHover ? 'bold' : 'unset');
}
addBarClickHandler(g) {
g.on('click', (event, d) => {
event.stopPropagation();
this.openTooltipByKey(d.key);
});
}
addTextClickHandler(g) {
// no click handler for labels in stacked charts
if (this.stacks()?.length) {
return;
}
g.on('click', (event, d) => {
event.stopPropagation();
// Note: In non-stacked charts, the stackKey is equal to the key, so this works - but the logic is confusing [VU3REQ-4790]
this.openTooltipByKey(d.stackKey);
});
}
openTooltipByKey(key) {
const index = this.slicedDataPoints().findIndex(item => item.key === key);
const rect = this.barSelection
.filter((_d, i) => i === index)
// we actually want the bar, not the group (because if value is 0, then the group is not positioned correctly
.node()
.children[0].getBoundingClientRect();
const container = this.chartRef().nativeElement.getBoundingClientRect();
this.tooltipPosition.set({
x: rect.left - container.left + rect.width / 2,
y: (this.tooltipOrientation() === 'top' ? rect.top : rect.bottom) - container.top,
});
this.selectedItem.set(this.slicedDataPoints()[index]);
}
addTextAndTitle(g, availableWidth) {
g.each((d, index, nodes) => {
const target = select(nodes[index]);
target.text(this.textRenderer.render(d.label, 15, availableWidth, target.style('font-size'), target.style('font-family')));
target.append('title').text(d.label);
});
}
styleGridLines(g) {
g.selectAll('.tick line').style('stroke', 'var(--cds-global-color-gray-200, #e8e8e8)');
}
addVerticalBarRectangle(x, y, extraSize = 0) {
return this.barSelection
.append('rect')
.attr('x', (d) => {
return (x(d.stackKey) || 0) + (x.bandwidth() - this.barSizePx()) / 2 + extraSize;
})
.attr('y', (d) => y(d.stackValue1))
.attr('width', this.barSizePx() - 2 * extraSize)
.attr('height', (d) => y(d.stackValue0) - y(d.stackValue1))
.attr('rx', 1)
.attr('ry', 1);
}
addHorizontalBarRectangle(x, y, extraSize = 0) {
return this.barSelection
.append('rect')
.attr('x', (d) => x(d.stackValue0))
.attr('y', (d) => {
return (y(d.stackKey) || 0) + (y.bandwidth() - this.barSizePx()) / 2 + extraSize;
})
.attr('width', (d) => x(d.stackValue1) - x(d.stackValue0))
.attr('height', this.barSizePx() - 2 * extraSize)
.attr('rx', 1)
.attr('ry', 1);
}
/** Appends a shared-style axis description label to the chart group. */
appendAxisLabel(g, text, x, y, transform) {
if (!text) {
return;
}
const el = g
.append('text')
.attr('text-anchor', 'middle')
.style('font-size', '12px')
.style('fill', 'var(--cds-global-color-construction-400, #666)')
.text(text)
.attr('x', x)
.attr('y', y);
if (transform) {
el.attr('transform', transform);
}
}
stackItems(data) {
let groups;
if (this.stacks()?.length) {
groups = Array.from(group(data, (d) => d.stackKey ?? d.key));
}
else {
groups = data.map(item => [item.key, [item]]);
}
return this.mapItems(groups);
}
mapItems(groups) {
const result = [];
for (const [_, items] of groups) {
let stackValue = 0;
const stackSum = sum(items, (d) => d.value);
result.push(items.map(item => {
const stackValue0 = stackValue;
stackValue += item.value;
return {
...item,
stackValue0,
stackValue1: stackValue,
stackKey: item.stackKey ?? item.key,
percentageOfStack: stackSum === 0 ? 0 : (100 * item.value) / stackSum,
};
}));
}
return result;
}
getMaxAmountOfItems(width, height) {
if (this.orientation() === 'vertical') {
return Math.floor(width / this.barAreaSizePx());
}
return Math.floor(height / BarChartComponent.HORIZONTAL_BAR_MIN_HEIGHT_PX);
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: BarChartComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.18", type: BarChartComponent, isStandalone: false, selector: "clr-bar-chart", inputs: { data: { classPropertyName: "data", publicName: "data", isSignal: true, isRequired: true, transformFunction: null }, stacks: { classPropertyName: "stacks", publicName: "stacks", isSignal: true, isRequired: false, transformFunction: null }, orientation: { classPropertyName: "orientation", publicName: "orientation", isSignal: true, isRequired: true, transformFunction: null }, tooltipOrientation: { classPropertyName: "tooltipOrientation", publicName: "tooltipOrientation", isSignal: true, isRequired: false, transformFunction: null }, barSizePx: { classPropertyName: "barSizePx", publicName: "barSizePx", isSignal: true, isRequired: false, transformFunction: null }, barAreaSizePx: { classPropertyName: "barAreaSizePx", publicName: "barAreaSizePx", isSignal: true, isRequired: false, transformFunction: null }, tooltipPercentOfTotal: { classPropertyName: "tooltipPercentOfTotal", publicName: "tooltipPercentOfTotal", isSignal: true, isRequired: false, transformFunction: null }, tooltipPercentOf: { classPropertyName: "tooltipPercentOf", publicName: "tooltipPercentOf", isSignal: true, isRequired: false, transformFunction: null }, noItemsMessage: { classPropertyName: "noItemsMessage", publicName: "noItemsMessage", isSignal: true, isRequired: false, transformFunction: null }, tooManyItemsMessage: { classPropertyName: "tooManyItemsMessage", publicName: "tooManyItemsMessage", isSignal: true, isRequired: false, transformFunction: null }, tooManyItemsGroupedMessage: { classPropertyName: "tooManyItemsGroupedMessage", publicName: "tooManyItemsGroupedMessage", isSignal: true, isRequired: false, transformFunction: null }, showLegend: { classPropertyName: "showLegend", publicName: "showLegend", isSignal: true, isRequired: false, transformFunction: null }, showExportButton: { classPropertyName: "showExportButton", publicName: "showExportButton", isSignal: true, isRequired: false, transformFunction: null }, exportButtonTitle: { classPropertyName: "exportButtonTitle", publicName: "exportButtonTitle", isSignal: true, isRequired: false, transformFunction: null }, exportFilename: { classPropertyName: "exportFilename", publicName: "exportFilename", isSignal: true, isRequired: false, transformFunction: null }, xAxisLabel: { classPropertyName: "xAxisLabel", publicName: "xAxisLabel", isSignal: true, isRequired: false, transformFunction: null }, yAxisLabel: { classPropertyName: "yAxisLabel", publicName: "yAxisLabel", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { valueClicked: "valueClicked" }, usesInheritance: true, usesOnChanges: true, ngImport: i0, template: "<div class=\"chart-container\">\n <div class=\"chart-area\" #container (cngWindowResize)=\"updateChart()\" [class.pt-3]=\"alertMessageAndType()\">\n <svg [class.d-none]=\"loading() || !data()?.length\" #chart></svg>\n\n @if (tooltipPosition()) {\n <cng-chart-tooltip\n [tooltipPosition]=\"tooltipPosition()\"\n [tooltipOrientation]=\"tooltipOrientation()\"\n [squareColor]=\"!stacks() ? selectedItem()?.color : undefined\"\n (tooltipClosed)=\"resetTooltip()\"\n (cngOutsideClick)=\"resetTooltip()\"\n (tooltipHeaderClicked)=\"valueClicked.emit({ key: tooltipKey(), label: tooltipLabel(), value: tooltipValue() })\"\n >\n <ng-container ngProjectAs=\"cng-title\"> ({{ tooltipValue() }}) {{ tooltipLabel() }}</ng-container>\n\n <p class=\"mt-0\">\n {{ (100 * tooltipValue()) / total() | number : '1.0-2' }}%\n {{ tooltipPercentOfTotal() }}\n </p>\n\n @if (stacks()) { @for (slice of selectedStackSlices(); track slice.key) {\n <div class=\"mt-0-5 d-flex\">\n <div class=\"color-square mr-0-5\" [style.background-color]=\"toChartColor(slice.color)\"></div>\n <strong>{{ slice.fullLabel || slice.label }}: </strong>\n <span\n class=\"has-more-info\"\n (click)=\"\n valueClicked.emit({\n key: [slice.key],\n label: slice.fullLabel || slice.label,\n value: slice.value,\n })\n \"\n >\n {{ slice.value }}\n </span>\n </div>\n <p class=\"mt-0-25 percentage-info\">\n {{ (100 * slice.value) / total() | number : '1.0-2' }}%\n {{ tooltipPercentOfTotal() }}\n {{ slice.percentageOfStack | number : '1.0-2' }}% {{ tooltipPercentOf() }} \"{{ tooltipLabel() }}\"\n </p>\n } }\n </cng-chart-tooltip>\n } @if (loading() || !data()?.length) {\n <cng-bar-chart-skeleton [orientation]=\"orientation()\" [skeletonType]=\"loading() ? 'loading' : 'placeholder'\" />\n } @if (alertMessageAndType()) {\n <cng-chart-alert-overlay [alertType]=\"alertMessageAndType()[1]\" [alertMessage]=\"alertMessageAndType()[0]\" />\n }\n </div>\n\n @if (showLegend() && !loading() && legendItems().length) {\n <cng-chart-legend [items]=\"legendItems()\" />\n } @if (showExportButton() && !loading() && data()?.length) {\n <cng-chart-export-button\n [svgRef]=\"svgElement()\"\n [filename]=\"exportFilename()\"\n [buttonTitle]=\"exportButtonTitle()\"\n [legendItems]=\"legendItems()\"\n />\n }\n</div>\n", styles: [":host{display:block;width:100%;height:100%}:host ::ng-deep .domain{display:none}.chart-container{display:flex;flex-direction:column;width:100%;height:100%;position:relative}.chart-area{flex:1;min-height:0;position:relative}svg{height:100%;width:100%}cng-bar-chart-skeleton{height:100%}.percentage-info{margin-left:15px}\n"], dependencies: [{ kind: "component", type: ChartAlertOverlayComponent, selector: "cng-chart-alert-overlay", inputs: ["alertMessage", "alertType"] }, { kind: "component", type: ChartExportButtonComponent, selector: "cng-chart-export-button", inputs: ["svgRef", "filename", "buttonTitle", "legendItems"] }, { kind: "component", type: ChartLegendComponent, selector: "cng-chart-legend", inputs: ["items"] }, { kind: "component", type: ChartSkeletonComponent, selector: "cng-bar-chart-skeleton", inputs: ["skeletonType", "orientation"] }, { kind: "component", type: ChartTooltipComponent, selector: "cng-chart-tooltip", inputs: ["tooltipPosition", "tooltipOrientation", "squareColor", "tooltipClickable"], outputs: ["tooltipClosed", "tooltipHeaderClicked"] }, { kind: "directive", type: OutsideClickDirective, selector: "[cngOutsideClick]", outputs: ["cngOutsideClick"] }, { kind: "directive", type: WindowResizeDirective, selector: "[cngWindowResize]", inputs: ["debounce", "includeFirst"], outputs: ["cngWindowResize"] }, { kind: "pipe", type: i8.DecimalPipe, name: "number" }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: BarChartComponent, decorators: [{
type: Component,
args: [{ selector: 'clr-bar-chart', changeDetection: ChangeDetectionStrategy.OnPush, standalone: false, template: "<div class=\"chart-container\">\n <div class=\"chart-area\" #container (cngWindowResize)=\"updateChart()\" [class.pt-3]=\"alertMessageAndType()\">\n <svg [class.d-none]=\"loading() || !data()?.length\" #chart></svg>\n\n @if (tooltipPosition()) {\n <cng-chart-tooltip\n [tooltipPosition]=\"tooltipPosition()\"\n [tooltipOrientation]=\"tooltipOrientation()\"\n [squareColor]=\"!stacks() ? selectedItem()?.color : undefined\"\n (tooltipClosed)=\"resetTooltip()\"\n (cngOutsideClick)=\"resetTooltip()\"\n (tooltipHeaderClicked)=\"valueClicked.emit({ key: tooltipKey(), label: tooltipLabel(), value: tooltipValue() })\"\n >\n <ng-container ngProjectAs=\"cng-title\"> ({{ tooltipValue() }}) {{ tooltipLabel() }}</ng-container>\n\n <p class=\"mt-0\">\n {{ (100 * tooltipValue()) / total() | number : '1.0-2' }}%\n {{ tooltipPercentOfTotal() }}\n </p>\n\n @if (stacks()) { @for (slice of selectedStackSlices(); track slice.key) {\n <div class=\"mt-0-5 d-flex\">\n <div class=\"color-square mr-0-5\" [style.background-color]=\"toChartColor(slice.color)\"></div>\n <strong>{{ slice.fullLabel || slice.label }}: </strong>\n <span\n class=\"has-more-info\"\n (click)=\"\n valueClicked.emit({\n key: [slice.key],\n label: slice.fullLabel || slice.label,\n value: slice.value,\n })\n \"\n >\n {{ slice.value }}\n </span>\n </div>\n <p class=\"mt-0-25 percentage-info\">\n {{ (100 * slice.value) / total() | number : '1.0-2' }}%\n {{ tooltipPercentOfTotal() }}\n {{ slice.percentageOfStack | number : '1.0-2' }}% {{ tooltipPercentOf() }} \"{{ tooltipLabel() }}\"\n </p>\n } }\n </cng-chart-tooltip>\n } @if (loading() || !data()?.length) {\n <cng-bar-chart-skeleton [orientation]=\"orientation()\" [skeletonType]=\"loading() ? 'loading' : 'placeholder'\" />\n } @if (alertMessageAndType()) {\n <cng-chart-alert-overlay [alertType]=\"alertMessageAndType()[1]\" [alertMessage]=\"alertMessageAndType()[0]\" />\n }\n </div>\n\n @if (showLegend() && !loading() && legendItems().length) {\n <cng-chart-legend [items]=\"legendItems()\" />\n } @if (showExportButton() && !loading() && data()?.length) {\n <cng-chart-export-button\n [svgRef]=\"svgElement()\"\n [filename]=\"exportFilename()\"\n [buttonTitle]=\"exportButtonTitle()\"\n [legendItems]=\"legendItems()\"\n />\n }\n</div>\n", styles: [":host{display:block;width:100%;height:100%}:host ::ng-deep .domain{display:none}.chart-container{display:flex;flex-direction:column;width:100%;height:100%;position:relative}.chart-area{flex:1;min-height:0;position:relative}svg{height:100%;width:100%}cng-bar-chart-skeleton{height:100%}.percentage-info{margin-left:15px}\n"] }]
}], ctorParameters: () => [], propDecorators: { data: [{ type: i0.Input, args: [{ isSignal: true, alias: "data", required: true }] }], stacks: [{ type: i0.Input, args: [{ isSignal: true, alias: "stacks", required: false }] }], orientation: [{ type: i0.Input, args: [{ isSignal: true, alias: "orientation", required: true }] }], tooltipOrientation: [{ type: i0.Input, args: [{ isSignal: true, alias: "tooltipOrientation", required: false }] }], barSizePx: [{ type: i0.Input, args: [{ isSignal: true, alias: "barSizePx", required: false }] }], barAreaSizePx: [{ type: i0.Input, args: [{ isSignal: true, alias: "barAreaSizePx", required: false }] }], tooltipPercentOfTotal: [{ type: i0.Input, args: [{ isSignal: true, alias: "tooltipPercentOfTotal", required: false }] }], tooltipPercentOf: [{ type: i0.Input, args: [{ isSignal: true, alias: "tooltipPercentOf", required: false }] }], noItemsMessage: [{ type: i0.Input, args: [{ isSignal: true, alias: "noItemsMessage", required: false }] }], tooManyItemsMessage: [{ type: i0.Input, args: [{ isSignal: true, alias: "tooManyItemsMessage", required: false }] }], tooManyItemsGroupedMessage: [{ type: i0.Input, args: [{ isSignal: true, alias: "tooManyItemsGroupedMessage", required: false }] }], showLegend: [{ type: i0.Input, args: [{ isSignal: true, alias: "showLegend", required: false }] }], showExportButton: [{ type: i0.Input, args: [{ isSignal: true, alias: "showExportButton", required: false }] }], exportButtonTitle: [{ type: i0.Input, args: [{ isSignal: true, alias: "exportButtonTitle", required: false }] }], exportFilename: [{ type: i0.Input, args: [{ isSignal: true, alias: "exportFilename", required: false }] }], xAxisLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "xAxisLabel", required: false }] }], yAxisLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "yAxisLabel", required: false }] }], valueClicked: [{ type: i0.Output, args: ["valueClicked"] }] } });
/*
* Copyright (c) 2018-2026 Porsche Informatik. All Rights Reserved.
* This software is released under MIT license.
* The full license information can be found in LICENSE in the root directory of this project.
*/
/**
* Renders interactive dot circles for a single XY series onto the given D3 group.
*
* - Default radius: 4px, hover radius: 6px.
* - Calls `onClick` with the clicked element, data point and series when a dot is clicked.
*
*/
function renderDots(g, series, x, y, onClick) {
g.selectAll(`.dot-${series.key}`)
.data(series.data, (d) => d.x)
.join('circle')
.attr('class', `dot dot-${series.key}`)
.attr('cx', (d) => x(d.x) ?? 0)
.attr('cy', (d) => y(d.value))
.attr('r', 4)
.style('fill', toChartColor(series.color))
.attr('stroke', '#fff')
.attr('stroke-width', 2)
.style('cursor', 'pointer')
.on('mouseover', (e) => {
select(e.currentTarget).attr('r', 6);
})
.on('mouseout', (e) => {
select(e.currentTarget).attr('r', 4);
})
.on('click', (event, d) => {
event.stopPropagation();
onClick(event.currentTarget, d, series);
});
}
/*
* Copyright (c) 2018-2026 Porsche Informatik. All Rights Reserved.
* This software is released under MIT license.
* The full license information can be found in LICENSE in the root directory of this project.
*/
/**
* Computes sensible tick values for a linear Y scale.
*
* When the domain contains enough integer values (≥ 3) the ticks are filtered
* to integers only (avoids fractional labels for count data). For small-range
* domains – e.g. 0–1 ratios / probabilities – the raw D3 ticks are returned
* unchanged so that decimal labels are still visible.
*/
function computeYTickValues(y, count = 5) {
const raw = y.ticks(count);
const integers = raw.filter(Number.isInteger);
// Keep integer-only ticks only when there are at least 3 of them; otherwise
// the domain is too small (e.g. 0–1) and we need the decimal ticks.
return integers.length >= 3 ? integers : raw;
}
/**
* Returns a D3 tick-format function that matches the decision made in
* {@link computeYTickValues}:
*
* - **Integer / large-value domain** (≥ 3 integer ticks): SI prefix format
* (`~s`), e.g. `1k`, `200`.
* - **Small / decimal domain** (< 3 integer ticks, e.g. 0–1 ratios): plain
* fixed-point format (`.3~f`) – avoids the SI "milli" prefix (`m`) that
* D3 would otherwise apply to values < 1.
*/
function computeYTickFormat(y, count = 5) {
const raw = y.ticks(count);
const integers = raw.filter(Number.isInteger);
return integers.length >= 3 ? format('~s') : format('.3~f');
}
/**
* Draws the X and Y axes (with integer-only Y ticks and grid lines) plus
* optional description labels onto the given D3 group.
*
* @param g The D3 group element to append into.
* @param x Any D3 axis scale keyed on strings (`ScalePoint` or `ScaleBand`).
* @param y The linear Y scale.
* @param opts Axis configuration options.
*/
function drawXYAxes(g, x, y, opts) {
const { xLabelMap, width, height, xAxisLabel, yAxisLabel, effectiveLeft } = opts;
// X axis
g.append('g')
.attr('transform', `translate(0,${height})`)
.call(axisBottom(x).tickFormat((d) => xLabelMap.get(d) ?? d))
.selectAll('text')
.style('font-size', '11px')
.style('fill', 'var(--cds-global-color-construction-400, #666)');
// Y axis
const tickValues = computeYTickValues(y);
const tickFormat = computeYTickFormat(y);
g.append('g')
.call(axisLeft(y).tickValues(tickValues).tickSize(-width).tickFormat(tickFormat))
.selectAll('text')
.style('font-size', '11px')
.style('fill', 'var(--cds-global-color-construction-400, #666)');
// Optional X axis description label
if (xAxisLabel) {
g.append('text')
.attr('x', width / 2)
.attr('y', height + 40)
.attr('text-anchor', 'middle')
.style('font-size', '12px')
.style('fill', 'var(--cds-global-color-construction-400, #666)')
.text(xAxisLabel);
}
// Optional Y axis description label (rotated, centred in the left-margin band)
if (yAxisLabel) {
g.append('text')
.attr('transform', 'rotate(-90)')
.attr('x', -height / 2)
.attr('y', -(effectiveLeft / 2))
.attr('text-anchor', 'middle')
.style('font-size', '12px')
.style('fill', 'var(--cds-global-color-construction-400, #666)')
.text(yAxisLabel);
}
}
/**
* Styles all D3 grid tick lines inside `g` with the neutral chart grid color.
*/
function styleGridLines(g) {
g.selectAll('.tick line').style('stroke', 'var(--cds-global-color-gray-200, #e8e8e8)');
}
class LineChartComponent extends ChartBase {
constructor() {
super(...arguments);
this.series = input.required(...(ngDevMode ? [{ debugName: "series" }] : /* istanbul ignore next */ []));
this.tooltipOrientation = input('top', ...(ngDevMode ? [{ debugName: "tooltipOrientation" }] : /* istanbul ignore next */ []));
this.showArea = input(false, ...(ngDevMode ? [{ debugName: "showArea" }] : /* istanbul ignore next */ []));
this.showLegend = input(true, ...(ngDevMode ? [{ debugName: "showLegend" }] : /* istanbul ignore next */ []));
this.showValues = input(false, ...(ngDevMode ? [{ debugName: "showValues" }] : /* istanbul ignore next */ []));
this.showExportButton = input(false, ...(ngDevMode ? [{ debugName: "showExportButton" }] : /* istanbul ignore next */ []));
this.exportButtonTitle = input('Export', ...(ngDevMode ? [{ debugName: "exportButtonTitle" }] : /* istanbul ignore next */ []));
this.exportFilename = input('line-chart', ...(ngDevMode ? [{ debugName: "exportFilename" }] : /* istanbul ignore next */ []));
this.noItemsMessage = input(NO_ITEMS_MESSAGE, ...(ngDevMode ? [{ debugName: "noItemsMessage" }] : /* istanbul ignore next */ []));
this.tooltipPercentOfTotal = input('of total', ...(ngDevMode ? [{ debugName: "tooltipPercentOfTotal" }] : /* istanbul ignore next */ []));
/** Optional label rendered below the X axis. */
this.xAxisLabel = input('', ...(ngDevMode ? [{ debugName: "xAxisLabel" }] : /* istanbul ignore next */ []));
/** Optional label rendered rotated to the left of the Y axis. */
this.yAxisLabel = input('', ...(ngDevMode ? [{ debugName: "yAxisLabel" }] : /* istanbul ignore next */ []));
this.yMin = input(0, ...(ngDevMode ? [{ debugName: "yMin" }] : /* istanbul ignore next */ []));
this.autoscaleYAxis = input(false, ...(ngDevMode ? [{ debugName: "autoscaleYAxis" }] : /* istanbul ignore next */ []));
this.valueClicked = output();
this.MARGIN = { top: 20, right: 30, bottom: 30, left: 65 };
this.hasData = computed(() => this.series().some(s => s.data.length > 0), ...(ngDevMode ? [{ debugName: "hasData" }] : /* istanbul ignore next */ []));
this.total = computed(() => this.series().reduce((acc, s) => acc + s.data.reduce((a, d) => a + d.value, 0), 0), ...(ngDevMode ? [{ debugName: "total" }] : /* istanbul ignore next */ []));
this.alertMessageAndType = computed(() => {
if (this.loading()) {
return undefined;
}
if (!this.hasData()) {
return [this.noItemsMessage(), NO_ITEMS_ALERT_TYPE];
}
return undefined;
}, ...(ngDevMode ? [{ debugName: "alertMessageAndType" }] : /* istanbul ignore next */ []));
this.legendItems = computed(() => {
if (!this.showLegend()) {
return [];
}
return this.series().map(s => ({ label: s.label, color: s.color }));
}, ...(ngDevMode ? [{ debugName: "legendItems" }] : /* istanbul ignore next */ []));
}
ngOnChanges(_changes) {
if (!this.svg) {
return;
}
requestAnimationFrame(() => this.updateChart());
}
ngAfterViewInit() {
this.svg = select(this.chartRef().nativeElement);
super.ngAfterViewInit();
}
updateChart() {
this.svg.selectAll('*').remove();
if (this.loading()) {
return;
}
if (!this.hasData()) {
return;
}
const { width: containerWidth, height: containerHeight } = this.getContainerDimensions();
const extraBottom = this.xAxisLabel() ? 16 : 0;
const extraLeft = this.yAxisLabel() ? 16 : 0;
const extraTop = this.showValues() ? 14 : 0;
const effectiveLeft = this.MARGIN.left + extraLeft;
const width = containerWidth - effectiveLeft - this.MARGIN.right;
const height = containerHeight - (this.MARGIN.top + extraTop) - (this.MARGIN.bottom + extraBottom);
// Collect all unique X keys in the order they appear across all series
const xKeys = [...new Set(this.series().flatMap(s => s.data.map(d => d.x)))];
// Build a display-label map
const xLabelMap = new Map();
this.series().forEach(s => s.data.forEach(d => xLabelMap.set(d.x, d.xLabel ?? d.x)));
const x = scalePoint().domain(xKeys).range([0, width]).padding(0.5);
const maxY = max(this.series().flatMap(s => s.data.map(d => d.value))) ?? 0;
const minY = this.autoscaleYAxis()
? (min(this.series().flatMap(s => s.data.map(d => d.value))) ?? 0)
: this.yMin();
const y = scaleLinear().domain([minY, maxY]).nice().range([height, 0]);
const g = this.svg
.attr('width', width)
.attr('height', height)
.append('g')
.attr('transform', `translate(${effectiveLeft},${this.MARGIN.top + extraTop})`);
drawXYAxes(g, x, y, {
xLabelMap,
width,
height,
xAxisLabel: this.xAxisLabel(),
yAxisLabel: this.yAxisLabel(),
effectiveLeft,
});
styleGridLines(g);
this.drawSeries(g, x, y);
}
drawSeries(g, x, y) {
const lineGenerator = line()
.x((d) => x(d.x) ?? 0)
.y((d) => y(d.value))
.curve(curveMonotoneX);
for (const series of this.series()) {
if (!series.data.length) {
continue;
}
// Optional area fill
if (this.showArea()) {
g.append('path')
.datum(series.data)
.attr('class', `area area-${series.key}`)
.style('fill', toChartColor(series.color))
.attr('fill-opacity', 0.1)
.attr('d', lineGenerator);
}
// Line path
g.append('path')
.datum(series.data)
.attr('class', `line line-${series.key}`)
.attr('fill', 'none')
.style('stroke', toChartColor(series.color))
.attr('stroke-width', 2)
.attr('stroke-linejoin', 'round')
.attr('stroke-linecap', 'round')
.attr('d', lineGenerator);
// Dots
renderDots(g, series, x, y, (el, point, s) => this.openTooltip(el, point, s));
// Value labels above each dot
if (this.showValues()) {
g.selectAll(`.value-label-${series.key}`)
.data(series.data, (d) => d.x)
.join('text')
.attr('class', `value-label value-label-${series.key}`)
.attr('x', (d) => x(d.x) ?? 0)
.attr('y', (d) => y(d.value) - 9)
.attr('text-anchor', 'middle')
.style('font-size', '11px')
.style('font-weight', '600')
.style('fill', toChartColor(series.color))
.attr('stroke', '#fff')
.attr('stroke-width', 3)
.attr('paint-order', 'stroke fill')
.style('pointer-events', 'none')
.text((d) => format('~s')(d.value));
}
}
}
openTooltip(el, point, series) {
const rect = el.getBoundingClientRect();
const container = this.chartRef().nativeElement.getBoundingClientRect();
this.tooltipPosition.set({
x: rect.left - container.left + rect.width / 2,
y: (this.tooltipOrientation() === 'top' ? rect.top : rect.bottom) - container.top,
});
this.selectedItem.set({ ...point, seriesKey: series.key, seriesLabel: series.label, color: series.color });
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: LineChartComponent, deps: null, target: i0.ɵɵFactoryTarget.Component }); }
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.18", type: LineChartComponent, isStandalone: false, selector: "clr-line-chart", inputs: { series: { classPropertyName: "series", publicName: "series", isSignal: true, isRequired: true, transformFunction: null }, tooltipOrientation: { classPropertyName: "tooltipOrientation", publicName: "tooltipOrientation", isSignal: true, isRequired: false, transformFunction: null }, showArea: { classPropertyName: "showArea", publicName: "showArea", isSignal: true, isRequired: false, transformFunction: null }, showLegend: { classPropertyName: "showLegend", publicName: "showLegend", isSignal: true, isRequired: false, transformFunction: null }, showValues: { classPropertyName: "showValues", publicName: "showValues", isSignal: true, isRequired: false, transformFunction: null }, showExportButton: { classPropertyName: "showExportButton", publicName: "showExportButton", isSignal: true, isRequired: false, transformFunction: null }, exportButtonTitle: { classPropertyName: "exportButtonTitle", publicName: "exportButtonTitle", isSignal: true, isRequired: false, transformFunction: null }, exportFilename: { classPropertyName: "exportFilename", publicName: "exportFilename", isSignal: true, isRequired: false, transformFunction: null }, noItemsMessage: { classPropertyName: "noItemsMessage", publicName: "noItemsMessage", isSignal: true, isRequired: false, transformFunction: null }, tooltipPercentOfTotal: { classPropertyName: "tooltipPercentOfTotal", publicName: "tooltipPercentOfTotal", isSignal: true, isRequired: false, transformFunction: null }, xAxisLabel: { classPropertyName: "xAxisLabel", publicName: "xAxisLabel", isSignal: true, isRequired: false, transformFunction: null }, yAxisLabel: { classPropertyName: "yAxisLabel", publicName: "yAxisLabel", isSignal: true, isRequired: false, transformFunction: null }, yMin: { classPropertyName: "yMin", publicName: "yMin", isSignal: true, isRequired: false, transformFunction: null }, autoscaleYAxis: { classPropertyName: "autoscaleYAxis", publicName: "autoscaleYAxis", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { valueClicked: "valueClicked" }, usesInheritance: true, usesOnChanges: true, ngImport: i0, template: "<div class=\"chart-container\">\n <div class=\"chart-area\" #container (cngWindowResize)=\"updateChart()\" [class.pt-3]=\"alertMessageAndType()\">\n <svg [class.d-none]=\"loading() || !hasData()\" #chart></svg>\n\n @if (tooltipPosition()) {\n <cng-chart-tooltip\n [tooltipPosition]=\"tooltipPosition()\"\n [tooltipOrientation]=\"tooltipOrientation()\"\n [squareColor]=\"selectedItem()?.color\"\n (tooltipClosed)=\"resetTooltip()\"\n (cngOutsideClick)=\"resetTooltip()\"\n (tooltipHeaderClicked)=\"\n valueClicked.emit({\n seriesKey: selectedItem().seriesKey,\n seriesLabel: selectedItem().seriesLabel,\n x: selectedItem().x,\n xLabel: selectedItem().xLabel,\n value: selectedItem().value,\n })\n \"\n >\n <ng-container ngProjectAs=\"cng-title\">\n ({{ selectedItem().value }}) {{ selectedItem().xLabel || selectedItem().x }}\n </ng-container>\n\n <p class=\"mt-0\">\n <strong>{{ selectedItem().seriesLabel }}:</strong>\n {{ selectedItem().value }}\n </p>\n <p class=\"mt-0\">\n {{ (100 * selectedItem().value) / total() | number : '1.0-2' }}%\n {{ tooltipPercentOfTotal() }}\n </p>\n </cng-chart-tooltip>\n } @if (loading() || !hasData()) {\n <cng-bar-chart-skeleton orientation=\"vertical\" [skeletonType]=\"loading() ? 'loading' : 'placeholder'\" />\n } @if (alertMessageAndType()) {\n <cng-chart-alert-overlay [alertType]=\"alertMessageAndType()[1]\" [alertMessage]=\"alertMessageAndType()[0]\" />\n }\n </div>\n\n @if (showLegend() && !loading() && legendItems().length) {\n <cng-chart-legend [items]=\"legendItems()\" />\n } @if (showExportButton() && !loading() && hasData()) {\n <cng-chart-export-button\n [svgRef]=\"svgElement()\"\n [filename]=\"exportFilename()\"\n [buttonTitle]=\"exportButtonTitle()\"\n [legendItems]=\"legendItems()\"\n />\n }\n</div>\n", styles: [":host{display:block;width:100%;height:100%}:host ::ng-deep .domain{display:none}.chart-container{display:flex;flex-direction:column;width:100%;height:100%;position:relative}.chart-area{flex:1;min-height:0;position:relative}svg{height:100%;width:100%}cng-bar-chart-skeleton{height:100%}.line,.area{pointer-events:none}.dot{transition:r .1s ease}\n"], dependencies: [{ kind: "component", type: ChartAlertOverlayComponent, selector: "cng-chart-alert-overlay", inputs: ["alertMessage", "alertType"] }, { kind: "component", type: ChartExportButtonComponent, selector: "cng-chart-export-button", inputs: ["svgRef", "filename", "buttonTitle", "legendItems"] }, { kind: "component", type: ChartLegendComponent, selector: "cng-chart-legend", inputs: ["items"] }, { kind: "component", type: ChartSkeletonComponent, selector: "cng-bar-chart-skeleton", inputs: ["skeletonType", "orientation"] }, { kind: "component", type: ChartTooltipComponent, selector: "cng-chart-tooltip", inputs: ["tooltipPosition", "tooltipOrientation", "squareColor", "tooltipClickable"], outputs: ["tooltipClosed", "tooltipHeaderClicked"] }, { kind: "directive", type: OutsideClickDirective, selector: "[cngOutsideClick]", outputs: ["cngOutsideClick"] }, { kind: "directive", type: WindowResizeDirective, selector: "[cngWindowResize]", inputs: ["debounce", "includeFirst"], outputs: ["cngWindowResize"] }, { kind: "pipe", type: i8.DecimalPipe, name: "number" }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: LineChartComponent, decorators: [{
type: Component,
args: [{ selector: 'clr-line-chart', standalone: false, changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"chart-container\">\n <div class=\"chart-area\" #container (cngWindowResize)=\"updateChart()\" [class.pt-3]=\"alertMessageAndType()\">\n <svg [class.d-none]=\"loading() || !hasData()\" #chart></svg>\n\n @if (tooltipPosition()) {\n <cng-chart-tooltip\n [tooltipPosition]=\"tooltipPosition()\"\n [tooltipOrientation]=\"tooltipOrientation()\"\n [squareColor]=\"selectedItem()?.color\"\n (tooltipClosed)=\"resetTooltip()\"\n (cngOutsideClick)=\"resetTooltip()\"\n (tooltipHeaderClicked)=\"\n valueClicked.emit({\n seriesKey: selectedItem().seriesKey,\n seriesLabel: selectedItem().seriesLabel,\n x: selectedItem().x,\n xLabel: selectedItem().xLabel,\n value: selectedItem().value,\n })\n \"\n >\n <ng-container ngProjectAs=\"cng-title\">\n ({{ selectedItem().value }}) {{ selectedItem().xLabel || selectedItem().x }}\n </ng-container>\n\n <p class=\"mt-0\">\n <strong>{{ selectedItem().seriesLabel }}:</strong>\n {{ selectedItem().value }}\n </p>\n <p class=\"mt-0\">\n {{ (100 * selectedItem().value) / total() | number : '1.0-2' }}%\n {{ tooltipPercentOfTotal() }}\n </p>\n </cng-chart-tooltip>\n } @if (loading() || !hasData()) {\n <cng-bar-chart-skeleton orientation=\"vertical\" [skeletonType]=\"loading() ? 'loading' : 'placeholder'\" />\n } @if (alertMessageAndType()) {\n <cng-chart-alert-overlay [alertType]=\"alertMessageAndType()[1]\" [alertMessage]=\"alertMessageAndType()[0]\" />\n }\n </div>\n\n @if (showLegend() && !loading() && legendItems().length) {\n <cng-chart-legend [items]=\"legendItems()\" />\n } @if (showExportButton() && !loading() && hasData()) {\n <cng-chart-export-button\n [svgRef]=\"svgElement()\"\n [filename]=\"exportFilename()\"\n [buttonTitle]=\"exportButtonTitle()\"\n [legendItems]=\"legendItems()\"\n />\n }\n</div>\n", styles: [":host{display:block;width:100%;height:100%}:host ::ng-deep .domain{display:none}.chart-container{display:flex;flex-direction:column;width:100%;height:100%;position:relative}.chart-area{flex:1;min-height:0;position:relative}svg{height:100%;width:100%}cng-bar-chart-skeleton{height:100%}.line,.area{pointer-events:none}.dot{transition:r .1s ease}\n"] }]
}], propDecorators: { series: [{ type: i0.Input, args: [{ isSignal: true, alias: "series", required: true }] }], tooltipOrientation: [{ type: i0.Input, args: [{ isSignal: true, alias: "tooltipOrientation", required: false }] }], showArea: [{ type: i0.Input, args: [{ isSignal: true, alias: "showArea", required: false }] }], showLegend: [{ type: i0.Input, args: [{ isSignal: true, alias: "showLegend", required: false }] }], showValues: [{ type: i0.Input, args: [{ isSignal: true, alias: "showValues", required: false }] }], showExportButton: [{ type: i0.Input, args: [{ isSignal: true, alias: "showExportButton", required: false }] }], exportButtonTitle: [{ type: i0.Input, args: [{ isSignal: true, alias: "exportButtonTitle", required: false }] }], exportFilename: [{ type: i0.Input, args: [{ isSignal: true, alias: "exportFilename", required: false }] }], noItemsMessage: [{ type: i0.Input, args: [{ isSignal: true, alias: "noItemsMessage", required: false }] }], tooltipPercentOfTotal: [{ type: i0.Input, args: [{ isSignal: true, alias: "tooltipPercentOfTotal", required: false }] }], xAxisLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "xAxisLabel", required: false }] }], yAxisLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "yAxisLabel", required: false }] }], yMin: [{ type: i0.Input, args: [{ isSignal: true, alias: "yMin", required: false }] }], autoscaleYAxis: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoscaleYAxis", required: false }] }], valueClicked: [{ type: i0.Output, args: ["valueClicked"] }] } });
class AreaChartComponent extends ChartBase {
constructor() {
super(...arguments);
this.series = input.required(...(ngDevMode ? [{ debugName: "series" }] : /* istanbul ignore next */ []));
this.tooltipOrientation = input('top', ...(ngDevMode ? [{ debugName: "tooltipOrientation" }] : /* istanbul ignore next */ []));
this.showLegend = input(true, ...(ngDevMode ? [{ debugName: "showLegend" }] : /* istanbul ignore next */ []));
this.showExportButton = input(false, ...(ngDevMode ? [{ debugName: "showExportButton" }] : /* istanbul ignore next */ []));
this.exportButtonTitle = input('Export', ...(ngDevMode ? [{ debugName: "exportButtonTitle" }] : /* istanbul ignore next */ []));
this.exportFilename = input('area-chart', ...(ngDevMode ? [{ debugName: "exportFilename" }] : /* istanbul ignore next */ []));
/** Area fill opacity (0–1). Default: 0.2. */
this.areaOpacity = input(0.2, ...(ngDevMode ? [{ debugName: "areaOpacity" }] : /* istanbul ignore next */ []));
this.noItemsMessage = input(NO_ITEMS_MESSAGE, ...(ngDevMode ? [{ debugName: "noItemsMessage" }] : /* istanbul ignore next */ []));
this.tooltipPercentOfTotal = input('of total', ...(ngDevMode ? [{ debugName: "tooltipPercentOfTotal" }] : /* istanbul ignore next */ []));
/** Optional label rendered below the X axis. */
this.xAxisLabel = input('', ...(ngDevMode ? [{ debugName: "xAxisLabel" }] : /* istanbul ignore next */ []));
/** Optional label rendered rotated to the left of the Y axis. */
this.yAxisLabel = input('', ...(ngDevMode ? [{ debugName: "yAxisLabel" }] : /* istanbul ignore next */ []));
this.valueClicked = output();
this.MARGIN = { top: 20, right: 30, bottom: 30, left: 65 };
this.hasData = computed(() => this.series().some(s => s.data.length > 0), ...(ngDevMode ? [{ debugName: "hasData" }] : /* istanbul ignore next */ []));
this.total = computed(() => this.series().reduce((acc, s) => acc + s.data.reduce((a, d) => a + d.value, 0), 0), ...(ngDevMode ? [{ debugName: "total" }] : /* istanbul ignore next */ []));
this.alertMessageAndType = computed(() => {
if (this.loading()) {
return undefined;
}
if (!this.hasData()) {
return [this.noItemsMessage(), NO_ITEMS_ALERT_TYPE];
}
return undefined;
}, ...(ngDevMode ? [{ debugName: "alertMessageAndType" }] : /* istanbul ignore next */ []));
this.legendItems = computed(() => {
if (!this.showLegend()) {
return [];
}
return this.series().map(s => ({ label: s.label, color: s.color }));
}, ...(ngDevMode ? [{ debugName: "legendItems" }] : /* istanbul ignore next */ []));
}
ngOnChanges(_changes) {
if (!this.svg) {
return;
}
requestAnimationFrame(() => this.updateChart());
}
ngAfterViewInit() {
this.svg = select(this.chartRef().nativeElement);
super.ngAfterViewInit();
}
updateChart() {
this.svg.selectAll('*').remove();
if (this.loading() || !this.hasData()) {
return;
}
const { width: containerWidth, height: containerHeight } = this.getContainerDimensions();
const extraBottom = this.xAxisLabel() ? 16 : 0;
const extraLeft = this.yAxisLabel() ? 16 : 0;
const effectiveLeft = this.MARGIN.left + extraLeft;
const width = containerWidth - effectiveLeft - this.MARGIN.right;
const height = containerHeight - this.MARGIN.top - (this.MARGIN.bottom + extraBottom);
const xKeys = [...new Set(this.series().flatMap(s => s.data.map(d => d.x)))];
const xLabelMap = new Map();
this.series().forEach(s => s.data.forEach(d => xLabelMap.set(d.x, d.xLabel ?? d.x)));
const x = scalePoint().domain(xKeys).range([0, width]).padding(0.5);
const maxY = max(this.series().flatMap(s => s.data.map(d => d.value))) ?? 0;
const y = scaleLinear().domain([0, maxY]).nice().range([height, 0]);
const g = this.svg
.attr('width', width)
.attr('height', height)
.append('g')
.attr('transform', `translate(${effectiveLeft},${this.MARGIN.top})`);
drawXYAxes(g, x, y, {
xLabelMap,
width,
height,
xAxisLabel: this.xAxisLabel(),
yAxisLabel: this.yAxisLabel(),
effectiveLeft,
});
styleGridLines(g);
this.drawSeries(g, x, y, height);
}
drawSeries(g, x, y, height) {
const areaGenerator = area()
.x((d) => x(d.x) ?? 0)
.y0(height)
.y1((d) => y(d.value))
.curve(curveMonotoneX);
const lineGenerator = line()
.x((d) => x(d.x) ?? 0)
.y((d) => y(d.value))
.curve(curveMonotoneX);
// Draw areas first (behind lines)
for (const series of this.series()) {
if (!series.data.length) {
continue;
}
g.append('path')
.datum(series.data)
.attr('class', `area area-${series.key}`)
.style('fill', toChartColor(series.color))
.attr('fill-opacity', this.areaOpacity())
.attr('stroke', 'none')
.attr('d', areaGenerator);
}
// Draw lines and dots on top
for (const series of this.series()) {
if (!series.data.length) {
continue;
}
g.append('path')
.datum(series.data)
.attr('class', `line line-${series.key}`)
.attr('fill', 'none')
.style('stroke', toChartColor(series.color))
.attr('stroke-width', 2)
.attr('stroke-linejoin', 'round')
.attr('stroke-linecap', 'round')
.attr('d', lineGenerator);
renderDots(g, series, x, y, (el, point, s) => this.openTooltip(el, point, s));
}
}
openTooltip(el, point, series) {
const rect = el.getBoundingClientRect();
const container = this.chartRef().nativeElement.getBoundingClientRect();
this.tooltipPosition.set({
x: rect.left - container.left + rect.width / 2,
y: (this.tooltipOrientation() === 'top' ? rect.top : rect.bottom) - container.top,
});
this.selectedItem.set({ ...point, seriesKey: series.key, seriesLabel: series.label, color: series.color });
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: AreaChartComponent, deps: null, target: i0.ɵɵFactoryTarget.Component }); }
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.18", type: AreaChartComponent, isStandalone: false, selector: "clr-area-chart", inputs: { series: { classPropertyName: "series", publicName: "series", isSignal: true, isRequired: true, transformFunction: null }, tooltipOrientation: { classPropertyName: "tooltipOrientation", publicName: "tooltipOrientation", isSignal: true, isRequired: false, transformFunction: null }, showLegend: { classPropertyName: "showLegend", publicName: "showLegend", isSignal: true, isRequired: false, transformFunction: null }, showExportButton: { classPropertyName: "showExportButton", publicName: "showExportButton", isSignal: true, isRequired: false, transformFunction: null }, exportButtonTitle: { classPropertyName: "exportButtonTitle", publicName: "exportButtonTitle", isSignal: true, isRequired: false, transformFunction: null }, exportFilename: { classPropertyName: "exportFilename", publicName: "exportFilename", isSignal: true, isRequired: false, transformFunction: null }, areaOpacity: { classPropertyName: "areaOpacity", publicName: "areaOpacity", isSignal: true, isRequired: false, transformFunction: null }, noItemsMessage: { classPropertyName: "noItemsMessage", publicName: "noItemsMessage", isSignal: true, isRequired: false, transformFunction: null }, tooltipPercentOfTotal: { classPropertyName: "tooltipPercentOfTotal", publicName: "tooltipPercentOfTotal", isSignal: true, isRequired: false, transformFunction: null }, xAxisLabel: { classPropertyName: "xAxisLabel", publicName: "xAxisLabel", isSignal: true, isRequired: false, transformFunction: null }, yAxisLabel: { classPropertyName: "yAxisLabel", publicName: "yAxisLabel", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { valueClicked: "valueClicked" }, usesInheritance: true, usesOnChanges: true, ngImport: i0, template: "<div class=\"chart-container\">\n <div class=\"chart-area\" #container (cngWindowResize)=\"updateChart()\" [class.pt-3]=\"alertMessageAndType()\">\n <svg [class.d-none]=\"loading() || !hasData()\" #chart></svg>\n\n @if (tooltipPosition()) {\n <cng-chart-tooltip\n [tooltipPosition]=\"tooltipPosition()\"\n [tooltipOrientation]=\"tooltipOrientation()\"\n [squareColor]=\"selectedItem()?.color\"\n (tooltipClosed)=\"resetTooltip()\"\n (cngOutsideClick)=\"resetTooltip()\"\n (tooltipHeaderClicked)=\"\n valueClicked.emit({\n seriesKey: selectedItem().seriesKey,\n seriesLabel: selectedItem().seriesLabel,\n x: selectedItem().x,\n xLabel: selectedItem().xLabel,\n value: selectedItem().value,\n })\n \"\n >\n <ng-container ngProjectAs=\"cng-title\">\n ({{ selectedItem().value }}) {{ selectedItem().xLabel || selectedItem().x }}\n </ng-container>\n\n <p class=\"mt-0\">\n <strong>{{ selectedItem().seriesLabel }}:</strong>\n {{ selectedItem().value }}\n </p>\n <p class=\"mt-0\">\n {{ (100 * selectedItem().value) / total() | number : '1.0-2' }}%\n {{ tooltipPercentOfTotal() }}\n </p>\n </cng-chart-tooltip>\n } @if (loading() || !hasData()) {\n <cng-bar-chart-skeleton orientation=\"vertical\" [skeletonType]=\"loading() ? 'loading' : 'placeholder'\" />\n } @if (alertMessageAndType()) {\n <cng-chart-alert-overlay [alertType]=\"alertMessageAndType()[1]\" [alertMessage]=\"alertMessageAndType()[0]\" />\n }\n </div>\n\n @if (showLegend() && !loading() && legendItems().length) {\n <cng-chart-legend [items]=\"legendItems()\" />\n } @if (showExportButton() && !loading() && hasData()) {\n <cng-chart-export-button\n [svgRef]=\"svgElement()\"\n [filename]=\"exportFilename()\"\n [buttonTitle]=\"exportButtonTitle()\"\n [legendItems]=\"legendItems()\"\n />\n }\n</div>\n", styles: [":host{display:block;width:100%;height:100%}:host ::ng-deep .domain{display:none}.chart-container{display:flex;flex-direction:column;width:100%;height:100%;position:relative}.chart-area{flex:1;min-height:0;position:relative}svg{height:100%;width:100%}cng-bar-chart-skeleton{height:100%}.line,.area{pointer-events:none}.dot{transition:r .1s ease}\n"], dependencies: [{ kind: "component", type: ChartAlertOverlayComponent, selector: "cng-chart-alert-overlay", inputs: ["alertMessage", "alertType"] }, { kind: "component", type: ChartExportButtonComponent, selector: "cng-chart-export-button", inputs: ["svgRef", "filename", "buttonTitle", "legendItems"] }, { kind: "component", type: ChartLegendComponent, selector: "cng-chart-legend", inputs: ["items"] }, { kind: "component", type: ChartSkeletonComponent, selector: "cng-bar-chart-skeleton", inputs: ["skeletonType", "orientation"] }, { kind: "component", type: ChartTooltipComponent, selector: "cng-chart-tooltip", inputs: ["tooltipPosition", "tooltipOrientation", "squareColor", "tooltipClickable"], outputs: ["tooltipClosed", "tooltipHeaderClicked"] }, { kind: "directive", type: OutsideClickDirective, selector: "[cngOutsideClick]", outputs: ["cngOutsideClick"] }, { kind: "directive", type: WindowResizeDirective, selector: "[cngWindowResize]", inputs: ["debounce", "includeFirst"], outputs: ["cngWindowResize"] }, { kind: "pipe", type: i8.DecimalPipe, name: "number" }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: AreaChartComponent, decorators: [{
type: Component,
args: [{ selector: 'clr-area-chart', standalone: false, changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"chart-container\">\n <div class=\"chart-area\" #container (cngWindowResize)=\"updateChart()\" [class.pt-3]=\"alertMessageAndType()\">\n <svg [class.d-none]=\"loading() || !hasData()\" #chart></svg>\n\n @if (tooltipPosition()) {\n <cng-chart-tooltip\n [tooltipPosition]=\"tooltipPosition()\"\n [tooltipOrientation]=\"tooltipOrientation()\"\n [squareColor]=\"selectedItem()?.color\"\n (tooltipClosed)=\"resetTooltip()\"\n (cngOutsideClick)=\"resetTooltip()\"\n (tooltipHeaderClicked)=\"\n valueClicked.emit({\n seriesKey: selectedItem().seriesKey,\n seriesLabel: selectedItem().seriesLabel,\n x: selectedItem().x,\n xLabel: selectedItem().xLabel,\n value: selectedItem().value,\n })\n \"\n >\n <ng-container ngProjectAs=\"cng-title\">\n ({{ selectedItem().value }}) {{ selectedItem().xLabel || selectedItem().x }}\n </ng-container>\n\n <p class=\"mt-0\">\n <strong>{{ selectedItem().seriesLabel }}:</strong>\n {{ selectedItem().value }}\n </p>\n <p class=\"mt-0\">\n {{ (100 * selectedItem().value) / total() | number : '1.0-2' }}%\n {{ tooltipPercentOfTotal() }}\n </p>\n </cng-chart-tooltip>\n } @if (loading() || !hasData()) {\n <cng-bar-chart-skeleton orientation=\"vertical\" [skeletonType]=\"loading() ? 'loading' : 'placeholder'\" />\n } @if (alertMessageAndType()) {\n <cng-chart-alert-overlay [alertType]=\"alertMessageAndType()[1]\" [alertMessage]=\"alertMessageAndType()[0]\" />\n }\n </div>\n\n @if (showLegend() && !loading() && legendItems().length) {\n <cng-chart-legend [items]=\"legendItems()\" />\n } @if (showExportButton() && !loading() && hasData()) {\n <cng-chart-export-button\n [svgRef]=\"svgElement()\"\n [filename]=\"exportFilename()\"\n [buttonTitle]=\"exportButtonTitle()\"\n [legendItems]=\"legendItems()\"\n />\n }\n</div>\n", styles: [":host{display:block;width:100%;height:100%}:host ::ng-deep .domain{display:none}.chart-container{display:flex;flex-direction:column;width:100%;height:100%;position:relative}.chart-area{flex:1;min-height:0;position:relative}svg{height:100%;width:100%}cng-bar-chart-skeleton{height:100%}.line,.area{pointer-events:none}.dot{transition:r .1s ease}\n"] }]
}], propDecorators: { series: [{ type: i0.Input, args: [{ isSignal: true, alias: "series", required: true }] }], tooltipOrientation: [{ type: i0.Input, args: [{ isSignal: true, alias: "tooltipOrientation", required: false }] }], showLegend: [{ type: i0.Input, args: [{ isSignal: true, alias: "showLegend", required: false }] }], showExportButton: [{ type: i0.Input, args: [{ isSignal: true, alias: "showExportButton", required: false }] }], exportButtonTitle: [{ type: i0.Input, args: [{ isSignal: true, alias: "exportButtonTitle", required: false }] }], exportFilename: [{ type: i0.Input, args: [{ isSignal: true, alias: "exportFilename", required: false }] }], areaOpacity: [{ type: i0.Input, args: [{ isSignal: true, alias: "areaOpacity", required: false }] }], noItemsMessage: [{ type: i0.Input, args: [{ isSignal: true, alias: "noItemsMessage", required: false }] }], tooltipPercentOfTotal: [{ type: i0.Input, args: [{ isSignal: true, alias: "tooltipPercentOfTotal", required: false }] }], xAxisLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "xAxisLabel", required: false }] }], yAxisLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "yAxisLabel", required: false }] }], valueClicked: [{ type: i0.Output, args: ["valueClicked"] }] } });
class ComboChartComponent extends ChartBase {
constructor() {
super(...arguments);
// ── Inputs ──────────────────────────────────────────────────────────────────
this.barSeries = input([], ...(ngDevMode ? [{ debugName: "barSeries" }] : /* istanbul ignore next */ []));
this.lineSeries = input([], ...(ngDevMode ? [{ debugName: "lineSeries" }] : /* istanbul ignore next */ []));
this.tooltipOrientation = input('top', ...(ngDevMode ? [{ debugName: "tooltipOrientation" }] : /* istanbul ignore next */ []));
this.showLegend = input(true, ...(ngDevMode ? [{ debugName: "showLegend" }] : /* istanbul ignore next */ []));
this.showExportButton = input(false, ...(ngDevMode ? [{ debugName: "showExportButton" }] : /* istanbul ignore next */ []));
this.exportButtonTitle = input('Export', ...(ngDevMode ? [{ debugName: "exportButtonTitle" }] : /* istanbul ignore next */ []));
this.exportFilename = input('combo-chart', ...(ngDevMode ? [{ debugName: "exportFilename" }] : /* istanbul ignore next */ []));
this.noItemsMessage = input(NO_ITEMS_MESSAGE, ...(ngDevMode ? [{ debugName: "noItemsMessage" }] : /* istanbul ignore next */ []));
this.tooltipPercentOfTotal = input('of total', ...(ngDevMode ? [{ debugName: "tooltipPercentOfTotal" }] : /* istanbul ignore next */ []));
/** Optional label rendered below the X axis. */
this.xAxisLabel = input('', ...(ngDevMode ? [{ debugName: "xAxisLabel" }] : /* istanbul ignore next */ []));
/** Optional label rendered rotated to the left of the Y axis (bar scale). */
this.yAxisLabel = input('', ...(ngDevMode ? [{ debugName: "yAxisLabel" }] : /* istanbul ignore next */ []));
/** Optional label rendered rotated to the right of the Y axis (line scale). */
this.yLineAxisLabel = input('', ...(ngDevMode ? [{ debugName: "yLineAxisLabel" }] : /* istanbul ignore next */ []));
/** Optional fixed maximum value for the bar Y axis (left). Defaults to auto. */
this.yBarMax = input(undefined, ...(ngDevMode ? [{ debugName: "yBarMax" }] : /* istanbul ignore next */ []));
/** Optional fixed maximum value for the line Y axis (right). Defaults to auto. */
this.yLineMax = input(undefined, ...(ngDevMode ? [{ debugName: "yLineMax" }] : /* istanbul ignore next */ []));
// ── Outputs ─────────────────────────────────────────────────────────────────
this.valueClicked = output();
// ── Layout ───────────────────────────────────────────────────────────────────
this.MARGIN = { top: 20, right: 30, bottom: 30, left: 65 };
// ── Computed ─────────────────────────────────────────────────────────────────
this.hasData = computed(() => this.barSeries().some(s => s.data.length > 0) || this.lineSeries().some(s => s.data.length > 0), ...(ngDevMode ? [{ debugName: "hasData" }] : /* istanbul ignore next */ []));
this.total = computed(() => {
const barTotal = this.barSeries().reduce((acc, s) => acc + s.data.reduce((a, d) => a + d.value, 0), 0);
const lineTotal = this.lineSeries().reduce((acc, s) => acc + s.data.reduce((a, d) => a + d.value, 0), 0);
return barTotal + lineTotal;
}, ...(ngDevMode ? [{ debugName: "total" }] : /* istanbul ignore next */ []));
this.alertMessageAndType = computed(() => {
if (this.loading()) {
return undefined;
}
if (!this.hasData()) {
return [this.noItemsMessage(), NO_ITEMS_ALERT_TYPE];
}
return undefined;
}, ...(ngDevMode ? [{ debugName: "alertMessageAndType" }] : /* istanbul ignore next */ []));
this.legendItems = computed(() => {
if (!this.showLegend()) {
return [];
}
const barItems = this.barSeries().map(s => ({ label: s.label, color: s.color }));
const lineItems = this.lineSeries().map(s => ({ label: s.label, color: s.color }));
return [...barItems, ...lineItems];
}, ...(ngDevMode ? [{ debugName: "legendItems" }] : /* istanbul ignore next */ []));
this._clipIdCounter = 0;
}
// ── Lifecycle ────────────────────────────────────────────────────────────────
ngOnChanges(_changes) {
if (!this.svg) {
return;
}
requestAnimationFrame(() => this.updateChart());
}
ngAfterViewInit() {
this.svg = select(this.chartRef().nativeElement);
super.ngAfterViewInit();
}
// ── Chart rendering ──────────────────────────────────────────────────────────
updateChart() {
this.svg.selectAll('*').remove();
if (this.loading() || !this.hasData()) {
return;
}
const { width: cw, height: ch } = this.getContainerDimensions();
const extraBottom = this.xAxisLabel() ? 16 : 0;
const extraLeft = this.yAxisLabel() ? 16 : 0;
const effectiveLeft = this.MARGIN.left + extraLeft;
// Reserve extra right margin when line series are present (right Y axis)
const hasLines = this.lineSeries().some(s => s.data.length > 0);
const extraRight = hasLines ? (this.yLineAxisLabel() ? 50 : 40) : 0;
const width = cw - effectiveLeft - this.MARGIN.right - extraRight;
const height = ch - this.MARGIN.top - (this.MARGIN.bottom + extraBottom);
// Collect all unique X keys preserving order
const allBarKeys = this.barSeries().flatMap(s => s.data.map(d => d.x));
const allLineKeys = this.lineSeries().flatMap(s => s.data.map(d => d.x));
const xKeys = [...new Set([...allBarKeys, ...allLineKeys])];
// Build X-label map
const xLabelMap = new Map();
this.barSeries().forEach(s => s.data.forEach(d => xLabelMap.set(d.x, d.xLabel ?? d.x)));
// Outer X scale (one band per category)
const x = scaleBand().domain(xKeys).range([0, width]).paddingInner(0.35).paddingOuter(0.15);
// ── Separate Y scales ────────────────────────────────────────────────────
// Bar Y scale (left axis)
const xStackTotals = new Map();
for (const series of this.barSeries()) {
for (const point of series.data) {
xStackTotals.set(point.x, (xStackTotals.get(point.x) ?? 0) + point.value);
}
}
const maxBarAuto = max([...xStackTotals.values()]) ?? 0;
const maxBar = this.yBarMax() ?? maxBarAuto;
const yBar = scaleLinear()
.domain([0, maxBar || 1])
.nice(this.yBarMax() === undefined ? undefined : 0)
.range([height, 0]);
// Line Y scale (right axis)
const maxLineAuto = max(this.lineSeries().flatMap(s => s.data.map(d => d.value))) ?? 0;
const maxLine = this.yLineMax() ?? maxLineAuto;
const yLine = scaleLinear()
.domain([0, maxLine || 1])
.nice(this.yLineMax() === undefined ? undefined : 0)
.range([height, 0]);
// ── SVG ClipPath ─────────────────────────────────────────────────────────
// Ensures bars/lines that exceed the Y-axis maximum (yBarMax/yLineMax) are
// visually clipped at the chart boundary without modifying the data values.
const clipId = `combo-clip-${this._clipIdCounter++}`;
this.svg
.append('defs')
.append('clipPath')
.attr('id', clipId)
.append('rect')
.attr('x', 0)
.attr('y', 0)
.attr('width', width)
.attr('height', height);
const g = this.svg
.attr('width', width)
.attr('height', height)
.append('g')
.attr('transform', `translate(${effectiveLeft},${this.MARGIN.top})`);
drawXYAxes(g, x, yBar, {
xLabelMap,
width,
height,
xAxisLabel: this.xAxisLabel(),
yAxisLabel: this.yAxisLabel(),
effectiveLeft,
});
styleGridLines(g);
// ── Right Y axis for line series ─────────────────────────────────────────
if (hasLines) {
const lineTickValues = computeYTickValues(yLine);
const lineTickFormat = computeYTickFormat(yLine);
const rightAxisG = g
.append('g')
.attr('transform', `translate(${width},0)`)
.call(axisRight(yLine).tickValues(lineTickValues).tickFormat(lineTickFormat).tickSize(-width));
rightAxisG
.selectAll('text')
.style('font-size', '11px')
.style('fill', 'var(--cds-global-color-construction-400, #666)');
// Style the line-axis grid lines with a distinct dashed color
rightAxisG
.selectAll('.tick line')
.style('stroke', 'var(--cds-global-color-gray-400, #b3b3b3)')
.style('stroke-dasharray', '4 3')
.style('stroke-opacity', '0.8');
// Remove the default domain line so it doesn't overlap the chart
rightAxisG.select('.domain').remove();
if (this.yLineAxisLabel()) {
g.append('text')
.attr('transform', 'rotate(-90)')
.attr('x', -height / 2)
.attr('y', width + extraRight - 4)
.attr('text-anchor', 'middle')
.style('font-size', '12px')
.style('fill', 'var(--cds-global-color-construction-400, #666)')
.text(this.yLineAxisLabel());
}
}
// Wrap chart content in a clipped group so elements exceeding the Y max
// are not visible beyond the chart boundary.
const contentGroup = g.append('g').attr('clip-path', `url(#${clipId})`);
this.drawBars(contentGroup, x, yBar);
this.drawLines(contentGroup, x, yLine);
}
drawBars(g, x, y) {
// Running stack base (cumulative sum) per X key across all bar series
const stackBase = new Map();
for (const series of this.barSeries()) {
if (!series.data.length) {
continue;
}
const stacked = series.data.map(d => ({
...d,
base: stackBase.get(d.x) ?? 0,
}));
// Advance running totals for the next series
for (const d of series.data) {
stackBase.set(d.x, (stackBase.get(d.x) ?? 0) + d.value);
}
g.selectAll(`.bar-${series.key}`)
.data(stacked, (d) => d.x)
.join('rect')
.attr('class', `bar bar-${series.key}`)
.attr('x', (d) => x(d.x) ?? 0)
.attr('y', (d) => y(d.base + d.value))
.attr('width', x.bandwidth())
.attr('height', (d) => y(d.base) - y(d.base + d.value))
.attr('rx', 2)
.attr('ry', 2)
.style('fill', toChartColor(series.color))
.style('cursor', 'pointer')
.on('mouseover', (e) => {
select(e.currentTarget).attr('fill-opacity', 0.75);
})
.on('mouseout', (e) => {
select(e.currentTarget).attr('fill-opacity', 1);
})
.on('click', (event, d) => {
event.stopPropagation();
const rect = event.currentTarget.getBoundingClientRect();
const container = this.chartRef().nativeElement.getBoundingClientRect();
this.tooltipPosition.set({
x: rect.left - container.left + rect.width / 2,
y: (this.tooltipOrientation() === 'top' ? rect.top : rect.bottom) - container.top,
});
this.selectedItem.set({
seriesKey: series.key,
seriesLabel: series.label,
seriesType: 'bar',
color: series.color,
x: d.x,
xLabel: d.xLabel,
value: d.value,
total: this.total(),
});
});
}
}
drawLines(g, x, y) {
// Map X key → band center position for line/dot placement
const lineX = (key) => (x(key) ?? 0) + x.bandwidth() / 2;
const lineGenerator = line()
.x((d) => lineX(d.x))
.y((d) => y(d.value))
.curve(curveMonotoneX);
for (const series of this.lineSeries()) {
if (!series.data.length) {
continue;
}
// Line path
g.append('path')
.datum(series.data)
.attr('class', `combo-line combo-line-${series.key}`)
.attr('fill', 'none')
.style('stroke', toChartColor(series.color))
.attr('stroke-width', 2.5)
.attr('stroke-linejoin', 'round')
.attr('stroke-linecap', 'round')
.attr('d', lineGenerator);
// Dots
g.selectAll(`.combo-dot-${series.key}`)
.data(series.data, (d) => d.x)
.join('circle')
.attr('class', `combo-dot combo-dot-${series.key}`)
.attr('cx', (d) => lineX(d.x))
.attr('cy', (d) => y(d.value))
.attr('r', 4)
.style('fill', toChartColor(series.color))
.attr('stroke', '#fff')
.attr('stroke-width', 2)
.style('cursor', 'pointer')
.on('mouseover', (e) => {
select(e.currentTarget).attr('r', 6);
})
.on('mouseout', (e) => {
select(e.currentTarget).attr('r', 4);
})
.on('click', (event, d) => {
event.stopPropagation();
const el = event.currentTarget;
const rect = el.getBoundingClientRect();
const container = this.chartRef().nativeElement.getBoundingClientRect();
this.tooltipPosition.set({
x: rect.left - container.left + rect.width / 2,
y: (this.tooltipOrientation() === 'top' ? rect.top : rect.bottom) - container.top,
});
this.selectedItem.set({
seriesKey: series.key,
seriesLabel: series.label,
seriesType: 'line',
color: series.color,
x: d.x,
value: d.value,
total: this.total(),
});
});
}
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: ComboChartComponent, deps: null, target: i0.ɵɵFactoryTarget.Component }); }
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.18", type: ComboChartComponent, isStandalone: false, selector: "clr-combo-chart", inputs: { barSeries: { classPropertyName: "barSeries", publicName: "barSeries", isSignal: true, isRequired: false, transformFunction: null }, lineSeries: { classPropertyName: "lineSeries", publicName: "lineSeries", isSignal: true, isRequired: false, transformFunction: null }, tooltipOrientation: { classPropertyName: "tooltipOrientation", publicName: "tooltipOrientation", isSignal: true, isRequired: false, transformFunction: null }, showLegend: { classPropertyName: "showLegend", publicName: "showLegend", isSignal: true, isRequired: false, transformFunction: null }, showExportButton: { classPropertyName: "showExportButton", publicName: "showExportButton", isSignal: true, isRequired: false, transformFunction: null }, exportButtonTitle: { classPropertyName: "exportButtonTitle", publicName: "exportButtonTitle", isSignal: true, isRequired: false, transformFunction: null }, exportFilename: { classPropertyName: "exportFilename", publicName: "exportFilename", isSignal: true, isRequired: false, transformFunction: null }, noItemsMessage: { classPropertyName: "noItemsMessage", publicName: "noItemsMessage", isSignal: true, isRequired: false, transformFunction: null }, tooltipPercentOfTotal: { classPropertyName: "tooltipPercentOfTotal", publicName: "tooltipPercentOfTotal", isSignal: true, isRequired: false, transformFunction: null }, xAxisLabel: { classPropertyName: "xAxisLabel", publicName: "xAxisLabel", isSignal: true, isRequired: false, transformFunction: null }, yAxisLabel: { classPropertyName: "yAxisLabel", publicName: "yAxisLabel", isSignal: true, isRequired: false, transformFunction: null }, yLineAxisLabel: { classPropertyName: "yLineAxisLabel", publicName: "yLineAxisLabel", isSignal: true, isRequired: false, transformFunction: null }, yBarMax: { classPropertyName: "yBarMax", publicName: "yBarMax", isSignal: true, isRequired: false, transformFunction: null }, yLineMax: { classPropertyName: "yLineMax", publicName: "yLineMax", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { valueClicked: "valueClicked" }, usesInheritance: true, usesOnChanges: true, ngImport: i0, template: "<div class=\"chart-container\">\n <div class=\"chart-area\" #container (cngWindowResize)=\"updateChart()\" [class.pt-3]=\"alertMessageAndType()\">\n <svg [class.d-none]=\"loading() || !hasData()\" #chart></svg>\n\n @if (tooltipPosition()) {\n <cng-chart-tooltip\n [tooltipPosition]=\"tooltipPosition()\"\n [tooltipOrientation]=\"tooltipOrientation()\"\n [squareColor]=\"selectedItem()?.color\"\n (tooltipClosed)=\"resetTooltip()\"\n (cngOutsideClick)=\"resetTooltip()\"\n (tooltipHeaderClicked)=\"\n valueClicked.emit({\n seriesKey: selectedItem().seriesKey,\n seriesLabel: selectedItem().seriesLabel,\n seriesType: selectedItem().seriesType,\n x: selectedItem().x,\n xLabel: selectedItem().xLabel,\n value: selectedItem().value,\n })\n \"\n >\n <ng-container ngProjectAs=\"cng-title\">\n ({{ selectedItem().value }}) {{ selectedItem().xLabel || selectedItem().x }}\n </ng-container>\n\n <p class=\"mt-0\">\n <strong>{{ selectedItem().seriesLabel }}:</strong>\n {{ selectedItem().value }}\n </p>\n <p class=\"mt-0\">\n {{ (100 * selectedItem().value) / selectedItem().total | number : '1.0-2' }}%\n {{ tooltipPercentOfTotal() }}\n </p>\n </cng-chart-tooltip>\n } @if (loading() || !hasData()) {\n <cng-bar-chart-skeleton orientation=\"vertical\" [skeletonType]=\"loading() ? 'loading' : 'placeholder'\" />\n } @if (alertMessageAndType()) {\n <cng-chart-alert-overlay [alertType]=\"alertMessageAndType()[1]\" [alertMessage]=\"alertMessageAndType()[0]\" />\n }\n </div>\n\n @if (showLegend() && !loading() && legendItems().length) {\n <cng-chart-legend [items]=\"legendItems()\" />\n } @if (showExportButton() && !loading() && hasData()) {\n <cng-chart-export-button\n [svgRef]=\"svgElement()\"\n [filename]=\"exportFilename()\"\n [buttonTitle]=\"exportButtonTitle()\"\n [legendItems]=\"legendItems()\"\n />\n }\n</div>\n", styles: [":host{display:block;width:100%;height:100%}:host ::ng-deep .domain{display:none}.chart-container{display:flex;flex-direction:column;width:100%;height:100%;position:relative}.chart-area{flex:1;min-height:0;position:relative}svg{height:100%;width:100%}cng-bar-chart-skeleton{height:100%}.combo-line{pointer-events:none}.combo-dot{transition:r .1s ease}\n"], dependencies: [{ kind: "component", type: ChartAlertOverlayComponent, selector: "cng-chart-alert-overlay", inputs: ["alertMessage", "alertType"] }, { kind: "component", type: ChartExportButtonComponent, selector: "cng-chart-export-button", inputs: ["svgRef", "filename", "buttonTitle", "legendItems"] }, { kind: "component", type: ChartLegendComponent, selector: "cng-chart-legend", inputs: ["items"] }, { kind: "component", type: ChartSkeletonComponent, selector: "cng-bar-chart-skeleton", inputs: ["skeletonType", "orientation"] }, { kind: "component", type: ChartTooltipComponent, selector: "cng-chart-tooltip", inputs: ["tooltipPosition", "tooltipOrientation", "squareColor", "tooltipClickable"], outputs: ["tooltipClosed", "tooltipHeaderClicked"] }, { kind: "directive", type: OutsideClickDirective, selector: "[cngOutsideClick]", outputs: ["cngOutsideClick"] }, { kind: "directive", type: WindowResizeDirective, selector: "[cngWindowResize]", inputs: ["debounce", "includeFirst"], outputs: ["cngWindowResize"] }, { kind: "pipe", type: i8.DecimalPipe, name: "number" }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: ComboChartComponent, decorators: [{
type: Component,
args: [{ selector: 'clr-combo-chart', standalone: false, changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"chart-container\">\n <div class=\"chart-area\" #container (cngWindowResize)=\"updateChart()\" [class.pt-3]=\"alertMessageAndType()\">\n <svg [class.d-none]=\"loading() || !hasData()\" #chart></svg>\n\n @if (tooltipPosition()) {\n <cng-chart-tooltip\n [tooltipPosition]=\"tooltipPosition()\"\n [tooltipOrientation]=\"tooltipOrientation()\"\n [squareColor]=\"selectedItem()?.color\"\n (tooltipClosed)=\"resetTooltip()\"\n (cngOutsideClick)=\"resetTooltip()\"\n (tooltipHeaderClicked)=\"\n valueClicked.emit({\n seriesKey: selectedItem().seriesKey,\n seriesLabel: selectedItem().seriesLabel,\n seriesType: selectedItem().seriesType,\n x: selectedItem().x,\n xLabel: selectedItem().xLabel,\n value: selectedItem().value,\n })\n \"\n >\n <ng-container ngProjectAs=\"cng-title\">\n ({{ selectedItem().value }}) {{ selectedItem().xLabel || selectedItem().x }}\n </ng-container>\n\n <p class=\"mt-0\">\n <strong>{{ selectedItem().seriesLabel }}:</strong>\n {{ selectedItem().value }}\n </p>\n <p class=\"mt-0\">\n {{ (100 * selectedItem().value) / selectedItem().total | number : '1.0-2' }}%\n {{ tooltipPercentOfTotal() }}\n </p>\n </cng-chart-tooltip>\n } @if (loading() || !hasData()) {\n <cng-bar-chart-skeleton orientation=\"vertical\" [skeletonType]=\"loading() ? 'loading' : 'placeholder'\" />\n } @if (alertMessageAndType()) {\n <cng-chart-alert-overlay [alertType]=\"alertMessageAndType()[1]\" [alertMessage]=\"alertMessageAndType()[0]\" />\n }\n </div>\n\n @if (showLegend() && !loading() && legendItems().length) {\n <cng-chart-legend [items]=\"legendItems()\" />\n } @if (showExportButton() && !loading() && hasData()) {\n <cng-chart-export-button\n [svgRef]=\"svgElement()\"\n [filename]=\"exportFilename()\"\n [buttonTitle]=\"exportButtonTitle()\"\n [legendItems]=\"legendItems()\"\n />\n }\n</div>\n", styles: [":host{display:block;width:100%;height:100%}:host ::ng-deep .domain{display:none}.chart-container{display:flex;flex-direction:column;width:100%;height:100%;position:relative}.chart-area{flex:1;min-height:0;position:relative}svg{height:100%;width:100%}cng-bar-chart-skeleton{height:100%}.combo-line{pointer-events:none}.combo-dot{transition:r .1s ease}\n"] }]
}], propDecorators: { barSeries: [{ type: i0.Input, args: [{ isSignal: true, alias: "barSeries", required: false }] }], lineSeries: [{ type: i0.Input, args: [{ isSignal: true, alias: "lineSeries", required: false }] }], tooltipOrientation: [{ type: i0.Input, args: [{ isSignal: true, alias: "tooltipOrientation", required: false }] }], showLegend: [{ type: i0.Input, args: [{ isSignal: true, alias: "showLegend", required: false }] }], showExportButton: [{ type: i0.Input, args: [{ isSignal: true, alias: "showExportButton", required: false }] }], exportButtonTitle: [{ type: i0.Input, args: [{ isSignal: true, alias: "exportButtonTitle", required: false }] }], exportFilename: [{ type: i0.Input, args: [{ isSignal: true, alias: "exportFilename", required: false }] }], noItemsMessage: [{ type: i0.Input, args: [{ isSignal: true, alias: "noItemsMessage", required: false }] }], tooltipPercentOfTotal: [{ type: i0.Input, args: [{ isSignal: true, alias: "tooltipPercentOfTotal", required: false }] }], xAxisLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "xAxisLabel", required: false }] }], yAxisLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "yAxisLabel", required: false }] }], yLineAxisLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "yLineAxisLabel", required: false }] }], yBarMax: [{ type: i0.Input, args: [{ isSignal: true, alias: "yBarMax", required: false }] }], yLineMax: [{ type: i0.Input, args: [{ isSignal: true, alias: "yLineMax", required: false }] }], valueClicked: [{ type: i0.Output, args: ["valueClicked"] }] } });
class PieChartComponent extends ChartBase {
constructor() {
super(...arguments);
this.data = input.required(...(ngDevMode ? [{ debugName: "data" }] : /* istanbul ignore next */ []));
this.donut = input(true, ...(ngDevMode ? [{ debugName: "donut" }] : /* istanbul ignore next */ []));
this.showLegend = input(true, ...(ngDevMode ? [{ debugName: "showLegend" }] : /* istanbul ignore next */ []));
this.showExportButton = input(false, ...(ngDevMode ? [{ debugName: "showExportButton" }] : /* istanbul ignore next */ []));
this.exportButtonTitle = input('Export', ...(ngDevMode ? [{ debugName: "exportButtonTitle" }] : /* istanbul ignore next */ []));
this.exportFilename = input('pie-chart', ...(ngDevMode ? [{ debugName: "exportFilename" }] : /* istanbul ignore next */ []));
this.tooltipOrientation = input('top', ...(ngDevMode ? [{ debugName: "tooltipOrientation" }] : /* istanbul ignore next */ []));
this.noItemsMessage = input(NO_ITEMS_MESSAGE, ...(ngDevMode ? [{ debugName: "noItemsMessage" }] : /* istanbul ignore next */ []));
this.tooltipPercentOfTotal = input('of total', ...(ngDevMode ? [{ debugName: "tooltipPercentOfTotal" }] : /* istanbul ignore next */ []));
this.valueClicked = output();
this.hasData = computed(() => this.data()?.some(d => d.value > 0), ...(ngDevMode ? [{ debugName: "hasData" }] : /* istanbul ignore next */ []));
this.total = computed(() => this.data()?.reduce((acc, d) => acc + d.value, 0) ?? 0, ...(ngDevMode ? [{ debugName: "total" }] : /* istanbul ignore next */ []));
this.alertMessageAndType = computed(() => {
if (this.loading()) {
return undefined;
}
if (!this.hasData()) {
return [this.noItemsMessage(), NO_ITEMS_ALERT_TYPE];
}
return undefined;
}, ...(ngDevMode ? [{ debugName: "alertMessageAndType" }] : /* istanbul ignore next */ []));
this.legendItems = computed(() => {
if (!this.showLegend()) {
return [];
}
return (this.data() ?? []).filter(d => d.value > 0).map(d => ({ label: d.fullLabel ?? d.label, color: d.color }));
}, ...(ngDevMode ? [{ debugName: "legendItems" }] : /* istanbul ignore next */ []));
}
ngOnChanges(_changes) {
if (!this.svg) {
return;
}
requestAnimationFrame(() => this.updateChart());
}
ngAfterViewInit() {
this.svg = select(this.chartRef().nativeElement);
super.ngAfterViewInit();
}
updateChart() {
this.svg.selectAll('*').remove();
if (this.loading() || !this.hasData()) {
return;
}
const { width, height } = this.getContainerDimensions();
const radius = Math.min(width, height) / 2;
const innerRadius = this.donut() ? radius * 0.55 : 0;
const outerRadius = radius * 0.9;
this.arcGen = arc().innerRadius(innerRadius).outerRadius(outerRadius);
const hoverArc = arc()
.innerRadius(innerRadius)
.outerRadius(outerRadius * 1.06);
const pieGen = pie()
.value(d => d.value)
.sort(null);
const arcs = pieGen(this.data().filter(d => d.value > 0));
const g = this.svg
.attr('width', width)
.attr('height', height)
.append('g')
.attr('transform', `translate(${width / 2},${height / 2})`);
// Slices
g.selectAll('.slice')
.data(arcs, (d) => d.data.key)
.join('path')
.attr('class', 'slice')
.attr('d', this.arcGen)
.style('fill', (d) => toChartColor(d.data.color))
.attr('stroke', '#fff')
.attr('stroke-width', 2)
.style('cursor', 'pointer')
.on('mouseover', (e) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
select(e.currentTarget).attr('d', hoverArc);
})
.on('mouseout', (e) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
select(e.currentTarget).attr('d', this.arcGen);
})
.on('click', (event, d) => {
event.stopPropagation();
this.openTooltip(d, width, height);
});
// Slice labels – only show when the slice angle is large enough to fit text
const MIN_ANGLE = 0.4; // ~23°
const labelRadius = this.donut() ? (innerRadius + outerRadius) / 2 : outerRadius * 0.65;
const labelArc = arc().innerRadius(labelRadius).outerRadius(labelRadius);
const total = this.total();
const labelGroups = g
.selectAll('.slice-label')
.data(arcs.filter((d) => d.endAngle - d.startAngle >= MIN_ANGLE), (d) => d.data.key)
.join('g')
.attr('class', 'slice-label')
.attr('transform', (d) => {
const [x, y] = labelArc.centroid(d);
return `translate(${x},${y})`;
})
.style('pointer-events', 'none');
labelGroups
.append('text')
.attr('class', 'label-value')
.attr('text-anchor', 'middle')
.attr('dy', '-0.3em')
.style('font-size', '11px')
.style('font-weight', '600')
.style('fill', '#fff')
.style('pointer-events', 'none')
.text((d) => format('~s')(d.data.value));
labelGroups
.append('text')
.attr('class', 'label-percent')
.attr('text-anchor', 'middle')
.attr('dy', '0.9em')
.style('font-size', '10px')
.style('fill', 'rgba(255, 255, 255, 0.9)')
.style('pointer-events', 'none')
.text((d) => `${((100 * d.data.value) / total).toFixed(1)}%`);
// Center label (donut only)
if (this.donut()) {
g.append('text')
.attr('class', 'center-label')
.attr('text-anchor', 'middle')
.attr('dy', '-0.1em')
.style('font-size', '1.4rem')
.style('font-weight', '600')
.style('fill', 'var(--cds-global-color-gray-900, #21333b)')
.text(format('~s')(this.total()));
g.append('text')
.attr('class', 'center-sublabel')
.attr('text-anchor', 'middle')
.attr('dy', '1.2em')
.style('font-size', '0.65rem')
.style('fill', 'var(--cds-global-color-construction-400, #666)')
.style('text-transform', 'uppercase')
.style('letter-spacing', '0.05em')
.text('Total');
}
}
openTooltip(d, width, height) {
const [cx, cy] = this.arcGen.centroid(d);
this.tooltipPosition.set({
x: width / 2 + cx,
y: height / 2 + cy,
});
this.selectedItem.set(d.data);
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: PieChartComponent, deps: null, target: i0.ɵɵFactoryTarget.Component }); }
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.18", type: PieChartComponent, isStandalone: false, selector: "clr-pie-chart", inputs: { data: { classPropertyName: "data", publicName: "data", isSignal: true, isRequired: true, transformFunction: null }, donut: { classPropertyName: "donut", publicName: "donut", isSignal: true, isRequired: false, transformFunction: null }, showLegend: { classPropertyName: "showLegend", publicName: "showLegend", isSignal: true, isRequired: false, transformFunction: null }, showExportButton: { classPropertyName: "showExportButton", publicName: "showExportButton", isSignal: true, isRequired: false, transformFunction: null }, exportButtonTitle: { classPropertyName: "exportButtonTitle", publicName: "exportButtonTitle", isSignal: true, isRequired: false, transformFunction: null }, exportFilename: { classPropertyName: "exportFilename", publicName: "exportFilename", isSignal: true, isRequired: false, transformFunction: null }, tooltipOrientation: { classPropertyName: "tooltipOrientation", publicName: "tooltipOrientation", isSignal: true, isRequired: false, transformFunction: null }, noItemsMessage: { classPropertyName: "noItemsMessage", publicName: "noItemsMessage", isSignal: true, isRequired: false, transformFunction: null }, tooltipPercentOfTotal: { classPropertyName: "tooltipPercentOfTotal", publicName: "tooltipPercentOfTotal", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { valueClicked: "valueClicked" }, usesInheritance: true, usesOnChanges: true, ngImport: i0, template: "<div class=\"chart-container\">\n <div class=\"chart-area\" #container (cngWindowResize)=\"updateChart()\" [class.pt-3]=\"alertMessageAndType()\">\n <svg [class.d-none]=\"loading() || !hasData()\" #chart></svg>\n\n @if (tooltipPosition()) {\n <cng-chart-tooltip\n [tooltipPosition]=\"tooltipPosition()\"\n [tooltipOrientation]=\"tooltipOrientation()\"\n [squareColor]=\"selectedItem()?.color\"\n (tooltipClosed)=\"resetTooltip()\"\n (cngOutsideClick)=\"resetTooltip()\"\n (tooltipHeaderClicked)=\"\n valueClicked.emit({\n key: selectedItem().key,\n label: selectedItem().fullLabel || selectedItem().label,\n value: selectedItem().value,\n })\n \"\n >\n <ng-container ngProjectAs=\"cng-title\">\n ({{ selectedItem().value }}) {{ selectedItem().fullLabel || selectedItem().label }}\n </ng-container>\n\n <p class=\"mt-0\">\n {{ (100 * selectedItem().value) / total() | number : '1.0-2' }}%\n {{ tooltipPercentOfTotal() }}\n </p>\n </cng-chart-tooltip>\n } @if (loading() || !hasData()) {\n <cng-bar-chart-skeleton orientation=\"vertical\" [skeletonType]=\"loading() ? 'loading' : 'placeholder'\" />\n } @if (alertMessageAndType()) {\n <cng-chart-alert-overlay [alertType]=\"alertMessageAndType()[1]\" [alertMessage]=\"alertMessageAndType()[0]\" />\n }\n </div>\n\n @if (showLegend() && !loading() && legendItems().length) {\n <cng-chart-legend [items]=\"legendItems()\" />\n } @if (showExportButton() && !loading() && hasData()) {\n <cng-chart-export-button\n [svgRef]=\"svgElement()\"\n [filename]=\"exportFilename()\"\n [buttonTitle]=\"exportButtonTitle()\"\n [legendItems]=\"legendItems()\"\n />\n }\n</div>\n", styles: [":host{display:block;width:100%;height:100%}.chart-container{display:flex;flex-direction:column;width:100%;height:100%;position:relative}.chart-area{flex:1;min-height:0;position:relative}svg{height:100%;width:100%}cng-bar-chart-skeleton{height:100%}.slice{transition:d .15s ease}.label-value{font-size:11px;font-weight:600;fill:#fff}.label-percent{font-size:10px;fill:#ffffffe6}.center-label{font-size:1.4rem;font-weight:600;fill:var(--cds-global-color-gray-900, #21333b)}.center-sublabel{font-size:.65rem;fill:var(--cds-global-color-construction-400, #666);text-transform:uppercase;letter-spacing:.05em}\n"], dependencies: [{ kind: "component", type: ChartAlertOverlayComponent, selector: "cng-chart-alert-overlay", inputs: ["alertMessage", "alertType"] }, { kind: "component", type: ChartExportButtonComponent, selector: "cng-chart-export-button", inputs: ["svgRef", "filename", "buttonTitle", "legendItems"] }, { kind: "component", type: ChartLegendComponent, selector: "cng-chart-legend", inputs: ["items"] }, { kind: "component", type: ChartSkeletonComponent, selector: "cng-bar-chart-skeleton", inputs: ["skeletonType", "orientation"] }, { kind: "component", type: ChartTooltipComponent, selector: "cng-chart-tooltip", inputs: ["tooltipPosition", "tooltipOrientation", "squareColor", "tooltipClickable"], outputs: ["tooltipClosed", "tooltipHeaderClicked"] }, { kind: "directive", type: OutsideClickDirective, selector: "[cngOutsideClick]", outputs: ["cngOutsideClick"] }, { kind: "directive", type: WindowResizeDirective, selector: "[cngWindowResize]", inputs: ["debounce", "includeFirst"], outputs: ["cngWindowResize"] }, { kind: "pipe", type: i8.DecimalPipe, name: "number" }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: PieChartComponent, decorators: [{
type: Component,
args: [{ selector: 'clr-pie-chart', standalone: false, changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"chart-container\">\n <div class=\"chart-area\" #container (cngWindowResize)=\"updateChart()\" [class.pt-3]=\"alertMessageAndType()\">\n <svg [class.d-none]=\"loading() || !hasData()\" #chart></svg>\n\n @if (tooltipPosition()) {\n <cng-chart-tooltip\n [tooltipPosition]=\"tooltipPosition()\"\n [tooltipOrientation]=\"tooltipOrientation()\"\n [squareColor]=\"selectedItem()?.color\"\n (tooltipClosed)=\"resetTooltip()\"\n (cngOutsideClick)=\"resetTooltip()\"\n (tooltipHeaderClicked)=\"\n valueClicked.emit({\n key: selectedItem().key,\n label: selectedItem().fullLabel || selectedItem().label,\n value: selectedItem().value,\n })\n \"\n >\n <ng-container ngProjectAs=\"cng-title\">\n ({{ selectedItem().value }}) {{ selectedItem().fullLabel || selectedItem().label }}\n </ng-container>\n\n <p class=\"mt-0\">\n {{ (100 * selectedItem().value) / total() | number : '1.0-2' }}%\n {{ tooltipPercentOfTotal() }}\n </p>\n </cng-chart-tooltip>\n } @if (loading() || !hasData()) {\n <cng-bar-chart-skeleton orientation=\"vertical\" [skeletonType]=\"loading() ? 'loading' : 'placeholder'\" />\n } @if (alertMessageAndType()) {\n <cng-chart-alert-overlay [alertType]=\"alertMessageAndType()[1]\" [alertMessage]=\"alertMessageAndType()[0]\" />\n }\n </div>\n\n @if (showLegend() && !loading() && legendItems().length) {\n <cng-chart-legend [items]=\"legendItems()\" />\n } @if (showExportButton() && !loading() && hasData()) {\n <cng-chart-export-button\n [svgRef]=\"svgElement()\"\n [filename]=\"exportFilename()\"\n [buttonTitle]=\"exportButtonTitle()\"\n [legendItems]=\"legendItems()\"\n />\n }\n</div>\n", styles: [":host{display:block;width:100%;height:100%}.chart-container{display:flex;flex-direction:column;width:100%;height:100%;position:relative}.chart-area{flex:1;min-height:0;position:relative}svg{height:100%;width:100%}cng-bar-chart-skeleton{height:100%}.slice{transition:d .15s ease}.label-value{font-size:11px;font-weight:600;fill:#fff}.label-percent{font-size:10px;fill:#ffffffe6}.center-label{font-size:1.4rem;font-weight:600;fill:var(--cds-global-color-gray-900, #21333b)}.center-sublabel{font-size:.65rem;fill:var(--cds-global-color-construction-400, #666);text-transform:uppercase;letter-spacing:.05em}\n"] }]
}], propDecorators: { data: [{ type: i0.Input, args: [{ isSignal: true, alias: "data", required: true }] }], donut: [{ type: i0.Input, args: [{ isSignal: true, alias: "donut", required: false }] }], showLegend: [{ type: i0.Input, args: [{ isSignal: true, alias: "showLegend", required: false }] }], showExportButton: [{ type: i0.Input, args: [{ isSignal: true, alias: "showExportButton", required: false }] }], exportButtonTitle: [{ type: i0.Input, args: [{ isSignal: true, alias: "exportButtonTitle", required: false }] }], exportFilename: [{ type: i0.Input, args: [{ isSignal: true, alias: "exportFilename", required: false }] }], tooltipOrientation: [{ type: i0.Input, args: [{ isSignal: true, alias: "tooltipOrientation", required: false }] }], noItemsMessage: [{ type: i0.Input, args: [{ isSignal: true, alias: "noItemsMessage", required: false }] }], tooltipPercentOfTotal: [{ type: i0.Input, args: [{ isSignal: true, alias: "tooltipPercentOfTotal", required: false }] }], valueClicked: [{ type: i0.Output, args: ["valueClicked"] }] } });
/*
* Copyright (c) 2018-2026 Porsche Informatik. All Rights Reserved.
* This software is released under MIT license.
* The full license information can be found in LICENSE in the root directory of this project.
*/
// ─── Constants ────────────────────────────────────────────────────────────────
const DEFAULT_SECTION_COLORS = ['#009ADB', '#66D1FF', '#D9D9D9', '#A6A6A6'];
const DEFAULT_TEXT_COLORS = ['#eee', '#eee', '#555', '#eee'];
const DEFAULT_CHART_LABELS = {
total: 'Total',
all: 'All',
};
class FunnelChartComponent extends ChartBase {
constructor() {
super(...arguments);
// ── Inputs ──────────────────────────────────────────────────────────────────
this.data = input([], ...(ngDevMode ? [{ debugName: "data" }] : /* istanbul ignore next */ []));
this.showExportButton = input(false, ...(ngDevMode ? [{ debugName: "showExportButton" }] : /* istanbul ignore next */ []));
this.exportButtonTitle = input('Export', ...(ngDevMode ? [{ debugName: "exportButtonTitle" }] : /* istanbul ignore next */ []));
this.exportFilename = input('funnel-chart', ...(ngDevMode ? [{ debugName: "exportFilename" }] : /* istanbul ignore next */ []));
/** Rendering mode. 'default' = horizontal bars with sections; 'centered' = centered trapezoid funnel. */
this.orientation = input('default', ...(ngDevMode ? [{ debugName: "orientation" }] : /* istanbul ignore next */ []));
/** Width reserved on each side for labels (px). */
this.textSize = input(200, ...(ngDevMode ? [{ debugName: "textSize" }] : /* istanbul ignore next */ []));
/** Gap between funnel bars and their side-line labels (px). */
this.lineTextPadding = input(8, ...(ngDevMode ? [{ debugName: "lineTextPadding" }] : /* istanbul ignore next */ []));
/** Vertical gap between funnel bars – default mode only (px). */
this.barGap = input(2, ...(ngDevMode ? [{ debugName: "barGap" }] : /* istanbul ignore next */ []));
/** Vertical padding inside the side measurement lines (px). */
this.sideLineVerticalPadding = input(4, ...(ngDevMode ? [{ debugName: "sideLineVerticalPadding" }] : /* istanbul ignore next */ []));
// ── Centered-mode specific inputs ────────────────────────────────────────────
/** Minimum length of the horizontal measurement lines on each side (px). Centered mode only. */
this.minLineSize = input(30, ...(ngDevMode ? [{ debugName: "minLineSize" }] : /* istanbul ignore next */ []));
/** Ratio of bar height to spacer height. Centered mode only. */
this.barToSpacerRatio = input(2, ...(ngDevMode ? [{ debugName: "barToSpacerRatio" }] : /* istanbul ignore next */ []));
/** Vertical padding inside the center divider lines (px). Centered mode only. */
this.middleLineVerticalPadding = input(4, ...(ngDevMode ? [{ debugName: "middleLineVerticalPadding" }] : /* istanbul ignore next */ []));
/** Fill color of the centered-mode bars. Accepts hex or CSS custom property. Centered mode only. */
this.barColor = input(DEFAULT_SECTION_COLORS[0], ...(ngDevMode ? [{ debugName: "barColor" }] : /* istanbul ignore next */ []));
/** Fill color of the trapezoid spacers. Accepts hex or CSS custom property. Centered mode only. */
this.spacerColor = input('#D9D9D9', ...(ngDevMode ? [{ debugName: "spacerColor" }] : /* istanbul ignore next */ []));
/**
* Override the chart labels shown in the tooltip (total / all rows).
* Partial – any key not provided falls back to the English default.
*/
this.chartLabels = input({}, ...(ngDevMode ? [{ debugName: "chartLabels" }] : /* istanbul ignore next */ []));
/**
* Override section colors by section key.
* Accepts hex values ('#009ADB') or CSS custom properties ('--cds-global-color-blue-800').
* Per-section `color` in the data takes priority over this map; this map takes priority over
* the built-in default palette.
*/
this.sectionColors = input({}, ...(ngDevMode ? [{ debugName: "sectionColors" }] : /* istanbul ignore next */ []));
// ── Outputs ─────────────────────────────────────────────────────────────────
this.valueClicked = output();
// ── Computed ─────────────────────────────────────────────────────────────────
this.resolvedChartLabels = computed(() => ({
...DEFAULT_CHART_LABELS,
...this.chartLabels(),
}), ...(ngDevMode ? [{ debugName: "resolvedChartLabels" }] : /* istanbul ignore next */ []));
/** CSS-ready color for the centered-mode bar. */
this.centeredBarCssColor = computed(() => toChartColor(this.barColor()), ...(ngDevMode ? [{ debugName: "centeredBarCssColor" }] : /* istanbul ignore next */ []));
/** CSS-ready spacer color. */
this.centeredSpacerCssColor = computed(() => toChartColor(this.spacerColor()), ...(ngDevMode ? [{ debugName: "centeredSpacerCssColor" }] : /* istanbul ignore next */ []));
this.total = computed(() => this.data()[0]?.value ?? 0, ...(ngDevMode ? [{ debugName: "total" }] : /* istanbul ignore next */ []));
this.hasData = computed(() => this.data().length > 0, ...(ngDevMode ? [{ debugName: "hasData" }] : /* istanbul ignore next */ []));
// ── State ─────────────────────────────────────────────────────────────────────
this.selectedItemLabel = computed(() => this.selectedItem()?.fullLabel ?? this.selectedItem()?.label ?? '', ...(ngDevMode ? [{ debugName: "selectedItemLabel" }] : /* istanbul ignore next */ []));
this.selectedSpacerItem = signal(undefined, ...(ngDevMode ? [{ debugName: "selectedSpacerItem" }] : /* istanbul ignore next */ []));
this.sectionTooltips = signal([], ...(ngDevMode ? [{ debugName: "sectionTooltips" }] : /* istanbul ignore next */ []));
}
// ── Lifecycle ────────────────────────────────────────────────────────────────
ngOnChanges(_changes) {
if (!this.svg) {
return;
}
requestAnimationFrame(() => this.updateChart());
}
ngAfterViewInit() {
this.svg = select(this.chartRef().nativeElement);
super.ngAfterViewInit();
}
// ── Chart rendering ──────────────────────────────────────────────────────────
updateChart() {
if (!this.svg) {
return;
}
this.svg.selectAll('*').remove();
if (this.loading() || !this.hasData()) {
return;
}
const { width: containerWidth, height: containerHeight } = this.getContainerDimensions();
if (this.orientation() === 'centered') {
this.renderCenteredChart(containerWidth, containerHeight);
}
else {
const funnelWidth = this.getFunnelWidth(containerWidth);
const barHeight = this.getBarHeight(containerHeight);
const dataPoints = this.calculateDataPoints(funnelWidth, barHeight);
this.renderRightSideText(dataPoints, containerWidth, barHeight);
this.renderLeftSideText(dataPoints, barHeight);
this.renderSideLines(dataPoints, containerWidth, funnelWidth, barHeight);
this.renderFunnel(dataPoints, containerWidth, containerHeight, funnelWidth, barHeight);
}
}
// ── Data calculation ─────────────────────────────────────────────────────────
calculateDataPoints(funnelWidth, barHeight) {
const maxValue = max(this.data(), d => d.value) ?? 1;
const widthScale = scaleLinear().domain([0, maxValue]).range([0, funnelWidth]);
const sectionColorOverrides = this.sectionColors();
return this.data().map((d, i) => {
let xOffset = 0;
const sections = (d.sections ?? []).map((s, si) => {
const rawColor = s.color ?? sectionColorOverrides[s.key] ?? DEFAULT_SECTION_COLORS[si % DEFAULT_SECTION_COLORS.length];
const cssColor = toChartColor(rawColor);
const hoverColor = rawColor.startsWith('--')
? cssColor
: (color(rawColor)?.darker(0.5)?.formatHex() ?? cssColor);
const textColor = s.textColor ?? DEFAULT_TEXT_COLORS[si % DEFAULT_TEXT_COLORS.length];
const sWidth = widthScale(s.value);
const sec = {
key: s.key,
label: s.label,
x: xOffset,
width: sWidth,
value: s.value,
percentage: d.value ? this.round((s.value / d.value) * 100) : 0,
cssColor,
hoverColor,
textColor,
};
xOffset += sWidth;
return sec;
});
const point = {
...d,
sections,
y: i * (barHeight + this.barGap()),
width: widthScale(d.value),
percentage: this.total() ? this.round((d.value / this.total()) * 100) : 0,
};
if (i > 0) {
const previousValue = this.data()[i - 1].value;
point.delta = previousValue - d.value;
point.deltaPercentage = previousValue === 0 ? 0 : this.round((point.delta / previousValue) * 100);
}
return point;
});
}
// ── Rendering helpers ────────────────────────────────────────────────────────
renderRightSideText(dataPoints, containerWidth, barHeight) {
const rightG = this.svg.append('g').attr('transform', `translate(${containerWidth - this.textSize()},0)`);
dataPoints.forEach((d, i) => {
if (i === 0) {
return;
}
const prev = dataPoints[i - 1];
const labelY = (prev.y + barHeight + d.y) / 2 + 4;
rightG
.append('text')
.attr('x', 0)
.attr('y', labelY)
.attr('text-anchor', 'start')
.style('font-size', '12px')
.style('fill', 'var(--cds-global-color-construction-400, #666)')
.text(`-${this.round(d.deltaPercentage)}% (${d.delta})`);
});
}
renderLeftSideText(dataPoints, barHeight) {
const leftG = this.svg.append('g');
const ICON_SPACE = 20;
const positions = [];
dataPoints.forEach(d => {
const labelY = (d.y + barHeight + d.y) / 2 + 4;
const iconSpacing = d.description ? ICON_SPACE : 0;
const textLength = leftG
.append('text')
.attr('x', this.textSize() - iconSpacing)
.attr('y', labelY - 8)
.attr('text-anchor', 'end')
.style('font-size', '13px')
.style('font-weight', '600')
.style('fill', 'var(--cds-global-color-gray-900, #21333b)')
.style('cursor', 'pointer')
.text(d.label)
.on('click', () => this.valueClicked.emit({ value: d.value, label: d.label, key: d.key }))
.node()
.getComputedTextLength();
if (d.description) {
positions.push({
x: this.textSize() - iconSpacing + 5,
y: labelY - 24,
key: d.key,
description: d.description,
});
}
leftG
.append('line')
.attr('x1', this.textSize() - textLength - iconSpacing)
.attr('y1', labelY - 5)
.attr('x2', this.textSize() - iconSpacing)
.attr('y2', labelY - 5)
.attr('stroke', 'var(--cds-global-color-construction-400, #666)')
.attr('stroke-width', 2)
.attr('stroke-dasharray', '2,2');
leftG
.append('text')
.attr('x', this.textSize())
.attr('y', labelY + 10)
.attr('text-anchor', 'end')
.style('font-size', '12px')
.style('fill', 'var(--cds-global-color-construction-400, #666)')
.text(`${this.round(d.percentage)}% (${d.value})`);
});
this.sectionTooltips.set(positions);
}
renderSideLines(dataPoints, containerWidth, funnelWidth, barHeight) {
const lineG = this.svg.append('g').attr('transform', `translate(${(containerWidth - funnelWidth) / 2},0)`);
dataPoints.forEach(d => {
const midY = d.y + barHeight / 2;
lineG
.append('line')
.attr('x1', 0)
.attr('y1', midY)
.attr('x2', funnelWidth)
.attr('y2', midY)
.attr('stroke', '#D9D9D9')
.attr('stroke-width', 1);
this.renderSideLine(lineG, funnelWidth, d.y, barHeight);
if (d.value === 0) {
this.renderSideLine(lineG, 0, d.y, barHeight);
}
});
}
renderSideLine(g, x, y, barHeight) {
g.append('line')
.attr('x1', x)
.attr('y1', y + this.sideLineVerticalPadding())
.attr('x2', x)
.attr('y2', y + barHeight - this.sideLineVerticalPadding())
.attr('stroke', '#D9D9D9')
.attr('stroke-width', 1);
}
renderFunnel(dataPoints, containerWidth, containerHeight, funnelWidth, barHeight) {
const funnelG = this.svg
.attr('width', containerWidth)
.attr('height', containerHeight)
.append('g')
.attr('transform', `translate(${(containerWidth - funnelWidth) / 2},0)`);
// First pass: all rects (z-order: rects below texts)
dataPoints.forEach(dp => {
dp.sections.forEach(section => {
funnelG
.append('rect')
.attr('x', section.x)
.attr('y', dp.y)
.attr('width', section.width)
.attr('height', barHeight)
.style('fill', section.cssColor)
.style('cursor', 'pointer')
.on('mouseover', e => {
select(e.currentTarget).style('fill', section.hoverColor);
})
.on('mouseout', e => {
select(e.currentTarget).style('fill', section.cssColor);
})
.on('click', (event) => {
this.handleSectionClick(event, dp, section.key);
});
});
});
// Second pass: texts on top
dataPoints.forEach(dp => {
dp.sections.forEach(section => {
if (section.width > 40) {
funnelG
.append('text')
.attr('x', section.x + section.width / 2)
.attr('y', dp.y + barHeight / 2)
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'middle')
.style('font-size', '13px')
.style('font-weight', '600')
.style('pointer-events', 'none')
.style('fill', section.textColor)
.text(`${section.percentage}%`);
}
});
});
}
// ── Event handlers ────────────────────────────────────────────────────────────
handleSectionClick(event, d, _sectionKey) {
event.stopPropagation();
const target = event.currentTarget;
const rect = target.getBoundingClientRect();
const container = this.chartRef().nativeElement.getBoundingClientRect();
this.tooltipPosition.set({
x: rect.left - container.left + rect.width / 2,
y: rect.bottom - container.top,
});
this.selectedItem.set(d);
}
resetTooltip() {
if (this.selectedItem() || this.selectedSpacerItem() || this.tooltipPosition()) {
this.selectedItem.set(undefined);
this.selectedSpacerItem.set(undefined);
this.tooltipPosition.set(undefined);
}
}
/** Formats a percentage to 2 decimal places for template use. */
pct(value, total) {
return this.round(total ? (value / total) * 100 : 0);
}
// ── Centered chart ────────────────────────────────────────────────────────────
renderCenteredChart(containerWidth, containerHeight) {
const maxFunnelWidth = this.getMaxFunnelWidth(containerWidth);
const barHeight = this.getCenteredBarHeight(containerHeight);
const spacerHeight = this.getCenteredSpacerHeight(containerHeight);
const dataPoints = this.calculateCenteredDataPoints(maxFunnelWidth, barHeight, spacerHeight);
const spacerPoints = this.calculateSpacerDataPoints(dataPoints, maxFunnelWidth, barHeight);
this.renderCenteredLeftSideText(dataPoints, barHeight);
this.renderCenteredLeftSideLines(dataPoints, maxFunnelWidth, barHeight);
this.renderCenteredRightSideText(dataPoints, containerWidth, barHeight);
this.renderCenteredRightSideLines(dataPoints, containerWidth, maxFunnelWidth, barHeight);
this.renderCenteredMiddleLines(dataPoints, spacerPoints, containerWidth, barHeight);
this.renderCenteredFunnel(dataPoints, spacerPoints, containerWidth, containerHeight, maxFunnelWidth, barHeight, spacerHeight);
}
calculateCenteredDataPoints(maxFunnelWidth, barHeight, spacerHeight) {
const total = this.data()[0]?.value ?? 1;
const maxValue = max(this.data(), d => d.value) ?? 1;
const scale = scaleLinear().domain([0, maxValue]).range([0, maxFunnelWidth]);
return this.data().map((d, i) => {
const point = {
...d,
y: i * (barHeight + spacerHeight),
width: scale(d.value),
percentage: total ? this.round((d.value / total) * 100) : 0,
};
if (i > 0) {
const prev = this.data()[i - 1].value;
point.delta = prev - d.value;
point.deltaPercentage = prev === 0 ? 0 : this.round((point.delta / prev) * 100);
}
return point;
});
}
calculateSpacerDataPoints(dataPoints, maxFunnelWidth, barHeight) {
const spacers = [];
for (let i = 0; i < dataPoints.length - 1; i++) {
const cur = dataPoints[i];
const next = dataPoints[i + 1];
spacers.push({
index: i,
topWidth: cur.width,
bottomWidth: next.width,
topY: cur.y + barHeight,
bottomY: next.y,
topX: (maxFunnelWidth - cur.width) / 2,
bottomX: (maxFunnelWidth - next.width) / 2,
deltaPercentage: next.deltaPercentage ?? 0,
});
}
return spacers;
}
renderCenteredLeftSideText(dataPoints, barHeight) {
const g = this.svg.append('g').attr('transform', `translate(${this.textSize()},0)`);
const ICON_SPACE = 20;
const positions = [];
dataPoints.forEach(d => {
const cy = d.y + barHeight / 2;
const iconSpacing = d.description ? ICON_SPACE : 0;
const textLen = g
.append('text')
.attr('x', -iconSpacing)
.attr('y', cy - 8)
.attr('text-anchor', 'end')
.style('font-size', '13px')
.style('font-weight', '600')
.style('fill', 'var(--cds-global-color-gray-900, #21333b)')
.style('cursor', 'pointer')
.text(d.label)
.on('click', () => this.valueClicked.emit({ value: d.value, label: d.label, key: d.key }))
.node()
.getComputedTextLength();
if (d.description) {
positions.push({ key: d.key, x: this.textSize() - iconSpacing + 5, y: cy - 24, description: d.description });
}
g.append('line')
.attr('x1', -textLen - iconSpacing)
.attr('y1', cy - 5)
.attr('x2', -iconSpacing)
.attr('y2', cy - 5)
.attr('stroke', 'var(--cds-global-color-construction-400, #666)')
.attr('stroke-width', 2)
.attr('stroke-dasharray', '2,2');
g.append('text')
.attr('x', 0)
.attr('y', cy + 10)
.attr('text-anchor', 'end')
.style('font-size', '12px')
.style('fill', 'var(--cds-global-color-construction-400, #666)')
.text(`${this.round(d.percentage)}% (${d.value})`);
});
this.sectionTooltips.set(positions);
}
renderCenteredLeftSideLines(dataPoints, maxFunnelWidth, barHeight) {
const g = this.svg.append('g').attr('transform', `translate(${this.textSize() + this.lineTextPadding()},0)`);
dataPoints.forEach(d => {
g.append('line')
.attr('x1', 0)
.attr('y1', d.y + barHeight / 2)
.attr('x2', maxFunnelWidth / 2 + this.minLineSize())
.attr('y2', d.y + barHeight / 2)
.attr('stroke', '#D9D9D9')
.attr('stroke-width', 1);
});
}
renderCenteredRightSideText(dataPoints, containerWidth, barHeight) {
const g = this.svg.append('g').attr('transform', `translate(${containerWidth - this.textSize()},0)`);
dataPoints.forEach((d, i) => {
if (i === 0) {
return;
}
const prev = dataPoints[i - 1];
const cy = (prev.y + barHeight + d.y) / 2 + 4;
g.append('text')
.attr('x', 0)
.attr('y', cy)
.attr('text-anchor', 'start')
.style('font-size', '12px')
.style('fill', 'var(--cds-global-color-construction-400, #666)')
.text(`-${this.round(d.deltaPercentage)}% (${d.delta})`);
});
}
renderCenteredRightSideLines(dataPoints, containerWidth, maxFunnelWidth, barHeight) {
const g = this.svg.append('g').attr('transform', `translate(${containerWidth / 2},0)`);
dataPoints.forEach((d, i) => {
if (i === 0) {
return;
}
const prev = dataPoints[i - 1];
const cy = (prev.y + barHeight + d.y) / 2;
g.append('line')
.attr('x1', 0)
.attr('y1', cy)
.attr('x2', maxFunnelWidth / 2 + this.minLineSize())
.attr('y2', cy)
.attr('stroke', '#D9D9D9')
.attr('stroke-width', 1);
});
}
renderCenteredMiddleLines(dataPoints, spacerPoints, containerWidth, barHeight) {
const vPad = this.middleLineVerticalPadding();
const g = this.svg.append('g').attr('transform', `translate(${containerWidth / 2},0)`);
g.selectAll('.bar-mid')
.data(dataPoints)
.enter()
.append('line')
.attr('x1', 0)
.attr('y1', d => d.y + vPad)
.attr('x2', 0)
.attr('y2', d => d.y + barHeight - vPad)
.attr('stroke', '#D9D9D9')
.attr('stroke-width', 1);
g.selectAll('.spacer-mid')
.data(spacerPoints)
.enter()
.append('line')
.attr('x1', 0)
.attr('y1', d => d.topY + vPad)
.attr('x2', 0)
.attr('y2', d => d.bottomY - vPad)
.attr('stroke', '#D9D9D9')
.attr('stroke-width', 1);
}
renderCenteredFunnel(dataPoints, spacerPoints, containerWidth, containerHeight, maxFunnelWidth, barHeight, spacerHeight) {
const g = this.svg
.attr('width', containerWidth)
.attr('height', containerHeight)
.append('g')
.attr('transform', `translate(${(containerWidth - maxFunnelWidth) / 2},0)`);
// ── Bars ──────────────────────────────────────────────────────────────────
const rawBarColor = this.barColor();
const cssBarColor = toChartColor(rawBarColor);
const hoverBarColor = rawBarColor.startsWith('--')
? cssBarColor
: (color(rawBarColor)?.darker(0.5)?.formatHex() ?? cssBarColor);
const bars = g
.selectAll('.c-bar')
.data(dataPoints)
.enter()
.append('g')
.attr('class', 'c-bar');
bars
.append('rect')
.attr('x', d => (maxFunnelWidth - d.width) / 2)
.attr('y', d => d.y)
.attr('width', d => d.width)
.attr('height', barHeight)
.style('fill', cssBarColor)
.style('cursor', 'pointer')
.on('mouseover', e => {
select(e.currentTarget).style('fill', hoverBarColor);
})
.on('mouseout', e => {
select(e.currentTarget).style('fill', cssBarColor);
})
.on('click', (event, d) => {
event.stopPropagation();
const rect = event.currentTarget.getBoundingClientRect();
const cont = this.chartRef().nativeElement.getBoundingClientRect();
this.tooltipPosition.set({ x: rect.left - cont.left + rect.width / 2, y: rect.top - cont.top });
this.selectedSpacerItem.set(undefined);
this.selectedItem.set(d);
});
bars
.selectAll('.c-bar-label')
.data(dataPoints.filter(d => d.width > 40))
.enter()
.append('text')
.attr('x', maxFunnelWidth / 2)
.attr('y', d => d.y + barHeight / 2)
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'middle')
.style('font-size', '13px')
.style('font-weight', '600')
.style('pointer-events', 'none')
.style('fill', '#eee')
.text(d => `${this.round(d.percentage)}%`);
// ── Spacers ───────────────────────────────────────────────────────────────
const rawSpacerColor = this.spacerColor();
const cssSpacerColor = toChartColor(rawSpacerColor);
const hoverSpacerColor = rawSpacerColor.startsWith('--')
? cssSpacerColor
: (color(rawSpacerColor)?.darker(0.2)?.formatHex() ?? cssSpacerColor);
g.selectAll('.c-spacer')
.data(spacerPoints)
.enter()
.append('polygon')
.attr('class', 'c-spacer')
.attr('points', d => `${d.topX},${d.topY} ${d.topX + d.topWidth},${d.topY} ${d.bottomX + d.bottomWidth},${d.bottomY} ${d.bottomX},${d.bottomY}`)
.style('fill', cssSpacerColor)
.style('cursor', 'pointer')
.on('mouseover', e => {
select(e.currentTarget).style('fill', hoverSpacerColor);
})
.on('mouseout', e => {
select(e.currentTarget).style('fill', cssSpacerColor);
})
.on('click', (event, d) => {
event.stopPropagation();
const rect = event.currentTarget.getBoundingClientRect();
const cont = this.chartRef().nativeElement.getBoundingClientRect();
this.tooltipPosition.set({ x: rect.left - cont.left + rect.width / 2, y: rect.top - cont.top });
this.selectedItem.set(undefined);
this.selectedSpacerItem.set(d);
});
g.selectAll('.c-spacer-label')
.data(spacerPoints.filter(d => d.bottomWidth > 40))
.enter()
.append('text')
.attr('x', maxFunnelWidth / 2)
.attr('y', d => d.topY + spacerHeight / 2)
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'middle')
.style('font-size', '13px')
.style('font-weight', '600')
.style('pointer-events', 'none')
.style('fill', '#333')
.text(d => `-${this.round(d.deltaPercentage)}%`);
}
// ── Helpers ───────────────────────────────────────────────────────────────────
getMaxFunnelWidth(containerWidth) {
return containerWidth - this.textSize() * 2 - this.minLineSize() * 2 - this.lineTextPadding() * 2;
}
getCenteredFractionHeight(containerHeight) {
return containerHeight / (this.data().length * (this.barToSpacerRatio() + 1) - 1);
}
getCenteredBarHeight(containerHeight) {
return this.getCenteredFractionHeight(containerHeight) * this.barToSpacerRatio();
}
getCenteredSpacerHeight(containerHeight) {
return this.getCenteredFractionHeight(containerHeight);
}
getFunnelWidth(containerWidth) {
return containerWidth - this.textSize() * 2 - this.lineTextPadding() * 2;
}
getBarHeight(containerHeight) {
const n = this.data().length;
if (n === 0) {
return 0;
}
return (containerHeight - (n - 1) * this.barGap()) / n;
}
round(value, decimals = 2) {
const factor = 10 ** decimals;
return Math.round(factor * value) / factor;
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: FunnelChartComponent, deps: null, target: i0.ɵɵFactoryTarget.Component }); }
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.18", type: FunnelChartComponent, isStandalone: false, selector: "clr-funnel-chart", inputs: { data: { classPropertyName: "data", publicName: "data", isSignal: true, isRequired: false, transformFunction: null }, showExportButton: { classPropertyName: "showExportButton", publicName: "showExportButton", isSignal: true, isRequired: false, transformFunction: null }, exportButtonTitle: { classPropertyName: "exportButtonTitle", publicName: "exportButtonTitle", isSignal: true, isRequired: false, transformFunction: null }, exportFilename: { classPropertyName: "exportFilename", publicName: "exportFilename", isSignal: true, isRequired: false, transformFunction: null }, orientation: { classPropertyName: "orientation", publicName: "orientation", isSignal: true, isRequired: false, transformFunction: null }, textSize: { classPropertyName: "textSize", publicName: "textSize", isSignal: true, isRequired: false, transformFunction: null }, lineTextPadding: { classPropertyName: "lineTextPadding", publicName: "lineTextPadding", isSignal: true, isRequired: false, transformFunction: null }, barGap: { classPropertyName: "barGap", publicName: "barGap", isSignal: true, isRequired: false, transformFunction: null }, sideLineVerticalPadding: { classPropertyName: "sideLineVerticalPadding", publicName: "sideLineVerticalPadding", isSignal: true, isRequired: false, transformFunction: null }, minLineSize: { classPropertyName: "minLineSize", publicName: "minLineSize", isSignal: true, isRequired: false, transformFunction: null }, barToSpacerRatio: { classPropertyName: "barToSpacerRatio", publicName: "barToSpacerRatio", isSignal: true, isRequired: false, transformFunction: null }, middleLineVerticalPadding: { classPropertyName: "middleLineVerticalPadding", publicName: "middleLineVerticalPadding", isSignal: true, isRequired: false, transformFunction: null }, barColor: { classPropertyName: "barColor", publicName: "barColor", isSignal: true, isRequired: false, transformFunction: null }, spacerColor: { classPropertyName: "spacerColor", publicName: "spacerColor", isSignal: true, isRequired: false, transformFunction: null }, chartLabels: { classPropertyName: "chartLabels", publicName: "chartLabels", isSignal: true, isRequired: false, transformFunction: null }, sectionColors: { classPropertyName: "sectionColors", publicName: "sectionColors", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { valueClicked: "valueClicked" }, usesInheritance: true, usesOnChanges: true, ngImport: i0, template: "<div class=\"funnel-container\" #container (cngWindowResize)=\"updateChart()\">\n <svg [class.d-none]=\"loading() || !hasData()\" #chart></svg>\n\n @if (!loading()) { @for (pos of sectionTooltips(); track pos.key) {\n <clr-signpost [style.left.px]=\"pos.x\" [style.top.px]=\"pos.y\">\n <cds-icon clrSignpostTrigger shape=\"info-circle\" size=\"18\" />\n <clr-signpost-content clrPosition=\"bottom-left\">\n <p class=\"mt-0\">{{ pos.description }}</p>\n </clr-signpost-content>\n </clr-signpost>\n } } @if (loading()) {\n <cng-bar-chart-skeleton orientation=\"vertical\" skeletonType=\"loading\" />\n } @if (tooltipPosition()) { @if (orientation() === 'centered') {\n\n <!-- \u2500\u2500 Centered mode tooltip \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <cng-chart-tooltip\n [tooltipPosition]=\"tooltipPosition()\"\n tooltipOrientation=\"bottom\"\n [squareColor]=\"selectedSpacerItem() ? centeredSpacerCssColor() : centeredBarCssColor()\"\n [tooltipClickable]=\"!selectedSpacerItem()\"\n (tooltipClosed)=\"resetTooltip()\"\n (cngOutsideClick)=\"resetTooltip()\"\n (tooltipHeaderClicked)=\"\n selectedItem() && valueClicked.emit({\n value: selectedItem().value,\n label: selectedItem().label,\n key: selectedItem().key,\n section: undefined,\n })\n \"\n >\n <ng-container ngProjectAs=\"cng-title\">\n @if (selectedSpacerItem()) {\n {{ data()[selectedSpacerItem().index].label }} \u2192 {{ data()[selectedSpacerItem().index + 1].label }} } @else { ({{\n selectedItem().value\n }}) {{ selectedItem().label }}\n }\n </ng-container>\n\n @if (selectedSpacerItem()) {\n <p class=\"mt-0\">\n <strong\n >{{ data()[selectedSpacerItem().index].value }} \u2192 {{ data()[selectedSpacerItem().index + 1].value }}</strong\n >\n </p>\n @for (section of (data()[selectedSpacerItem().index].sections ?? []); track section.key) { @let drillable =\n section.value > 0;\n <p class=\"tooltip-row mt-0 mt-0-5 d-flex\">\n <strong>{{ section.label }}: </strong>\n <span\n class=\"row-right\"\n [class.has-more-info]=\"drillable\"\n (click)=\"\n drillable && valueClicked.emit({\n value: section.value,\n label: data()[selectedSpacerItem().index].label + ': ' + section.label,\n key: data()[selectedSpacerItem().index].key,\n section: section.key,\n })\n \"\n >{{ section.value }}</span\n >\n ({{ pct(section.value, data()[selectedSpacerItem().index].value) }}%)\n </p>\n } } @else {\n <p class=\"mt-0\">{{ pct(selectedItem().value, total()) }}% {{ resolvedChartLabels().all }}</p>\n }\n </cng-chart-tooltip>\n\n } @else {\n\n <!-- \u2500\u2500 Default mode tooltip \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <cng-chart-tooltip\n [tooltipPosition]=\"tooltipPosition()\"\n tooltipOrientation=\"bottom\"\n [squareColor]=\"undefined\"\n [tooltipClickable]=\"false\"\n (tooltipClosed)=\"resetTooltip()\"\n (cngOutsideClick)=\"resetTooltip()\"\n >\n <ng-container ngProjectAs=\"cng-title\">{{ selectedItemLabel() }}</ng-container>\n @for (section of selectedItem().sections; track section.key) { @let drillable = section.value > 0;\n <p class=\"tooltip-row mt-0 mt-0-5 d-flex\">\n <span>\n <div class=\"color-square mr-0-5\" [style.background-color]=\"section.cssColor\"></div>\n </span>\n <strong>{{ section.label }}: </strong>\n <span\n class=\"row-right\"\n [class.has-more-info]=\"drillable\"\n (click)=\"\n drillable && valueClicked.emit({\n value: section.value,\n label: selectedItemLabel() + ': ' + section.label,\n key: selectedItem().key,\n section: section.key,\n })\n \"\n >{{ section.value }}</span\n >\n ({{ section.percentage }}%)\n </p>\n }\n\n <p class=\"tooltip-row mt-0-5\">\n <strong>{{ resolvedChartLabels().total }}: </strong>\n <span\n class=\"has-more-info\"\n (click)=\"\n valueClicked.emit({\n value: selectedItem().value,\n label: selectedItemLabel(),\n key: selectedItem().key,\n section: undefined\n })\n \"\n >{{ selectedItem().value }}</span\n >\n </p>\n\n <ng-container ngProjectAs=\"cng-footer\">\n <p class=\"tooltip-row mt-0\">\n <strong>{{ resolvedChartLabels().all }}: </strong>\n <span class=\"row-right\">{{ total() }}</span>\n </p>\n </ng-container>\n </cng-chart-tooltip>\n\n } } @if (showExportButton() && !loading() && hasData()) {\n <cng-chart-export-button\n class=\"funnel-export-btn\"\n [svgRef]=\"svgElement()\"\n [filename]=\"exportFilename()\"\n [buttonTitle]=\"exportButtonTitle()\"\n />\n }\n</div>\n", styles: [":host{display:block;width:100%;height:100%}.funnel-container{height:100%;width:100%;max-width:1400px;margin:auto;position:relative}svg{width:100%;height:100%}clr-signpost{position:absolute!important}.tooltip-row{display:flex;justify-content:space-between;align-items:center}.row-right{margin-left:auto}.funnel-export-btn{position:absolute;bottom:0;right:0;opacity:0;pointer-events:none;transition:opacity .2s ease}.funnel-container:hover .funnel-export-btn{opacity:1;pointer-events:auto}\n"], dependencies: [{ kind: "component", type: i1.ClrIcon, selector: "clr-icon, cds-icon", inputs: ["shape", "size", "direction", "flip", "solid", "status", "inverse", "badge"] }, { kind: "component", type: i1.ClrSignpost, selector: "clr-signpost", inputs: ["clrSignpostTriggerAriaLabel", "clrSignpostHideTrigger"] }, { kind: "component", type: i1.ClrSignpostContent, selector: "clr-signpost-content", inputs: ["clrSignpostCloseAriaLabel", "clrPosition"] }, { kind: "directive", type: i1.ClrSignpostTrigger, selector: "[clrSignpostTrigger]" }, { kind: "component", type: ChartExportButtonComponent, selector: "cng-chart-export-button", inputs: ["svgRef", "filename", "buttonTitle", "legendItems"] }, { kind: "component", type: ChartSkeletonComponent, selector: "cng-bar-chart-skeleton", inputs: ["skeletonType", "orientation"] }, { kind: "component", type: ChartTooltipComponent, selector: "cng-chart-tooltip", inputs: ["tooltipPosition", "tooltipOrientation", "squareColor", "tooltipClickable"], outputs: ["tooltipClosed", "tooltipHeaderClicked"] }, { kind: "directive", type: OutsideClickDirective, selector: "[cngOutsideClick]", outputs: ["cngOutsideClick"] }, { kind: "directive", type: WindowResizeDirective, selector: "[cngWindowResize]", inputs: ["debounce", "includeFirst"], outputs: ["cngWindowResize"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: FunnelChartComponent, decorators: [{
type: Component,
args: [{ selector: 'clr-funnel-chart', changeDetection: ChangeDetectionStrategy.OnPush, standalone: false, template: "<div class=\"funnel-container\" #container (cngWindowResize)=\"updateChart()\">\n <svg [class.d-none]=\"loading() || !hasData()\" #chart></svg>\n\n @if (!loading()) { @for (pos of sectionTooltips(); track pos.key) {\n <clr-signpost [style.left.px]=\"pos.x\" [style.top.px]=\"pos.y\">\n <cds-icon clrSignpostTrigger shape=\"info-circle\" size=\"18\" />\n <clr-signpost-content clrPosition=\"bottom-left\">\n <p class=\"mt-0\">{{ pos.description }}</p>\n </clr-signpost-content>\n </clr-signpost>\n } } @if (loading()) {\n <cng-bar-chart-skeleton orientation=\"vertical\" skeletonType=\"loading\" />\n } @if (tooltipPosition()) { @if (orientation() === 'centered') {\n\n <!-- \u2500\u2500 Centered mode tooltip \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <cng-chart-tooltip\n [tooltipPosition]=\"tooltipPosition()\"\n tooltipOrientation=\"bottom\"\n [squareColor]=\"selectedSpacerItem() ? centeredSpacerCssColor() : centeredBarCssColor()\"\n [tooltipClickable]=\"!selectedSpacerItem()\"\n (tooltipClosed)=\"resetTooltip()\"\n (cngOutsideClick)=\"resetTooltip()\"\n (tooltipHeaderClicked)=\"\n selectedItem() && valueClicked.emit({\n value: selectedItem().value,\n label: selectedItem().label,\n key: selectedItem().key,\n section: undefined,\n })\n \"\n >\n <ng-container ngProjectAs=\"cng-title\">\n @if (selectedSpacerItem()) {\n {{ data()[selectedSpacerItem().index].label }} \u2192 {{ data()[selectedSpacerItem().index + 1].label }} } @else { ({{\n selectedItem().value\n }}) {{ selectedItem().label }}\n }\n </ng-container>\n\n @if (selectedSpacerItem()) {\n <p class=\"mt-0\">\n <strong\n >{{ data()[selectedSpacerItem().index].value }} \u2192 {{ data()[selectedSpacerItem().index + 1].value }}</strong\n >\n </p>\n @for (section of (data()[selectedSpacerItem().index].sections ?? []); track section.key) { @let drillable =\n section.value > 0;\n <p class=\"tooltip-row mt-0 mt-0-5 d-flex\">\n <strong>{{ section.label }}: </strong>\n <span\n class=\"row-right\"\n [class.has-more-info]=\"drillable\"\n (click)=\"\n drillable && valueClicked.emit({\n value: section.value,\n label: data()[selectedSpacerItem().index].label + ': ' + section.label,\n key: data()[selectedSpacerItem().index].key,\n section: section.key,\n })\n \"\n >{{ section.value }}</span\n >\n ({{ pct(section.value, data()[selectedSpacerItem().index].value) }}%)\n </p>\n } } @else {\n <p class=\"mt-0\">{{ pct(selectedItem().value, total()) }}% {{ resolvedChartLabels().all }}</p>\n }\n </cng-chart-tooltip>\n\n } @else {\n\n <!-- \u2500\u2500 Default mode tooltip \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <cng-chart-tooltip\n [tooltipPosition]=\"tooltipPosition()\"\n tooltipOrientation=\"bottom\"\n [squareColor]=\"undefined\"\n [tooltipClickable]=\"false\"\n (tooltipClosed)=\"resetTooltip()\"\n (cngOutsideClick)=\"resetTooltip()\"\n >\n <ng-container ngProjectAs=\"cng-title\">{{ selectedItemLabel() }}</ng-container>\n @for (section of selectedItem().sections; track section.key) { @let drillable = section.value > 0;\n <p class=\"tooltip-row mt-0 mt-0-5 d-flex\">\n <span>\n <div class=\"color-square mr-0-5\" [style.background-color]=\"section.cssColor\"></div>\n </span>\n <strong>{{ section.label }}: </strong>\n <span\n class=\"row-right\"\n [class.has-more-info]=\"drillable\"\n (click)=\"\n drillable && valueClicked.emit({\n value: section.value,\n label: selectedItemLabel() + ': ' + section.label,\n key: selectedItem().key,\n section: section.key,\n })\n \"\n >{{ section.value }}</span\n >\n ({{ section.percentage }}%)\n </p>\n }\n\n <p class=\"tooltip-row mt-0-5\">\n <strong>{{ resolvedChartLabels().total }}: </strong>\n <span\n class=\"has-more-info\"\n (click)=\"\n valueClicked.emit({\n value: selectedItem().value,\n label: selectedItemLabel(),\n key: selectedItem().key,\n section: undefined\n })\n \"\n >{{ selectedItem().value }}</span\n >\n </p>\n\n <ng-container ngProjectAs=\"cng-footer\">\n <p class=\"tooltip-row mt-0\">\n <strong>{{ resolvedChartLabels().all }}: </strong>\n <span class=\"row-right\">{{ total() }}</span>\n </p>\n </ng-container>\n </cng-chart-tooltip>\n\n } } @if (showExportButton() && !loading() && hasData()) {\n <cng-chart-export-button\n class=\"funnel-export-btn\"\n [svgRef]=\"svgElement()\"\n [filename]=\"exportFilename()\"\n [buttonTitle]=\"exportButtonTitle()\"\n />\n }\n</div>\n", styles: [":host{display:block;width:100%;height:100%}.funnel-container{height:100%;width:100%;max-width:1400px;margin:auto;position:relative}svg{width:100%;height:100%}clr-signpost{position:absolute!important}.tooltip-row{display:flex;justify-content:space-between;align-items:center}.row-right{margin-left:auto}.funnel-export-btn{position:absolute;bottom:0;right:0;opacity:0;pointer-events:none;transition:opacity .2s ease}.funnel-container:hover .funnel-export-btn{opacity:1;pointer-events:auto}\n"] }]
}], propDecorators: { data: [{ type: i0.Input, args: [{ isSignal: true, alias: "data", required: false }] }], showExportButton: [{ type: i0.Input, args: [{ isSignal: true, alias: "showExportButton", required: false }] }], exportButtonTitle: [{ type: i0.Input, args: [{ isSignal: true, alias: "exportButtonTitle", required: false }] }], exportFilename: [{ type: i0.Input, args: [{ isSignal: true, alias: "exportFilename", required: false }] }], orientation: [{ type: i0.Input, args: [{ isSignal: true, alias: "orientation", required: false }] }], textSize: [{ type: i0.Input, args: [{ isSignal: true, alias: "textSize", required: false }] }], lineTextPadding: [{ type: i0.Input, args: [{ isSignal: true, alias: "lineTextPadding", required: false }] }], barGap: [{ type: i0.Input, args: [{ isSignal: true, alias: "barGap", required: false }] }], sideLineVerticalPadding: [{ type: i0.Input, args: [{ isSignal: true, alias: "sideLineVerticalPadding", required: false }] }], minLineSize: [{ type: i0.Input, args: [{ isSignal: true, alias: "minLineSize", required: false }] }], barToSpacerRatio: [{ type: i0.Input, args: [{ isSignal: true, alias: "barToSpacerRatio", required: false }] }], middleLineVerticalPadding: [{ type: i0.Input, args: [{ isSignal: true, alias: "middleLineVerticalPadding", required: false }] }], barColor: [{ type: i0.Input, args: [{ isSignal: true, alias: "barColor", required: false }] }], spacerColor: [{ type: i0.Input, args: [{ isSignal: true, alias: "spacerColor", required: false }] }], chartLabels: [{ type: i0.Input, args: [{ isSignal: true, alias: "chartLabels", required: false }] }], sectionColors: [{ type: i0.Input, args: [{ isSignal: true, alias: "sectionColors", required: false }] }], valueClicked: [{ type: i0.Output, args: ["valueClicked"] }] } });
const PREFERRED_ORDER = ['top-right', 'top-left', 'bottom-right', 'bottom-left'];
class AutoPositionDirective {
constructor() {
this.signpostContent = inject(ClrSignpostContent, { optional: true });
this.elementRef = inject(ElementRef);
this.handleMouseDown = () => {
// Only calculate when signpost is closed (about to open)
if (this.elementRef.nativeElement.classList.contains('is-off-screen')) {
this.updatePosition();
}
};
}
ngOnInit() {
const signpost = this.elementRef.nativeElement.closest('clr-signpost');
this.trigger = signpost?.querySelector('[clrSignpostTrigger]');
if (this.trigger) {
this.trigger.addEventListener('mousedown', this.handleMouseDown);
}
this.resizeObserver = new ResizeObserver(() => {
if (!this.elementRef.nativeElement.classList.contains('is-off-screen')) {
this.updatePosition();
}
});
this.resizeObserver.observe(document.body);
}
updatePosition() {
if (!this.signpostContent || !this.trigger) {
return;
}
const triggerRect = this.trigger.getBoundingClientRect();
this.signpostContent.position = this.getBestPosition(triggerRect);
}
getBestPosition(triggerRect) {
const contentWidth = 300;
const contentHeight = 200;
const buffer = 50;
const contentHeaderHeight = document.querySelector('.content-header')?.getBoundingClientRect().height ?? 0;
const appHeaderHight = document.querySelector('.header')?.getBoundingClientRect().height ?? 0;
const headerHeight = contentHeaderHeight + appHeaderHight;
// Find first position with enough space
for (const pos of PREFERRED_ORDER) {
if (this.hasSpace(triggerRect, pos, contentWidth, contentHeight, headerHeight, buffer)) {
return pos;
}
}
return PREFERRED_ORDER[0]; // Fallback to first preference
}
hasSpace(rect, position, width, height, headerHeight, buffer) {
const [vertical, horizontal] = position.split('-');
const verticalSpace = vertical === 'top'
? rect.top - headerHeight - height - buffer
: window.innerHeight - rect.bottom - height - buffer;
const horizontalSpace = horizontal === 'left' ? rect.left - width - buffer : window.innerWidth - rect.right - width - buffer;
return Math.min(verticalSpace, horizontalSpace) > 0;
}
ngOnDestroy() {
if (this.trigger) {
this.trigger.removeEventListener('mousedown', this.handleMouseDown);
}
this.resizeObserver?.disconnect();
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: AutoPositionDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.2.18", type: AutoPositionDirective, isStandalone: true, selector: "clr-signpost-content[autoPosition]", ngImport: i0 }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: AutoPositionDirective, decorators: [{
type: Directive,
args: [{
selector: 'clr-signpost-content[autoPosition]',
standalone: true,
}]
}] });
class TenantFullDateRangeDirective {
constructor() {
this.start = contentChild(ClrStartDateInput, ...(ngDevMode ? [{ debugName: "start" }] : /* istanbul ignore next */ []));
this.end = contentChild(ClrEndDateInput, ...(ngDevMode ? [{ debugName: "end" }] : /* istanbul ignore next */ []));
}
ngAfterContentInit() {
if (this.start()) {
const originalStartEmit = this.start().dateChange.emit.bind(this.start().dateChange);
this.start().dateChange.emit = date => {
originalStartEmit(this.toTenantDateTime(date, '00:00'));
};
}
if (this.end()) {
const originalEndEmit = this.end().dateChange.emit.bind(this.end().dateChange);
this.end().dateChange.emit = date => {
originalEndEmit(this.toTenantDateTime(date, '23:59'));
};
}
}
toTenantDateTime(date, time) {
if (!date) {
return date;
}
const [hours, minutes] = time.split(':').map(Number);
const result = new Date(date);
result.setHours(hours, minutes, 0, 0);
return result;
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: TenantFullDateRangeDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.2.0", version: "21.2.18", type: TenantFullDateRangeDirective, isStandalone: true, selector: "clr-date-range-container[cngTenantFullDateRange]", queries: [{ propertyName: "start", first: true, predicate: ClrStartDateInput, descendants: true, isSignal: true }, { propertyName: "end", first: true, predicate: ClrEndDateInput, descendants: true, isSignal: true }], ngImport: i0 }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: TenantFullDateRangeDirective, decorators: [{
type: Directive,
args: [{
selector: 'clr-date-range-container[cngTenantFullDateRange]',
standalone: true,
}]
}], propDecorators: { start: [{ type: i0.ContentChild, args: [i0.forwardRef(() => ClrStartDateInput), { isSignal: true }] }], end: [{ type: i0.ContentChild, args: [i0.forwardRef(() => ClrEndDateInput), { isSignal: true }] }] } });
/*
* Copyright (c) 2026 Porsche Informatik. All Rights Reserved.
* This software is released under MIT license.
* The full license information can be found in LICENSE in the root directory of this project.
*/
const CLR_CHARTS_DECLARATIONS = [
AreaChartComponent,
BarChartComponent,
ComboChartComponent,
FunnelChartComponent,
LineChartComponent,
PieChartComponent,
];
class ClrChartsModule {
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: ClrChartsModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule }); }
static { this.ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "21.2.18", ngImport: i0, type: ClrChartsModule, declarations: [AreaChartComponent,
BarChartComponent,
ComboChartComponent,
FunnelChartComponent,
LineChartComponent,
PieChartComponent], imports: [CommonModule,
DecimalPipe,
ClrAlertModule,
ClrIcon,
ClrSignpostModule,
// standalone helpers consumed by the non-standalone chart components
ChartAlertOverlayComponent,
ChartExportButtonComponent,
ChartLegendComponent,
ChartSkeletonComponent,
ChartTooltipComponent,
OutsideClickDirective,
WindowResizeDirective], exports: [AreaChartComponent,
BarChartComponent,
ComboChartComponent,
FunnelChartComponent,
LineChartComponent,
PieChartComponent] }); }
static { this.ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: ClrChartsModule, imports: [CommonModule,
ClrAlertModule,
ClrIcon,
ClrSignpostModule,
// standalone helpers consumed by the non-standalone chart components
ChartAlertOverlayComponent,
ChartExportButtonComponent,
ChartSkeletonComponent,
ChartTooltipComponent] }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: ClrChartsModule, decorators: [{
type: NgModule,
args: [{
imports: [
CommonModule,
DecimalPipe,
ClrAlertModule,
ClrIcon,
ClrSignpostModule,
// standalone helpers consumed by the non-standalone chart components
ChartAlertOverlayComponent,
ChartExportButtonComponent,
ChartLegendComponent,
ChartSkeletonComponent,
ChartTooltipComponent,
OutsideClickDirective,
WindowResizeDirective,
],
declarations: [...CLR_CHARTS_DECLARATIONS],
exports: [...CLR_CHARTS_DECLARATIONS],
}]
}] });
/*
* Copyright (c) 2026 Porsche Informatik. All Rights Reserved.
* This software is released under MIT license.
* The full license information can be found in LICENSE in the root directory of this project.
*/
/**
* Generated bundle index. Do not edit.
*/
export { AreaChartComponent, BarChartComponent, ClrChartsModule, ComboChartComponent, FunnelChartComponent, LineChartComponent, PieChartComponent };
//# sourceMappingURL=porscheinformatik-clr-addons-charts.mjs.map