@porscheinformatik/clr-addons 19.17.0 → 19.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2507 @@
1
+ import * as i0 from '@angular/core';
2
+ import { input, viewChild, computed, signal, Directive, ChangeDetectionStrategy, Component, Injectable, inject, output, Renderer2, DestroyRef, Inject, ElementRef, contentChild, NgModule } from '@angular/core';
3
+ import { format, select, scaleBand, scaleLinear, max, axisBottom, axisLeft, group, sum, scalePoint, line, curveMonotoneX, area, axisRight, arc, pie, color } from 'd3';
4
+ import * as i1 from '@clr/angular';
5
+ import { ClrAlertModule, ClrDropdownModule, ClrIconModule, ClrSignpostContent, ClrStartDateInput, ClrEndDateInput, ClrSignpostModule } from '@clr/angular';
6
+ import { ClarityIcons, downloadIcon } from '@cds/core/icon';
7
+ import { NgxSkeletonLoaderComponent } from 'ngx-skeleton-loader';
8
+ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
9
+ import { ReplaySubject, skip } from 'rxjs';
10
+ import { map, distinctUntilChanged, debounceTime } from 'rxjs/operators';
11
+ import * as i8 from '@angular/common';
12
+ import { DOCUMENT, CommonModule, DecimalPipe } from '@angular/common';
13
+
14
+ /*
15
+ * Copyright (c) 2018-2026 Porsche Informatik. All Rights Reserved.
16
+ * This software is released under MIT license.
17
+ * The full license information can be found in LICENSE in the root directory of this project.
18
+ */
19
+
20
+ const TOO_MANY_ITEMS_MESSAGE = 'too many items';
21
+ const TOO_MANY_ITEMS_GROUPED_MESSAGE = 'too many items';
22
+ const TOO_MANY_ITEMS_ALERT_TYPE = 'warning';
23
+ const NO_ITEMS_MESSAGE = 'no items';
24
+ const NO_ITEMS_ALERT_TYPE = 'info';
25
+
26
+ /**
27
+ * Converts a chart color value to a CSS-compatible string.
28
+ *
29
+ * Supports:
30
+ * - Hex values: '#e57200' → returned as-is
31
+ * - CSS custom props: '--cds-global-color-lavender-1000' → wrapped in var(...)
32
+ * - Any other string: 'rgb(...)' / 'hsl(...)' → returned as-is
33
+ */
34
+ function toChartColor(color) {
35
+ if (!color) {
36
+ return '';
37
+ }
38
+ return color.startsWith('--') ? `var(${color})` : color;
39
+ }
40
+
41
+ class TextRenderer {
42
+ constructor() {
43
+ this.cachedFont = '';
44
+ this.cachedEllipsisWidth = 0;
45
+ this.canvas = document.createElement('canvas');
46
+ const ctx = this.canvas.getContext('2d');
47
+ if (!ctx) {
48
+ throw new Error('2D context error');
49
+ }
50
+ this.context = ctx;
51
+ }
52
+ render(text, availableHeight, availableWidth, fontSize = '12px', fontFamily = 'Arial') {
53
+ const font = `${fontSize} ${fontFamily}`;
54
+ if (this.cachedFont !== font) {
55
+ this.context.font = font;
56
+ this.cachedEllipsisWidth = this.context.measureText('...').width;
57
+ this.cachedFont = font;
58
+ }
59
+ else {
60
+ this.context.font = font;
61
+ }
62
+ const textMetrics = this.context.measureText(text);
63
+ const textWidth = textMetrics.width;
64
+ const textHeight = Math.abs(textMetrics.actualBoundingBoxAscent) + Math.abs(textMetrics.actualBoundingBoxDescent);
65
+ if (textWidth <= availableWidth && textHeight <= availableHeight) {
66
+ return text;
67
+ }
68
+ let remainingWidth = availableWidth - this.cachedEllipsisWidth;
69
+ let truncatedText = '';
70
+ for (const char of text) {
71
+ const charWidth = this.context.measureText(char).width;
72
+ if (remainingWidth - charWidth >= 0) {
73
+ truncatedText += char;
74
+ remainingWidth -= charWidth;
75
+ }
76
+ else {
77
+ break;
78
+ }
79
+ }
80
+ truncatedText = truncatedText.trim();
81
+ if (truncatedText.length === 0) {
82
+ return '';
83
+ }
84
+ return truncatedText + '...';
85
+ }
86
+ }
87
+
88
+ const percentage = (value, total) => {
89
+ if (total <= 0) {
90
+ return 0;
91
+ }
92
+ return Math.min(100, Math.max(0, Math.round((value / total) * 100)));
93
+ };
94
+ const d3percentFormat = (f => (d) => `${f(d)}%`)(format('.1f'));
95
+
96
+ /*
97
+ * Copyright (c) 2018-2026 Porsche Informatik. All Rights Reserved.
98
+ * This software is released under MIT license.
99
+ * The full license information can be found in LICENSE in the root directory of this project.
100
+ */
101
+ /**
102
+ * Abstract base class shared by all chart components.
103
+ * ...
104
+ */
105
+ class ChartBase {
106
+ constructor() {
107
+ // ── Common Input ───────────────────────────────────────────────────────────
108
+ /** Whether the chart is in loading state (shows skeleton). */
109
+ this.loading = input(false);
110
+ // ── View references ────────────────────────────────────────────────────────
111
+ /** Reference to the `<svg #chart>` element in the component template. */
112
+ this.chartRef = viewChild('chart');
113
+ /** Reference to the `<div #container>` host element in the component template. */
114
+ this.containerRef = viewChild('container');
115
+ /** Exposes the raw SVG element for the chart-export button. */
116
+ this.svgElement = computed(() => this.chartRef()?.nativeElement);
117
+ // ── Tooltip State ──────────────────────────────────────────────────────────
118
+ this.selectedItem = signal(undefined);
119
+ this.tooltipPosition = signal(undefined);
120
+ }
121
+ // ── Lifecycle ──────────────────────────────────────────────────────────────
122
+ /** Schedules the first render after the view is ready. */
123
+ ngAfterViewInit() {
124
+ requestAnimationFrame(() => this.updateChart());
125
+ }
126
+ // ── Common Implementations ─────────────────────────────────────────────────
127
+ /** Returns the pixel dimensions of the container div. */
128
+ getContainerDimensions() {
129
+ const el = this.containerRef().nativeElement;
130
+ return { width: el.clientWidth, height: el.clientHeight };
131
+ }
132
+ /**
133
+ * Clears the active tooltip. Override in subclasses that have additional
134
+ * selection state
135
+ */
136
+ resetTooltip() {
137
+ if (this.selectedItem() || this.tooltipPosition()) {
138
+ this.selectedItem.set(undefined);
139
+ this.tooltipPosition.set(undefined);
140
+ }
141
+ }
142
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: ChartBase, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
143
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.2.0", version: "19.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 }); }
144
+ }
145
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: ChartBase, decorators: [{
146
+ type: Directive
147
+ }] });
148
+
149
+ class ChartAlertOverlayComponent {
150
+ constructor() {
151
+ this.alertMessage = input.required();
152
+ this.alertType = input('info');
153
+ }
154
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: ChartAlertOverlayComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
155
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "19.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: `
156
+ <clr-alert [clrAlertSizeSmall]="true" [clrAlertClosable]="false" [clrAlertType]="alertType()">
157
+ <clr-alert-item>
158
+ <span class="alert-text">
159
+ {{ alertMessage() }}
160
+ </span>
161
+ </clr-alert-item>
162
+ </clr-alert>
163
+ <div class="overlay"></div>
164
+ `, isInline: true, styles: [":host{position:absolute;top:0;left:0;right:0;pointer-events:none;z-index:1;clr-alert{pointer-events:auto;::ng-deep .alert{margin-bottom:0}}.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 }); }
165
+ }
166
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: ChartAlertOverlayComponent, decorators: [{
167
+ type: Component,
168
+ args: [{ selector: 'cng-chart-alert-overlay', template: `
169
+ <clr-alert [clrAlertSizeSmall]="true" [clrAlertClosable]="false" [clrAlertType]="alertType()">
170
+ <clr-alert-item>
171
+ <span class="alert-text">
172
+ {{ alertMessage() }}
173
+ </span>
174
+ </clr-alert-item>
175
+ </clr-alert>
176
+ <div class="overlay"></div>
177
+ `, changeDetection: ChangeDetectionStrategy.OnPush, imports: [ClrAlertModule], styles: [":host{position:absolute;top:0;left:0;right:0;pointer-events:none;z-index:1;clr-alert{pointer-events:auto;::ng-deep .alert{margin-bottom:0}}.overlay{height:2rem;background:linear-gradient(to bottom,#fff,#fff0)}}\n"] }]
178
+ }] });
179
+
180
+ class ChartExportService {
181
+ exportSvg(svgEl, filename) {
182
+ const clone = this.cloneWithDimensions(svgEl);
183
+ const svgStr = new XMLSerializer().serializeToString(clone);
184
+ this.download(new Blob([svgStr], { type: 'image/svg+xml;charset=utf-8' }), `${filename}.svg`);
185
+ }
186
+ exportPng(svgEl, filename) {
187
+ this.toCanvas(svgEl).then(canvas => canvas.toBlob(blob => this.download(blob, `${filename}.png`), 'image/png'));
188
+ }
189
+ toCanvas(svgEl) {
190
+ return new Promise(resolve => {
191
+ const w = svgEl.clientWidth || Number(svgEl.getAttribute('width')) || 800;
192
+ const h = svgEl.clientHeight || Number(svgEl.getAttribute('height')) || 600;
193
+ const clone = this.cloneWithDimensions(svgEl, w, h);
194
+ const url = URL.createObjectURL(new Blob([new XMLSerializer().serializeToString(clone)], { type: 'image/svg+xml;charset=utf-8' }));
195
+ const img = new Image();
196
+ img.onload = () => {
197
+ const scale = 2; // 2× for retina quality
198
+ const canvas = document.createElement('canvas');
199
+ canvas.width = w * scale;
200
+ canvas.height = h * scale;
201
+ const ctx = canvas.getContext('2d');
202
+ ctx.scale(scale, scale);
203
+ ctx.fillStyle = '#ffffff';
204
+ ctx.fillRect(0, 0, w, h);
205
+ ctx.drawImage(img, 0, 0, w, h);
206
+ URL.revokeObjectURL(url);
207
+ resolve(canvas);
208
+ };
209
+ img.src = url;
210
+ });
211
+ }
212
+ cloneWithDimensions(svgEl, w, h) {
213
+ const clone = svgEl.cloneNode(true);
214
+ const width = w ?? svgEl.clientWidth ?? Number(svgEl.getAttribute('width')) ?? 800;
215
+ const height = h ?? svgEl.clientHeight ?? Number(svgEl.getAttribute('height')) ?? 600;
216
+ clone.setAttribute('width', String(width));
217
+ clone.setAttribute('height', String(height));
218
+ clone.style.background = '#ffffff';
219
+ return clone;
220
+ }
221
+ download(blob, filename) {
222
+ const url = URL.createObjectURL(blob);
223
+ const a = document.createElement('a');
224
+ a.href = url;
225
+ a.download = filename;
226
+ a.click();
227
+ setTimeout(() => URL.revokeObjectURL(url), 100);
228
+ }
229
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: ChartExportService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
230
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: ChartExportService, providedIn: 'root' }); }
231
+ }
232
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: ChartExportService, decorators: [{
233
+ type: Injectable,
234
+ args: [{ providedIn: 'root' }]
235
+ }] });
236
+
237
+ ClarityIcons.addIcons(downloadIcon);
238
+ class ChartExportButtonComponent {
239
+ constructor() {
240
+ this.svgRef = input(undefined);
241
+ this.filename = input('chart');
242
+ this.buttonTitle = input('Export');
243
+ this.exportService = inject(ChartExportService);
244
+ }
245
+ export(format) {
246
+ const svg = this.svgRef();
247
+ if (!svg) {
248
+ return;
249
+ }
250
+ switch (format) {
251
+ case 'svg':
252
+ this.exportService.exportSvg(svg, this.filename());
253
+ break;
254
+ case 'png':
255
+ this.exportService.exportPng(svg, this.filename());
256
+ break;
257
+ }
258
+ }
259
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: ChartExportButtonComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
260
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "19.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 } }, ngImport: i0, template: `
261
+ <clr-dropdown>
262
+ <button class="btn btn-sm btn-icon btn-link export-trigger" clrDropdownTrigger title="Export chart">
263
+ <cds-icon shape="download" size="16"></cds-icon>
264
+ {{ buttonTitle() }}
265
+ </button>
266
+ <clr-dropdown-menu *clrIfOpen clrPosition="bottom-left">
267
+ <button clrDropdownItem (click)="export('svg')">SVG</button>
268
+ <button clrDropdownItem (click)="export('png')">PNG</button>
269
+ </clr-dropdown-menu>
270
+ </clr-dropdown>
271
+ `, 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: i1.ClrIfOpen, selector: "[clrIfOpen]", inputs: ["clrIfOpen"], outputs: ["clrIfOpenChange"] }, { kind: "directive", type: i1.CdsIconCustomTag, selector: "cds-icon" }, { kind: "ngmodule", type: ClrIconModule }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
272
+ }
273
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: ChartExportButtonComponent, decorators: [{
274
+ type: Component,
275
+ args: [{ selector: 'cng-chart-export-button', template: `
276
+ <clr-dropdown>
277
+ <button class="btn btn-sm btn-icon btn-link export-trigger" clrDropdownTrigger title="Export chart">
278
+ <cds-icon shape="download" size="16"></cds-icon>
279
+ {{ buttonTitle() }}
280
+ </button>
281
+ <clr-dropdown-menu *clrIfOpen clrPosition="bottom-left">
282
+ <button clrDropdownItem (click)="export('svg')">SVG</button>
283
+ <button clrDropdownItem (click)="export('png')">PNG</button>
284
+ </clr-dropdown-menu>
285
+ </clr-dropdown>
286
+ `, imports: [ClrDropdownModule, ClrIconModule], changeDetection: ChangeDetectionStrategy.OnPush, styles: [":host{position:absolute;top:15px;right:15px;z-index:10;opacity:1;pointer-events:auto}\n"] }]
287
+ }] });
288
+
289
+ class ChartLegendComponent {
290
+ constructor() {
291
+ this.items = input.required();
292
+ this.toChartColor = toChartColor;
293
+ }
294
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: ChartLegendComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
295
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.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: `
296
+ <div class="chart-legend">
297
+ @for (item of items(); track item.label) {
298
+ <div class="legend-item">
299
+ <span class="legend-color-square" [style.background-color]="toChartColor(item.color)"></span>
300
+ <span class="legend-label">{{ item.label }}</span>
301
+ </div>
302
+ }
303
+ </div>
304
+ `, 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(--clr-color-neutral-600, #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 }); }
305
+ }
306
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: ChartLegendComponent, decorators: [{
307
+ type: Component,
308
+ args: [{ selector: 'cng-chart-legend', template: `
309
+ <div class="chart-legend">
310
+ @for (item of items(); track item.label) {
311
+ <div class="legend-item">
312
+ <span class="legend-color-square" [style.background-color]="toChartColor(item.color)"></span>
313
+ <span class="legend-label">{{ item.label }}</span>
314
+ </div>
315
+ }
316
+ </div>
317
+ `, 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(--clr-color-neutral-600, #666)}.legend-item{display:flex;align-items:center;gap:.35rem}.legend-color-square{width:10px;height:10px;border-radius:2px;flex-shrink:0}\n"] }]
318
+ }] });
319
+
320
+ class ChartSkeletonComponent {
321
+ constructor() {
322
+ this.skeletonType = input('loading');
323
+ this.orientation = input.required();
324
+ this.animationStyle = computed(() => (this.skeletonType() === 'loading' ? 'pulse' : undefined));
325
+ }
326
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: ChartSkeletonComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
327
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.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"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
328
+ }
329
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: ChartSkeletonComponent, decorators: [{
330
+ type: Component,
331
+ 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"] }]
332
+ }] });
333
+
334
+ class ChartTooltipComponent {
335
+ constructor() {
336
+ this.tooltipPosition = input.required();
337
+ this.tooltipOrientation = input('top');
338
+ this.squareColor = input.required();
339
+ this.tooltipClickable = input(true);
340
+ this.tooltipClosed = output();
341
+ this.tooltipHeaderClicked = output();
342
+ this.toChartColor = toChartColor;
343
+ }
344
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: ChartTooltipComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
345
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.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(--clr-color-neutral-0, #fff);border:1px solid var(--clr-color-neutral-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(--clr-color-neutral-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(--clr-color-neutral-700, #565656)}hr:has(+.tooltip-footer:empty){display:none}hr{margin:6px -16px}\n"], dependencies: [{ kind: "ngmodule", type: ClrIconModule }, { kind: "directive", type: i1.CdsIconCustomTag, selector: "cds-icon" }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
346
+ }
347
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: ChartTooltipComponent, decorators: [{
348
+ type: Component,
349
+ args: [{ selector: 'cng-chart-tooltip', changeDetection: ChangeDetectionStrategy.OnPush, imports: [ClrIconModule], 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(--clr-color-neutral-0, #fff);border:1px solid var(--clr-color-neutral-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(--clr-color-neutral-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(--clr-color-neutral-700, #565656)}hr:has(+.tooltip-footer:empty){display:none}hr{margin:6px -16px}\n"] }]
350
+ }] });
351
+
352
+ // Clarity actually has an "outside-click" directive, but they don't export it 🤷
353
+ class OutsideClickDirective {
354
+ constructor() {
355
+ this.outsideClick = output({ alias: 'cngOutsideClick' });
356
+ this.renderer = inject(Renderer2);
357
+ this.destroyRef = inject(DestroyRef);
358
+ }
359
+ ngAfterViewInit() {
360
+ const listener = this.renderer.listen('document', 'click', () => this.outsideClick.emit());
361
+ this.destroyRef.onDestroy(listener);
362
+ }
363
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: OutsideClickDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
364
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "19.2.18", type: OutsideClickDirective, isStandalone: true, selector: "[cngOutsideClick]", outputs: { outsideClick: "cngOutsideClick" }, ngImport: i0 }); }
365
+ }
366
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: OutsideClickDirective, decorators: [{
367
+ type: Directive,
368
+ args: [{
369
+ selector: '[cngOutsideClick]',
370
+ }]
371
+ }] });
372
+
373
+ /**
374
+ * Enum for the Screen size in pixels, defined by clarity here https://vmware.github.io/clarity/documentation/v0.12/grid
375
+ * We consider screens above 1200px to be a desktop, between 576px and 1199px to be a tablet and below 576px to be a phone.
376
+ */
377
+ var ScreenWidth;
378
+ (function (ScreenWidth) {
379
+ ScreenWidth[ScreenWidth["EXTRA_LARGE"] = 1200] = "EXTRA_LARGE";
380
+ ScreenWidth[ScreenWidth["LARGE"] = 992] = "LARGE";
381
+ ScreenWidth[ScreenWidth["MEDIUM"] = 768] = "MEDIUM";
382
+ ScreenWidth[ScreenWidth["SMALL"] = 576] = "SMALL";
383
+ ScreenWidth[ScreenWidth["EXTRA_SMALL"] = 575] = "EXTRA_SMALL";
384
+ })(ScreenWidth || (ScreenWidth = {}));
385
+ var ScreenOrientation;
386
+ (function (ScreenOrientation) {
387
+ ScreenOrientation["PORTRAIT"] = "PORTRAIT";
388
+ ScreenOrientation["LANDSCAPE"] = "LANDSCAPE";
389
+ })(ScreenOrientation || (ScreenOrientation = {}));
390
+ const DATEPICKER_ENABLE_BREAKPOINT = 768;
391
+ const MOBILE_USERAGENT_REGEX = /Mobi/i;
392
+ class ScreenStateService {
393
+ constructor(_document) {
394
+ this._document = _document;
395
+ this.isUserAgentMobile = false;
396
+ this.screenWidthSubject = new ReplaySubject(1);
397
+ this.screenOrientationSubject = new ReplaySubject(1);
398
+ window.addEventListener('resize', () => {
399
+ this.onResize();
400
+ });
401
+ window.addEventListener('orientationchange', () => {
402
+ this.onOrientationChange();
403
+ });
404
+ this.determineMobileUserAgent();
405
+ // Execute them once for initial values
406
+ this.onResize();
407
+ this.onOrientationChange();
408
+ }
409
+ getScreenWidthChanged() {
410
+ return this.screenWidthSubject;
411
+ }
412
+ getMobileStateChanged() {
413
+ return this.getScreenWidthChanged().pipe(map(screenWidth => screenWidth < DATEPICKER_ENABLE_BREAKPOINT && this.isUserAgentMobile), distinctUntilChanged());
414
+ }
415
+ getScreenOrientationChanged() {
416
+ return this.screenOrientationSubject;
417
+ }
418
+ onResize() {
419
+ const windowWidth = window.innerWidth;
420
+ this.screenWidthSubject.next(windowWidth);
421
+ }
422
+ onOrientationChange() {
423
+ const prevOrientation = this.screenOrientation;
424
+ const orientation = window.orientation;
425
+ // Probably not always correct since this depends on the devices default orientation.
426
+ // Better way to identify this would probably be comparing height & width of the screen.
427
+ if (orientation === -90 || orientation === 90) {
428
+ this.screenOrientation = ScreenOrientation.LANDSCAPE;
429
+ }
430
+ else {
431
+ this.screenOrientation = ScreenOrientation.PORTRAIT;
432
+ }
433
+ if (this.screenOrientation !== prevOrientation) {
434
+ this.screenOrientationSubject.next(this.screenOrientation);
435
+ }
436
+ }
437
+ determineMobileUserAgent() {
438
+ if (this._document) {
439
+ this.isUserAgentMobile = MOBILE_USERAGENT_REGEX.test(this._document.defaultView.navigator.userAgent);
440
+ }
441
+ }
442
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: ScreenStateService, deps: [{ token: DOCUMENT }], target: i0.ɵɵFactoryTarget.Injectable }); }
443
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: ScreenStateService, providedIn: 'root' }); }
444
+ }
445
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: ScreenStateService, decorators: [{
446
+ type: Injectable,
447
+ args: [{ providedIn: 'root' }]
448
+ }], ctorParameters: () => [{ type: Document, decorators: [{
449
+ type: Inject,
450
+ args: [DOCUMENT]
451
+ }] }] });
452
+
453
+ class WindowResizeDirective {
454
+ constructor() {
455
+ this.debounce = input(250);
456
+ this.includeFirst = input(false);
457
+ this.windowResize = output({ alias: 'cngWindowResize' });
458
+ this.screenStateService = inject(ScreenStateService);
459
+ this.destroyRef = inject(DestroyRef);
460
+ }
461
+ ngOnInit() {
462
+ const skipCount = this.includeFirst() ? 0 : 1;
463
+ this.screenStateService
464
+ .getScreenWidthChanged()
465
+ .pipe(takeUntilDestroyed(this.destroyRef), skip(skipCount), debounceTime(this.debounce()))
466
+ .subscribe(width => this.windowResize.emit(width));
467
+ }
468
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: WindowResizeDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
469
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "19.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 }); }
470
+ }
471
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: WindowResizeDirective, decorators: [{
472
+ type: Directive,
473
+ args: [{
474
+ selector: '[cngWindowResize]',
475
+ }]
476
+ }] });
477
+
478
+ class BarChartComponent extends ChartBase {
479
+ static { this.HORIZONTAL_BAR_MIN_HEIGHT_PX = 25; }
480
+ constructor() {
481
+ super();
482
+ this.data = input.required();
483
+ this.stacks = input(undefined);
484
+ this.orientation = input.required();
485
+ this.tooltipOrientation = input('top');
486
+ this.barSizePx = input(15);
487
+ this.barAreaSizePx = input(40);
488
+ this.tooltipPercentOfTotal = input('of total');
489
+ this.tooltipPercentOf = input('of');
490
+ this.noItemsMessage = input(NO_ITEMS_MESSAGE);
491
+ this.tooManyItemsMessage = input(TOO_MANY_ITEMS_MESSAGE);
492
+ this.tooManyItemsGroupedMessage = input(TOO_MANY_ITEMS_GROUPED_MESSAGE);
493
+ this.showLegend = input(true);
494
+ this.showExportButton = input(false);
495
+ this.exportButtonTitle = input('Export');
496
+ this.exportFilename = input('bar-chart');
497
+ /** Optional label rendered below the X axis. */
498
+ this.xAxisLabel = input('');
499
+ /** Optional label rendered rotated to the left of the Y axis. */
500
+ this.yAxisLabel = input('');
501
+ this.valueClicked = output();
502
+ this.textRenderer = new TextRenderer();
503
+ this.MARGIN = { top: 10, right: 20, bottom: 30, left: 65 };
504
+ this.toChartColor = toChartColor;
505
+ this.total = computed(() => this.data().reduce((acc, v) => acc + v.value, 0));
506
+ this.totalByStack = computed(() => {
507
+ const totals = {};
508
+ for (const d of this.slicedDataPoints()) {
509
+ totals[d.stackKey] = (totals[d.stackKey] ?? 0) + d.value;
510
+ }
511
+ return totals;
512
+ });
513
+ this.keysByStack = computed(() => {
514
+ const keys = {};
515
+ for (const d of this.slicedDataPoints()) {
516
+ if (!keys[d.stackKey]) {
517
+ keys[d.stackKey] = [];
518
+ }
519
+ keys[d.stackKey].push(d.key);
520
+ }
521
+ return keys;
522
+ });
523
+ // Count the total amount of bars by either the stacKey (if stacked) or the key (if not stacked)
524
+ this.totalBarCount = computed(() => new Set(this.data()
525
+ .filter(d => d.value > 0)
526
+ .map(d => d.stackKey ?? d.key)).size);
527
+ this.showingBarCount = computed(() => new Set(this.slicedDataPoints()
528
+ .filter(d => d.value > 0)
529
+ .map(d => d.stackKey)).size);
530
+ this.alertMessageAndType = computed(() => {
531
+ if (this.loading()) {
532
+ return undefined;
533
+ }
534
+ if (!this.showingBarCount()) {
535
+ return [this.noItemsMessage(), NO_ITEMS_ALERT_TYPE];
536
+ }
537
+ else if (this.totalBarCount() !== this.showingBarCount()) {
538
+ return [
539
+ this.stacks() ? this.tooManyItemsGroupedMessage() : this.tooManyItemsMessage(),
540
+ TOO_MANY_ITEMS_ALERT_TYPE,
541
+ ];
542
+ }
543
+ return undefined;
544
+ });
545
+ this.legendItems = computed(() => {
546
+ if (!this.showLegend() || !this.data()?.length) {
547
+ return [];
548
+ }
549
+ if (this.stacks()?.length) {
550
+ // Stacked: one legend entry per distinct label (= each layer in the stack)
551
+ const seen = new Set();
552
+ const items = [];
553
+ for (const item of this.data()) {
554
+ const label = item.fullLabel ?? item.label;
555
+ if (!seen.has(label)) {
556
+ seen.add(label);
557
+ items.push({ label, color: item.color });
558
+ }
559
+ }
560
+ return items;
561
+ }
562
+ // Non-stacked: one entry per bar
563
+ return this.data().map(item => ({ label: item.fullLabel ?? item.label, color: item.color }));
564
+ });
565
+ /** Computed values used by the tooltip to avoid inline logic in the template. */
566
+ this.tooltipKey = computed(() => {
567
+ const item = this.selectedItem();
568
+ if (!item) {
569
+ return undefined;
570
+ }
571
+ return this.stacks()?.length ? this.keysByStack()[item.stackKey] ?? [] : [item.key];
572
+ });
573
+ this.tooltipLabel = computed(() => {
574
+ const item = this.selectedItem();
575
+ if (!item) {
576
+ return undefined;
577
+ }
578
+ return this.stacks()?.find(stack => stack.stackKey === item.stackKey)?.label ?? item.fullLabel ?? item.label;
579
+ });
580
+ this.tooltipValue = computed(() => {
581
+ const item = this.selectedItem();
582
+ if (!item) {
583
+ return undefined;
584
+ }
585
+ return this.stacks()?.length ? this.totalByStack()[item.stackKey] ?? 0 : item.value;
586
+ });
587
+ /** Slices belonging to the currently selected stack – used by the tooltip @for loop. */
588
+ this.selectedStackSlices = computed(() => {
589
+ const item = this.selectedItem();
590
+ if (!item || !this.stacks()?.length) {
591
+ return [];
592
+ }
593
+ return this.slicedDataPoints().filter(d => d.stackKey === item.stackKey);
594
+ });
595
+ this.maxAmountOfItems = signal(undefined);
596
+ this.slicedDataPoints = signal([]);
597
+ this.barSelection = null;
598
+ this.labelSelection = null;
599
+ }
600
+ ngOnChanges(_changes) {
601
+ if (!this.svg) {
602
+ return;
603
+ }
604
+ requestAnimationFrame(() => this.updateChart());
605
+ }
606
+ ngAfterViewInit() {
607
+ this.createChart();
608
+ super.ngAfterViewInit(); // schedules the initial requestAnimationFrame(() => updateChart())
609
+ }
610
+ createChart() {
611
+ const element = this.chartRef().nativeElement;
612
+ this.svg = select(element);
613
+ }
614
+ updateChart() {
615
+ this.svg.selectAll('*').remove();
616
+ if (this.loading()) {
617
+ this.slicedDataPoints.set([]);
618
+ return;
619
+ }
620
+ const { width: containerWidth, height: containerHeight } = this.getContainerDimensions();
621
+ const extraBottom = this.xAxisLabel() ? 16 : 0;
622
+ const extraLeft = this.yAxisLabel() ? 16 : 0;
623
+ const leftMargin = (this.orientation() === 'horizontal' ? this.MARGIN.left : 30) + extraLeft;
624
+ const width = containerWidth - leftMargin - this.MARGIN.right;
625
+ const height = containerHeight - this.MARGIN.top - (this.MARGIN.bottom + extraBottom);
626
+ this.maxAmountOfItems.set(this.getMaxAmountOfItems(width, height));
627
+ if (!this.data()?.length) {
628
+ this.slicedDataPoints.set([]);
629
+ return;
630
+ }
631
+ const stacks = this.stackItems(this.data());
632
+ const slicedStacks = stacks.slice(0, this.maxAmountOfItems());
633
+ const flatStacks = slicedStacks.flat();
634
+ this.slicedDataPoints.set(flatStacks);
635
+ const slicedStackKeys = new Set(slicedStacks.map(stack => stack[0].stackKey));
636
+ const labels = this.stacks()?.length
637
+ ? this.stacks()
638
+ .filter(stack => slicedStackKeys.has(stack.stackKey))
639
+ .map(stack => ({ stackKey: stack.stackKey, label: stack.label }))
640
+ : flatStacks.map(d => ({ stackKey: d.stackKey, label: d.label }));
641
+ const g = this.svg
642
+ .attr('width', width)
643
+ .attr('height', height)
644
+ .append('g')
645
+ .attr('transform', `translate(${leftMargin},${this.MARGIN.top})`);
646
+ if (this.orientation() === 'vertical') {
647
+ this.createVerticalChart(g, labels, width, height, this.xAxisLabel(), this.yAxisLabel(), leftMargin);
648
+ }
649
+ else {
650
+ this.createHorizontalChart(g, labels, width, height, this.xAxisLabel(), this.yAxisLabel(), leftMargin);
651
+ }
652
+ this.styleGridLines(g);
653
+ }
654
+ createVerticalChart(g, labels, width, height, xAxisLabel, yAxisLabel, leftMargin) {
655
+ const keys = labels.map(d => d.stackKey);
656
+ const x = scaleBand().domain(keys).range([0, width]);
657
+ const y = scaleLinear()
658
+ .domain([0, max(this.slicedDataPoints(), (d) => d.stackValue1) || 0])
659
+ .nice()
660
+ .range([height, 0]);
661
+ // X Axis
662
+ const labelSelection = g
663
+ .append('g')
664
+ .attr('transform', `translate(0,${height})`)
665
+ .call(axisBottom(x))
666
+ .selectAll('text');
667
+ this.labelSelection = labelSelection
668
+ .data(labels)
669
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
670
+ .call(this.addTextCommonInfo)
671
+ .style('cursor', 'pointer')
672
+ .call(this.addTextAndTitle.bind(this), x.bandwidth())
673
+ .call(this.addTextHoverHandlers.bind(this))
674
+ .call(this.addTextClickHandler.bind(this));
675
+ // Y Axis
676
+ const tickValues = y.ticks(5).filter((tick) => Number.isInteger(tick));
677
+ const yAxis = g
678
+ .append('g')
679
+ .call(axisLeft(y).tickValues(tickValues).tickSize(-width).tickFormat(format('~s')))
680
+ .selectAll('text');
681
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
682
+ yAxis.call(this.addTextCommonInfo);
683
+ this.createBarSelectionGroups(g);
684
+ // Add highlight rect (behind)
685
+ this.addVerticalBarRectangle(x, y, 2)
686
+ .attr('class', 'bar-highlight')
687
+ .attr('fill', '#fff')
688
+ .style('stroke', (d) => toChartColor(d.color))
689
+ .attr('stroke-width', 2)
690
+ .style('opacity', 0); // hidden by default;
691
+ // Add main bar rect (on top)
692
+ this.addVerticalBarRectangle(x, y)
693
+ .attr('class', 'bar')
694
+ .style('fill', (d) => toChartColor(d.color));
695
+ this.appendAxisLabel(g, xAxisLabel, width / 2, height + 40);
696
+ this.appendAxisLabel(g, yAxisLabel, -height / 2, -(leftMargin - 10), 'rotate(-90)');
697
+ }
698
+ createHorizontalChart(g, labels, width, height, xAxisLabel, yAxisLabel, leftMargin) {
699
+ const x = scaleLinear()
700
+ .domain([0, max(this.slicedDataPoints(), (d) => d.stackValue1) || 0])
701
+ .nice()
702
+ .range([0, width]);
703
+ const keys = labels.map(d => d.stackKey);
704
+ const y = scaleBand().domain(keys).range([0, height]);
705
+ // X Axis
706
+ const tickValues = x.ticks(5).filter((tick) => Number.isInteger(tick));
707
+ const xAxis = g
708
+ .append('g')
709
+ .attr('transform', `translate(0,${height})`)
710
+ .call(axisBottom(x).tickValues(tickValues).tickSize(-height).tickFormat(format('~s')))
711
+ .selectAll('text');
712
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
713
+ xAxis.call(this.addTextCommonInfo);
714
+ // Y Axis
715
+ const labelSelection = g
716
+ .append('g')
717
+ .attr('transform', `translate(0,0)`)
718
+ .call(axisLeft(y))
719
+ .selectAll('text');
720
+ this.labelSelection = labelSelection
721
+ .data(labels)
722
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
723
+ .call(this.addTextCommonInfo)
724
+ .style('cursor', 'pointer')
725
+ .call(this.addTextAndTitle.bind(this), this.MARGIN.left - 10)
726
+ .call(this.addTextHoverHandlers.bind(this))
727
+ .call(this.addTextClickHandler.bind(this));
728
+ this.createBarSelectionGroups(g);
729
+ // Add highlight rect (behind)
730
+ this.addHorizontalBarRectangle(x, y, 2)
731
+ .attr('class', 'bar-highlight')
732
+ .attr('fill', '#fff')
733
+ .style('stroke', (d) => toChartColor(d.color))
734
+ .attr('stroke-width', 2)
735
+ .style('opacity', 0); // hidden by default;
736
+ // Add main bar rect (on top)
737
+ this.addHorizontalBarRectangle(x, y)
738
+ .attr('class', 'bar')
739
+ .style('fill', (d) => toChartColor(d.color));
740
+ // X axis description label (value axis at bottom)
741
+ this.appendAxisLabel(g, xAxisLabel, width / 2, height + 40);
742
+ // Y axis description label: centred in the extra band reserved for it
743
+ this.appendAxisLabel(g, yAxisLabel, -height / 2, -((this.MARGIN.left + leftMargin) / 2), 'rotate(-90)');
744
+ }
745
+ createBarSelectionGroups(g) {
746
+ this.barSelection = g
747
+ .selectAll('.bar-group')
748
+ .data(this.slicedDataPoints(), (d) => d.key)
749
+ .join('g')
750
+ .attr('class', 'bar-group')
751
+ .style('cursor', 'pointer')
752
+ .on('mouseover', (_e, d) => this.setHoverStylesByStackKey(d.stackKey, d.key, true))
753
+ .on('mouseout', (_e, d) => this.setHoverStylesByStackKey(d.stackKey, d.key, false))
754
+ .call(this.addBarClickHandler.bind(this));
755
+ }
756
+ addTextCommonInfo(g) {
757
+ g.style('font-size', '11px').style('fill', 'var(--clr-color-neutral-600, #666)');
758
+ }
759
+ addTextHoverHandlers(g) {
760
+ g.on('mouseover', (_e, d) => this.setHoverStylesByStackKey(d.stackKey, undefined, true)) //
761
+ .on('mouseout', (_e, d) => this.setHoverStylesByStackKey(d.stackKey, undefined, false));
762
+ }
763
+ setHoverStylesByStackKey(stackKey, key, isHover) {
764
+ // When hovering a stack section, we also need to highlight the label, but not the other sections of the same stack.
765
+ // But when hovering a label, we need to highlight all sections of the stack.
766
+ const barGroup = this.barSelection.filter((d) => d.stackKey === stackKey && (key == null || d.key === key));
767
+ barGroup.select('.bar').style('mix-blend-mode', isHover ? 'multiply' : 'unset');
768
+ barGroup.select('.bar-highlight').style('opacity', isHover ? '1' : '0');
769
+ this.labelSelection
770
+ .filter((d) => d.stackKey === stackKey)
771
+ .style('font-weight', isHover ? 'bold' : 'unset');
772
+ }
773
+ addBarClickHandler(g) {
774
+ g.on('click', (event, d) => {
775
+ event.stopPropagation();
776
+ this.openTooltipByKey(d.key);
777
+ });
778
+ }
779
+ addTextClickHandler(g) {
780
+ // no click handler for labels in stacked charts
781
+ if (this.stacks()?.length) {
782
+ return;
783
+ }
784
+ g.on('click', (event, d) => {
785
+ event.stopPropagation();
786
+ // Note: In non-stacked charts, the stackKey is equal to the key, so this works - but the logic is confusing [VU3REQ-4790]
787
+ this.openTooltipByKey(d.stackKey);
788
+ });
789
+ }
790
+ openTooltipByKey(key) {
791
+ const index = this.slicedDataPoints().findIndex(item => item.key === key);
792
+ const rect = this.barSelection
793
+ .filter((_d, i) => i === index)
794
+ // we actually want the bar, not the group (because if value is 0, then the group is not positioned correctly
795
+ .node()
796
+ .children[0].getBoundingClientRect();
797
+ const container = this.chartRef().nativeElement.getBoundingClientRect();
798
+ this.tooltipPosition.set({
799
+ x: rect.left - container.left + rect.width / 2,
800
+ y: (this.tooltipOrientation() === 'top' ? rect.top : rect.bottom) - container.top,
801
+ });
802
+ this.selectedItem.set(this.slicedDataPoints()[index]);
803
+ }
804
+ addTextAndTitle(g, availableWidth) {
805
+ g.each((d, index, nodes) => {
806
+ const target = select(nodes[index]);
807
+ target.text(this.textRenderer.render(d.label, 15, availableWidth, target.style('font-size'), target.style('font-family')));
808
+ target.append('title').text(d.label);
809
+ });
810
+ }
811
+ styleGridLines(g) {
812
+ g.selectAll('.tick line').style('stroke', 'var(--clr-color-neutral-200, #e8e8e8)');
813
+ }
814
+ addVerticalBarRectangle(x, y, extraSize = 0) {
815
+ return this.barSelection
816
+ .append('rect')
817
+ .attr('x', (d) => {
818
+ return (x(d.stackKey) || 0) + (x.bandwidth() - this.barSizePx()) / 2 + extraSize;
819
+ })
820
+ .attr('y', (d) => y(d.stackValue1))
821
+ .attr('width', this.barSizePx() - 2 * extraSize)
822
+ .attr('height', (d) => y(d.stackValue0) - y(d.stackValue1))
823
+ .attr('rx', 1)
824
+ .attr('ry', 1);
825
+ }
826
+ addHorizontalBarRectangle(x, y, extraSize = 0) {
827
+ return this.barSelection
828
+ .append('rect')
829
+ .attr('x', (d) => x(d.stackValue0))
830
+ .attr('y', (d) => {
831
+ return (y(d.stackKey) || 0) + (y.bandwidth() - this.barSizePx()) / 2 + extraSize;
832
+ })
833
+ .attr('width', (d) => x(d.stackValue1) - x(d.stackValue0))
834
+ .attr('height', this.barSizePx() - 2 * extraSize)
835
+ .attr('rx', 1)
836
+ .attr('ry', 1);
837
+ }
838
+ /** Appends a shared-style axis description label to the chart group. */
839
+ appendAxisLabel(g, text, x, y, transform) {
840
+ if (!text) {
841
+ return;
842
+ }
843
+ const el = g
844
+ .append('text')
845
+ .attr('text-anchor', 'middle')
846
+ .style('font-size', '12px')
847
+ .style('fill', 'var(--clr-color-neutral-600, #666)')
848
+ .text(text)
849
+ .attr('x', x)
850
+ .attr('y', y);
851
+ if (transform) {
852
+ el.attr('transform', transform);
853
+ }
854
+ }
855
+ stackItems(data) {
856
+ let groups;
857
+ if (this.stacks()?.length) {
858
+ groups = Array.from(group(data, (d) => d.stackKey ?? d.key));
859
+ }
860
+ else {
861
+ groups = data.map(item => [item.key, [item]]);
862
+ }
863
+ return this.mapItems(groups);
864
+ }
865
+ mapItems(groups) {
866
+ const result = [];
867
+ for (const [_, items] of groups) {
868
+ let stackValue = 0;
869
+ const stackSum = sum(items, (d) => d.value);
870
+ result.push(items.map(item => {
871
+ const stackValue0 = stackValue;
872
+ stackValue += item.value;
873
+ return {
874
+ ...item,
875
+ stackValue0,
876
+ stackValue1: stackValue,
877
+ stackKey: item.stackKey ?? item.key,
878
+ percentageOfStack: stackSum === 0 ? 0 : (100 * item.value) / stackSum,
879
+ };
880
+ }));
881
+ }
882
+ return result;
883
+ }
884
+ getMaxAmountOfItems(width, height) {
885
+ if (this.orientation() === 'vertical') {
886
+ return Math.floor(width / this.barAreaSizePx());
887
+ }
888
+ return Math.floor(height / BarChartComponent.HORIZONTAL_BAR_MIN_HEIGHT_PX);
889
+ }
890
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: BarChartComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
891
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.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 }}:&nbsp;</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 [svgRef]=\"svgElement()\" [filename]=\"exportFilename()\" [buttonTitle]=\"exportButtonTitle()\" />\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"] }, { 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 }); }
892
+ }
893
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: BarChartComponent, decorators: [{
894
+ type: Component,
895
+ 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 }}:&nbsp;</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 [svgRef]=\"svgElement()\" [filename]=\"exportFilename()\" [buttonTitle]=\"exportButtonTitle()\" />\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"] }]
896
+ }], ctorParameters: () => [] });
897
+
898
+ /*
899
+ * Copyright (c) 2018-2026 Porsche Informatik. All Rights Reserved.
900
+ * This software is released under MIT license.
901
+ * The full license information can be found in LICENSE in the root directory of this project.
902
+ */
903
+ /**
904
+ * Renders interactive dot circles for a single XY series onto the given D3 group.
905
+ *
906
+ * - Default radius: 4px, hover radius: 6px.
907
+ * - Calls `onClick` with the clicked element, data point and series when a dot is clicked.
908
+ *
909
+ */
910
+ function renderDots(g, series, x, y, onClick) {
911
+ g.selectAll(`.dot-${series.key}`)
912
+ .data(series.data, (d) => d.x)
913
+ .join('circle')
914
+ .attr('class', `dot dot-${series.key}`)
915
+ .attr('cx', (d) => x(d.x) ?? 0)
916
+ .attr('cy', (d) => y(d.value))
917
+ .attr('r', 4)
918
+ .style('fill', toChartColor(series.color))
919
+ .attr('stroke', '#fff')
920
+ .attr('stroke-width', 2)
921
+ .style('cursor', 'pointer')
922
+ .on('mouseover', (e) => {
923
+ select(e.currentTarget).attr('r', 6);
924
+ })
925
+ .on('mouseout', (e) => {
926
+ select(e.currentTarget).attr('r', 4);
927
+ })
928
+ .on('click', (event, d) => {
929
+ event.stopPropagation();
930
+ onClick(event.currentTarget, d, series);
931
+ });
932
+ }
933
+
934
+ /*
935
+ * Copyright (c) 2018-2026 Porsche Informatik. All Rights Reserved.
936
+ * This software is released under MIT license.
937
+ * The full license information can be found in LICENSE in the root directory of this project.
938
+ */
939
+ /**
940
+ * Draws the X and Y axes (with integer-only Y ticks and grid lines) plus
941
+ * optional description labels onto the given D3 group.
942
+ *
943
+ * @param g The D3 group element to append into.
944
+ * @param x Any D3 axis scale keyed on strings (`ScalePoint` or `ScaleBand`).
945
+ * @param y The linear Y scale.
946
+ * @param opts Axis configuration options.
947
+ */
948
+ function drawXYAxes(g, x, y, opts) {
949
+ const { xLabelMap, width, height, xAxisLabel, yAxisLabel, effectiveLeft } = opts;
950
+ // X axis
951
+ g.append('g')
952
+ .attr('transform', `translate(0,${height})`)
953
+ .call(axisBottom(x).tickFormat((d) => xLabelMap.get(d) ?? d))
954
+ .selectAll('text')
955
+ .style('font-size', '11px')
956
+ .style('fill', 'var(--clr-color-neutral-600, #666)');
957
+ // Y axis
958
+ const tickValues = y.ticks(5).filter((t) => Number.isInteger(t));
959
+ g.append('g')
960
+ .call(axisLeft(y).tickValues(tickValues).tickSize(-width).tickFormat(format('~s')))
961
+ .selectAll('text')
962
+ .style('font-size', '11px')
963
+ .style('fill', 'var(--clr-color-neutral-600, #666)');
964
+ // Optional X axis description label
965
+ if (xAxisLabel) {
966
+ g.append('text')
967
+ .attr('x', width / 2)
968
+ .attr('y', height + 40)
969
+ .attr('text-anchor', 'middle')
970
+ .style('font-size', '12px')
971
+ .style('fill', 'var(--clr-color-neutral-600, #666)')
972
+ .text(xAxisLabel);
973
+ }
974
+ // Optional Y axis description label (rotated, centred in the left-margin band)
975
+ if (yAxisLabel) {
976
+ g.append('text')
977
+ .attr('transform', 'rotate(-90)')
978
+ .attr('x', -height / 2)
979
+ .attr('y', -(effectiveLeft / 2))
980
+ .attr('text-anchor', 'middle')
981
+ .style('font-size', '12px')
982
+ .style('fill', 'var(--clr-color-neutral-600, #666)')
983
+ .text(yAxisLabel);
984
+ }
985
+ }
986
+ /**
987
+ * Styles all D3 grid tick lines inside `g` with the neutral chart grid color.
988
+ */
989
+ function styleGridLines(g) {
990
+ g.selectAll('.tick line').style('stroke', 'var(--clr-color-neutral-200, #e8e8e8)');
991
+ }
992
+
993
+ class LineChartComponent extends ChartBase {
994
+ constructor() {
995
+ super(...arguments);
996
+ this.series = input.required();
997
+ this.tooltipOrientation = input('top');
998
+ this.showArea = input(false);
999
+ this.showLegend = input(true);
1000
+ this.showValues = input(false);
1001
+ this.showExportButton = input(false);
1002
+ this.exportButtonTitle = input('Export');
1003
+ this.exportFilename = input('line-chart');
1004
+ this.noItemsMessage = input(NO_ITEMS_MESSAGE);
1005
+ this.tooltipPercentOfTotal = input('of total');
1006
+ /** Optional label rendered below the X axis. */
1007
+ this.xAxisLabel = input('');
1008
+ /** Optional label rendered rotated to the left of the Y axis. */
1009
+ this.yAxisLabel = input('');
1010
+ this.valueClicked = output();
1011
+ this.MARGIN = { top: 20, right: 30, bottom: 30, left: 65 };
1012
+ this.hasData = computed(() => this.series().some(s => s.data.length > 0));
1013
+ this.total = computed(() => this.series().reduce((acc, s) => acc + s.data.reduce((a, d) => a + d.value, 0), 0));
1014
+ this.alertMessageAndType = computed(() => {
1015
+ if (this.loading()) {
1016
+ return undefined;
1017
+ }
1018
+ if (!this.hasData()) {
1019
+ return [this.noItemsMessage(), NO_ITEMS_ALERT_TYPE];
1020
+ }
1021
+ return undefined;
1022
+ });
1023
+ this.legendItems = computed(() => {
1024
+ if (!this.showLegend()) {
1025
+ return [];
1026
+ }
1027
+ return this.series().map(s => ({ label: s.label, color: s.color }));
1028
+ });
1029
+ }
1030
+ ngOnChanges(_changes) {
1031
+ if (!this.svg) {
1032
+ return;
1033
+ }
1034
+ requestAnimationFrame(() => this.updateChart());
1035
+ }
1036
+ ngAfterViewInit() {
1037
+ this.svg = select(this.chartRef().nativeElement);
1038
+ super.ngAfterViewInit();
1039
+ }
1040
+ updateChart() {
1041
+ this.svg.selectAll('*').remove();
1042
+ if (this.loading()) {
1043
+ return;
1044
+ }
1045
+ if (!this.hasData()) {
1046
+ return;
1047
+ }
1048
+ const { width: containerWidth, height: containerHeight } = this.getContainerDimensions();
1049
+ const extraBottom = this.xAxisLabel() ? 16 : 0;
1050
+ const extraLeft = this.yAxisLabel() ? 16 : 0;
1051
+ const extraTop = this.showValues() ? 14 : 0;
1052
+ const effectiveLeft = this.MARGIN.left + extraLeft;
1053
+ const width = containerWidth - effectiveLeft - this.MARGIN.right;
1054
+ const height = containerHeight - (this.MARGIN.top + extraTop) - (this.MARGIN.bottom + extraBottom);
1055
+ // Collect all unique X keys in the order they appear across all series
1056
+ const xKeys = [...new Set(this.series().flatMap(s => s.data.map(d => d.x)))];
1057
+ // Build a display-label map
1058
+ const xLabelMap = new Map();
1059
+ this.series().forEach(s => s.data.forEach(d => xLabelMap.set(d.x, d.xLabel ?? d.x)));
1060
+ const x = scalePoint().domain(xKeys).range([0, width]).padding(0.5);
1061
+ const maxY = max(this.series().flatMap(s => s.data.map(d => d.value))) ?? 0;
1062
+ const y = scaleLinear().domain([0, maxY]).nice().range([height, 0]);
1063
+ const g = this.svg
1064
+ .attr('width', width)
1065
+ .attr('height', height)
1066
+ .append('g')
1067
+ .attr('transform', `translate(${effectiveLeft},${this.MARGIN.top + extraTop})`);
1068
+ drawXYAxes(g, x, y, {
1069
+ xLabelMap,
1070
+ width,
1071
+ height,
1072
+ xAxisLabel: this.xAxisLabel(),
1073
+ yAxisLabel: this.yAxisLabel(),
1074
+ effectiveLeft,
1075
+ });
1076
+ styleGridLines(g);
1077
+ this.drawSeries(g, x, y);
1078
+ }
1079
+ drawSeries(g, x, y) {
1080
+ const lineGenerator = line()
1081
+ .x((d) => x(d.x) ?? 0)
1082
+ .y((d) => y(d.value))
1083
+ .curve(curveMonotoneX);
1084
+ for (const series of this.series()) {
1085
+ if (!series.data.length) {
1086
+ continue;
1087
+ }
1088
+ // Optional area fill
1089
+ if (this.showArea()) {
1090
+ g.append('path')
1091
+ .datum(series.data)
1092
+ .attr('class', `area area-${series.key}`)
1093
+ .style('fill', toChartColor(series.color))
1094
+ .attr('fill-opacity', 0.1)
1095
+ .attr('d', lineGenerator);
1096
+ }
1097
+ // Line path
1098
+ g.append('path')
1099
+ .datum(series.data)
1100
+ .attr('class', `line line-${series.key}`)
1101
+ .attr('fill', 'none')
1102
+ .style('stroke', toChartColor(series.color))
1103
+ .attr('stroke-width', 2)
1104
+ .attr('stroke-linejoin', 'round')
1105
+ .attr('stroke-linecap', 'round')
1106
+ .attr('d', lineGenerator);
1107
+ // Dots
1108
+ renderDots(g, series, x, y, (el, point, s) => this.openTooltip(el, point, s));
1109
+ // Value labels above each dot
1110
+ if (this.showValues()) {
1111
+ g.selectAll(`.value-label-${series.key}`)
1112
+ .data(series.data, (d) => d.x)
1113
+ .join('text')
1114
+ .attr('class', `value-label value-label-${series.key}`)
1115
+ .attr('x', (d) => x(d.x) ?? 0)
1116
+ .attr('y', (d) => y(d.value) - 9)
1117
+ .attr('text-anchor', 'middle')
1118
+ .style('font-size', '11px')
1119
+ .style('font-weight', '600')
1120
+ .style('fill', toChartColor(series.color))
1121
+ .attr('stroke', '#fff')
1122
+ .attr('stroke-width', 3)
1123
+ .attr('paint-order', 'stroke fill')
1124
+ .style('pointer-events', 'none')
1125
+ .text((d) => format('~s')(d.value));
1126
+ }
1127
+ }
1128
+ }
1129
+ openTooltip(el, point, series) {
1130
+ const rect = el.getBoundingClientRect();
1131
+ const container = this.chartRef().nativeElement.getBoundingClientRect();
1132
+ this.tooltipPosition.set({
1133
+ x: rect.left - container.left + rect.width / 2,
1134
+ y: (this.tooltipOrientation() === 'top' ? rect.top : rect.bottom) - container.top,
1135
+ });
1136
+ this.selectedItem.set({ ...point, seriesKey: series.key, seriesLabel: series.label, color: series.color });
1137
+ }
1138
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: LineChartComponent, deps: null, target: i0.ɵɵFactoryTarget.Component }); }
1139
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.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 } }, 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 [svgRef]=\"svgElement()\" [filename]=\"exportFilename()\" [buttonTitle]=\"exportButtonTitle()\" />\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"] }, { 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 }); }
1140
+ }
1141
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: LineChartComponent, decorators: [{
1142
+ type: Component,
1143
+ 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 [svgRef]=\"svgElement()\" [filename]=\"exportFilename()\" [buttonTitle]=\"exportButtonTitle()\" />\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"] }]
1144
+ }] });
1145
+
1146
+ class AreaChartComponent extends ChartBase {
1147
+ constructor() {
1148
+ super(...arguments);
1149
+ this.series = input.required();
1150
+ this.tooltipOrientation = input('top');
1151
+ this.showLegend = input(true);
1152
+ this.showExportButton = input(false);
1153
+ this.exportButtonTitle = input('Export');
1154
+ this.exportFilename = input('area-chart');
1155
+ /** Area fill opacity (0–1). Default: 0.2. */
1156
+ this.areaOpacity = input(0.2);
1157
+ this.noItemsMessage = input(NO_ITEMS_MESSAGE);
1158
+ this.tooltipPercentOfTotal = input('of total');
1159
+ /** Optional label rendered below the X axis. */
1160
+ this.xAxisLabel = input('');
1161
+ /** Optional label rendered rotated to the left of the Y axis. */
1162
+ this.yAxisLabel = input('');
1163
+ this.valueClicked = output();
1164
+ this.MARGIN = { top: 20, right: 30, bottom: 30, left: 65 };
1165
+ this.hasData = computed(() => this.series().some(s => s.data.length > 0));
1166
+ this.total = computed(() => this.series().reduce((acc, s) => acc + s.data.reduce((a, d) => a + d.value, 0), 0));
1167
+ this.alertMessageAndType = computed(() => {
1168
+ if (this.loading()) {
1169
+ return undefined;
1170
+ }
1171
+ if (!this.hasData()) {
1172
+ return [this.noItemsMessage(), NO_ITEMS_ALERT_TYPE];
1173
+ }
1174
+ return undefined;
1175
+ });
1176
+ this.legendItems = computed(() => {
1177
+ if (!this.showLegend()) {
1178
+ return [];
1179
+ }
1180
+ return this.series().map(s => ({ label: s.label, color: s.color }));
1181
+ });
1182
+ }
1183
+ ngOnChanges(_changes) {
1184
+ if (!this.svg) {
1185
+ return;
1186
+ }
1187
+ requestAnimationFrame(() => this.updateChart());
1188
+ }
1189
+ ngAfterViewInit() {
1190
+ this.svg = select(this.chartRef().nativeElement);
1191
+ super.ngAfterViewInit();
1192
+ }
1193
+ updateChart() {
1194
+ this.svg.selectAll('*').remove();
1195
+ if (this.loading() || !this.hasData()) {
1196
+ return;
1197
+ }
1198
+ const { width: containerWidth, height: containerHeight } = this.getContainerDimensions();
1199
+ const extraBottom = this.xAxisLabel() ? 16 : 0;
1200
+ const extraLeft = this.yAxisLabel() ? 16 : 0;
1201
+ const effectiveLeft = this.MARGIN.left + extraLeft;
1202
+ const width = containerWidth - effectiveLeft - this.MARGIN.right;
1203
+ const height = containerHeight - this.MARGIN.top - (this.MARGIN.bottom + extraBottom);
1204
+ const xKeys = [...new Set(this.series().flatMap(s => s.data.map(d => d.x)))];
1205
+ const xLabelMap = new Map();
1206
+ this.series().forEach(s => s.data.forEach(d => xLabelMap.set(d.x, d.xLabel ?? d.x)));
1207
+ const x = scalePoint().domain(xKeys).range([0, width]).padding(0.5);
1208
+ const maxY = max(this.series().flatMap(s => s.data.map(d => d.value))) ?? 0;
1209
+ const y = scaleLinear().domain([0, maxY]).nice().range([height, 0]);
1210
+ const g = this.svg
1211
+ .attr('width', width)
1212
+ .attr('height', height)
1213
+ .append('g')
1214
+ .attr('transform', `translate(${effectiveLeft},${this.MARGIN.top})`);
1215
+ drawXYAxes(g, x, y, {
1216
+ xLabelMap,
1217
+ width,
1218
+ height,
1219
+ xAxisLabel: this.xAxisLabel(),
1220
+ yAxisLabel: this.yAxisLabel(),
1221
+ effectiveLeft,
1222
+ });
1223
+ styleGridLines(g);
1224
+ this.drawSeries(g, x, y, height);
1225
+ }
1226
+ drawSeries(g, x, y, height) {
1227
+ const areaGenerator = area()
1228
+ .x((d) => x(d.x) ?? 0)
1229
+ .y0(height)
1230
+ .y1((d) => y(d.value))
1231
+ .curve(curveMonotoneX);
1232
+ const lineGenerator = line()
1233
+ .x((d) => x(d.x) ?? 0)
1234
+ .y((d) => y(d.value))
1235
+ .curve(curveMonotoneX);
1236
+ // Draw areas first (behind lines)
1237
+ for (const series of this.series()) {
1238
+ if (!series.data.length) {
1239
+ continue;
1240
+ }
1241
+ g.append('path')
1242
+ .datum(series.data)
1243
+ .attr('class', `area area-${series.key}`)
1244
+ .style('fill', toChartColor(series.color))
1245
+ .attr('fill-opacity', this.areaOpacity())
1246
+ .attr('stroke', 'none')
1247
+ .attr('d', areaGenerator);
1248
+ }
1249
+ // Draw lines and dots on top
1250
+ for (const series of this.series()) {
1251
+ if (!series.data.length) {
1252
+ continue;
1253
+ }
1254
+ g.append('path')
1255
+ .datum(series.data)
1256
+ .attr('class', `line line-${series.key}`)
1257
+ .attr('fill', 'none')
1258
+ .style('stroke', toChartColor(series.color))
1259
+ .attr('stroke-width', 2)
1260
+ .attr('stroke-linejoin', 'round')
1261
+ .attr('stroke-linecap', 'round')
1262
+ .attr('d', lineGenerator);
1263
+ renderDots(g, series, x, y, (el, point, s) => this.openTooltip(el, point, s));
1264
+ }
1265
+ }
1266
+ openTooltip(el, point, series) {
1267
+ const rect = el.getBoundingClientRect();
1268
+ const container = this.chartRef().nativeElement.getBoundingClientRect();
1269
+ this.tooltipPosition.set({
1270
+ x: rect.left - container.left + rect.width / 2,
1271
+ y: (this.tooltipOrientation() === 'top' ? rect.top : rect.bottom) - container.top,
1272
+ });
1273
+ this.selectedItem.set({ ...point, seriesKey: series.key, seriesLabel: series.label, color: series.color });
1274
+ }
1275
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: AreaChartComponent, deps: null, target: i0.ɵɵFactoryTarget.Component }); }
1276
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.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 [svgRef]=\"svgElement()\" [filename]=\"exportFilename()\" [buttonTitle]=\"exportButtonTitle()\" />\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"] }, { 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 }); }
1277
+ }
1278
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: AreaChartComponent, decorators: [{
1279
+ type: Component,
1280
+ 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 [svgRef]=\"svgElement()\" [filename]=\"exportFilename()\" [buttonTitle]=\"exportButtonTitle()\" />\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"] }]
1281
+ }] });
1282
+
1283
+ class ComboChartComponent extends ChartBase {
1284
+ constructor() {
1285
+ super(...arguments);
1286
+ // ── Inputs ──────────────────────────────────────────────────────────────────
1287
+ this.barSeries = input([]);
1288
+ this.lineSeries = input([]);
1289
+ this.tooltipOrientation = input('top');
1290
+ this.showLegend = input(true);
1291
+ this.showExportButton = input(false);
1292
+ this.exportButtonTitle = input('Export');
1293
+ this.exportFilename = input('combo-chart');
1294
+ this.noItemsMessage = input(NO_ITEMS_MESSAGE);
1295
+ this.tooltipPercentOfTotal = input('of total');
1296
+ /** Optional label rendered below the X axis. */
1297
+ this.xAxisLabel = input('');
1298
+ /** Optional label rendered rotated to the left of the Y axis (bar scale). */
1299
+ this.yAxisLabel = input('');
1300
+ /** Optional label rendered rotated to the right of the Y axis (line scale). */
1301
+ this.yLineAxisLabel = input('');
1302
+ // ── Outputs ─────────────────────────────────────────────────────────────────
1303
+ this.valueClicked = output();
1304
+ // ── Layout ───────────────────────────────────────────────────────────────────
1305
+ this.MARGIN = { top: 20, right: 30, bottom: 30, left: 65 };
1306
+ // ── Computed ─────────────────────────────────────────────────────────────────
1307
+ this.hasData = computed(() => this.barSeries().some(s => s.data.length > 0) || this.lineSeries().some(s => s.data.length > 0));
1308
+ this.total = computed(() => {
1309
+ const barTotal = this.barSeries().reduce((acc, s) => acc + s.data.reduce((a, d) => a + d.value, 0), 0);
1310
+ const lineTotal = this.lineSeries().reduce((acc, s) => acc + s.data.reduce((a, d) => a + d.value, 0), 0);
1311
+ return barTotal + lineTotal;
1312
+ });
1313
+ this.alertMessageAndType = computed(() => {
1314
+ if (this.loading()) {
1315
+ return undefined;
1316
+ }
1317
+ if (!this.hasData()) {
1318
+ return [this.noItemsMessage(), NO_ITEMS_ALERT_TYPE];
1319
+ }
1320
+ return undefined;
1321
+ });
1322
+ this.legendItems = computed(() => {
1323
+ if (!this.showLegend()) {
1324
+ return [];
1325
+ }
1326
+ const barItems = this.barSeries().map(s => ({ label: s.label, color: s.color }));
1327
+ const lineItems = this.lineSeries().map(s => ({ label: s.label, color: s.color }));
1328
+ return [...barItems, ...lineItems];
1329
+ });
1330
+ }
1331
+ // ── Lifecycle ────────────────────────────────────────────────────────────────
1332
+ ngOnChanges(_changes) {
1333
+ if (!this.svg) {
1334
+ return;
1335
+ }
1336
+ requestAnimationFrame(() => this.updateChart());
1337
+ }
1338
+ ngAfterViewInit() {
1339
+ this.svg = select(this.chartRef().nativeElement);
1340
+ super.ngAfterViewInit();
1341
+ }
1342
+ // ── Chart rendering ──────────────────────────────────────────────────────────
1343
+ updateChart() {
1344
+ this.svg.selectAll('*').remove();
1345
+ if (this.loading() || !this.hasData()) {
1346
+ return;
1347
+ }
1348
+ const { width: cw, height: ch } = this.getContainerDimensions();
1349
+ const extraBottom = this.xAxisLabel() ? 16 : 0;
1350
+ const extraLeft = this.yAxisLabel() ? 16 : 0;
1351
+ const effectiveLeft = this.MARGIN.left + extraLeft;
1352
+ // Reserve extra right margin when line series are present (right Y axis)
1353
+ const hasLines = this.lineSeries().some(s => s.data.length > 0);
1354
+ const extraRight = hasLines ? (this.yLineAxisLabel() ? 50 : 40) : 0;
1355
+ const width = cw - effectiveLeft - this.MARGIN.right - extraRight;
1356
+ const height = ch - this.MARGIN.top - (this.MARGIN.bottom + extraBottom);
1357
+ // Collect all unique X keys preserving order
1358
+ const allBarKeys = this.barSeries().flatMap(s => s.data.map(d => d.x));
1359
+ const allLineKeys = this.lineSeries().flatMap(s => s.data.map(d => d.x));
1360
+ const xKeys = [...new Set([...allBarKeys, ...allLineKeys])];
1361
+ // Build X-label map
1362
+ const xLabelMap = new Map();
1363
+ this.barSeries().forEach(s => s.data.forEach(d => xLabelMap.set(d.x, d.xLabel ?? d.x)));
1364
+ // Outer X scale (one band per category)
1365
+ const x = scaleBand().domain(xKeys).range([0, width]).paddingInner(0.35).paddingOuter(0.15);
1366
+ // ── Separate Y scales ────────────────────────────────────────────────────
1367
+ // Bar Y scale (left axis)
1368
+ const xStackTotals = new Map();
1369
+ for (const series of this.barSeries()) {
1370
+ for (const point of series.data) {
1371
+ xStackTotals.set(point.x, (xStackTotals.get(point.x) ?? 0) + point.value);
1372
+ }
1373
+ }
1374
+ const maxBar = max([...xStackTotals.values()]) ?? 0;
1375
+ const yBar = scaleLinear()
1376
+ .domain([0, maxBar || 1])
1377
+ .nice()
1378
+ .range([height, 0]);
1379
+ // Line Y scale (right axis)
1380
+ const maxLine = max(this.lineSeries().flatMap(s => s.data.map(d => d.value))) ?? 0;
1381
+ const yLine = scaleLinear()
1382
+ .domain([0, maxLine || 1])
1383
+ .nice()
1384
+ .range([height, 0]);
1385
+ const g = this.svg
1386
+ .attr('width', width)
1387
+ .attr('height', height)
1388
+ .append('g')
1389
+ .attr('transform', `translate(${effectiveLeft},${this.MARGIN.top})`);
1390
+ drawXYAxes(g, x, yBar, {
1391
+ xLabelMap,
1392
+ width,
1393
+ height,
1394
+ xAxisLabel: this.xAxisLabel(),
1395
+ yAxisLabel: this.yAxisLabel(),
1396
+ effectiveLeft,
1397
+ });
1398
+ styleGridLines(g);
1399
+ // ── Right Y axis for line series ─────────────────────────────────────────
1400
+ if (hasLines) {
1401
+ const lineTickValues = yLine.ticks(5).filter((t) => Number.isInteger(t));
1402
+ const rightAxisG = g
1403
+ .append('g')
1404
+ .attr('transform', `translate(${width},0)`)
1405
+ .call(axisRight(yLine).tickValues(lineTickValues).tickFormat(format('~s')).tickSize(-width) // draw grid lines across the chart
1406
+ );
1407
+ rightAxisG.selectAll('text').style('font-size', '11px').style('fill', 'var(--clr-color-neutral-600, #666)');
1408
+ // Style the line-axis grid lines with a distinct dashed color
1409
+ rightAxisG
1410
+ .selectAll('.tick line')
1411
+ .style('stroke', 'var(--clr-color-neutral-400, #b3b3b3)')
1412
+ .style('stroke-dasharray', '4 3')
1413
+ .style('stroke-opacity', '0.8');
1414
+ // Remove the default domain line so it doesn't overlap the chart
1415
+ rightAxisG.select('.domain').remove();
1416
+ if (this.yLineAxisLabel()) {
1417
+ g.append('text')
1418
+ .attr('transform', 'rotate(-90)')
1419
+ .attr('x', -height / 2)
1420
+ .attr('y', width + extraRight - 4)
1421
+ .attr('text-anchor', 'middle')
1422
+ .style('font-size', '12px')
1423
+ .style('fill', 'var(--clr-color-neutral-600, #666)')
1424
+ .text(this.yLineAxisLabel());
1425
+ }
1426
+ }
1427
+ this.drawBars(g, x, yBar);
1428
+ this.drawLines(g, x, yLine);
1429
+ }
1430
+ drawBars(g, x, y) {
1431
+ // Running stack base (cumulative sum) per X key across all bar series
1432
+ const stackBase = new Map();
1433
+ for (const series of this.barSeries()) {
1434
+ if (!series.data.length) {
1435
+ continue;
1436
+ }
1437
+ const stacked = series.data.map(d => ({
1438
+ ...d,
1439
+ base: stackBase.get(d.x) ?? 0,
1440
+ }));
1441
+ // Advance running totals for the next series
1442
+ for (const d of series.data) {
1443
+ stackBase.set(d.x, (stackBase.get(d.x) ?? 0) + d.value);
1444
+ }
1445
+ g.selectAll(`.bar-${series.key}`)
1446
+ .data(stacked, (d) => d.x)
1447
+ .join('rect')
1448
+ .attr('class', `bar bar-${series.key}`)
1449
+ .attr('x', (d) => x(d.x) ?? 0)
1450
+ .attr('y', (d) => y(d.base + d.value))
1451
+ .attr('width', x.bandwidth())
1452
+ .attr('height', (d) => y(d.base) - y(d.base + d.value))
1453
+ .attr('rx', 2)
1454
+ .attr('ry', 2)
1455
+ .style('fill', toChartColor(series.color))
1456
+ .style('cursor', 'pointer')
1457
+ .on('mouseover', (e) => {
1458
+ select(e.currentTarget).attr('fill-opacity', 0.75);
1459
+ })
1460
+ .on('mouseout', (e) => {
1461
+ select(e.currentTarget).attr('fill-opacity', 1);
1462
+ })
1463
+ .on('click', (event, d) => {
1464
+ event.stopPropagation();
1465
+ const rect = event.currentTarget.getBoundingClientRect();
1466
+ const container = this.chartRef().nativeElement.getBoundingClientRect();
1467
+ this.tooltipPosition.set({
1468
+ x: rect.left - container.left + rect.width / 2,
1469
+ y: (this.tooltipOrientation() === 'top' ? rect.top : rect.bottom) - container.top,
1470
+ });
1471
+ this.selectedItem.set({
1472
+ seriesKey: series.key,
1473
+ seriesLabel: series.label,
1474
+ seriesType: 'bar',
1475
+ color: series.color,
1476
+ x: d.x,
1477
+ xLabel: d.xLabel,
1478
+ value: d.value,
1479
+ total: this.total(),
1480
+ });
1481
+ });
1482
+ }
1483
+ }
1484
+ drawLines(g, x, y) {
1485
+ // Map X key → band center position for line/dot placement
1486
+ const lineX = (key) => (x(key) ?? 0) + x.bandwidth() / 2;
1487
+ const lineGenerator = line()
1488
+ .x((d) => lineX(d.x))
1489
+ .y((d) => y(d.value))
1490
+ .curve(curveMonotoneX);
1491
+ for (const series of this.lineSeries()) {
1492
+ if (!series.data.length) {
1493
+ continue;
1494
+ }
1495
+ // Line path
1496
+ g.append('path')
1497
+ .datum(series.data)
1498
+ .attr('class', `combo-line combo-line-${series.key}`)
1499
+ .attr('fill', 'none')
1500
+ .style('stroke', toChartColor(series.color))
1501
+ .attr('stroke-width', 2.5)
1502
+ .attr('stroke-linejoin', 'round')
1503
+ .attr('stroke-linecap', 'round')
1504
+ .attr('d', lineGenerator);
1505
+ // Dots
1506
+ g.selectAll(`.combo-dot-${series.key}`)
1507
+ .data(series.data, (d) => d.x)
1508
+ .join('circle')
1509
+ .attr('class', `combo-dot combo-dot-${series.key}`)
1510
+ .attr('cx', (d) => lineX(d.x))
1511
+ .attr('cy', (d) => y(d.value))
1512
+ .attr('r', 4)
1513
+ .style('fill', toChartColor(series.color))
1514
+ .attr('stroke', '#fff')
1515
+ .attr('stroke-width', 2)
1516
+ .style('cursor', 'pointer')
1517
+ .on('mouseover', (e) => {
1518
+ select(e.currentTarget).attr('r', 6);
1519
+ })
1520
+ .on('mouseout', (e) => {
1521
+ select(e.currentTarget).attr('r', 4);
1522
+ })
1523
+ .on('click', (event, d) => {
1524
+ event.stopPropagation();
1525
+ const el = event.currentTarget;
1526
+ const rect = el.getBoundingClientRect();
1527
+ const container = this.chartRef().nativeElement.getBoundingClientRect();
1528
+ this.tooltipPosition.set({
1529
+ x: rect.left - container.left + rect.width / 2,
1530
+ y: (this.tooltipOrientation() === 'top' ? rect.top : rect.bottom) - container.top,
1531
+ });
1532
+ this.selectedItem.set({
1533
+ seriesKey: series.key,
1534
+ seriesLabel: series.label,
1535
+ seriesType: 'line',
1536
+ color: series.color,
1537
+ x: d.x,
1538
+ value: d.value,
1539
+ total: this.total(),
1540
+ });
1541
+ });
1542
+ }
1543
+ }
1544
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: ComboChartComponent, deps: null, target: i0.ɵɵFactoryTarget.Component }); }
1545
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.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 } }, 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 [svgRef]=\"svgElement()\" [filename]=\"exportFilename()\" [buttonTitle]=\"exportButtonTitle()\" />\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"] }, { 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 }); }
1546
+ }
1547
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: ComboChartComponent, decorators: [{
1548
+ type: Component,
1549
+ 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 [svgRef]=\"svgElement()\" [filename]=\"exportFilename()\" [buttonTitle]=\"exportButtonTitle()\" />\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"] }]
1550
+ }] });
1551
+
1552
+ class PieChartComponent extends ChartBase {
1553
+ constructor() {
1554
+ super(...arguments);
1555
+ this.data = input.required();
1556
+ this.donut = input(true);
1557
+ this.showLegend = input(true);
1558
+ this.showExportButton = input(false);
1559
+ this.exportButtonTitle = input('Export');
1560
+ this.exportFilename = input('pie-chart');
1561
+ this.tooltipOrientation = input('top');
1562
+ this.noItemsMessage = input(NO_ITEMS_MESSAGE);
1563
+ this.tooltipPercentOfTotal = input('of total');
1564
+ this.valueClicked = output();
1565
+ this.hasData = computed(() => this.data()?.some(d => d.value > 0));
1566
+ this.total = computed(() => this.data()?.reduce((acc, d) => acc + d.value, 0) ?? 0);
1567
+ this.alertMessageAndType = computed(() => {
1568
+ if (this.loading()) {
1569
+ return undefined;
1570
+ }
1571
+ if (!this.hasData()) {
1572
+ return [this.noItemsMessage(), NO_ITEMS_ALERT_TYPE];
1573
+ }
1574
+ return undefined;
1575
+ });
1576
+ this.legendItems = computed(() => {
1577
+ if (!this.showLegend()) {
1578
+ return [];
1579
+ }
1580
+ return (this.data() ?? []).filter(d => d.value > 0).map(d => ({ label: d.fullLabel ?? d.label, color: d.color }));
1581
+ });
1582
+ }
1583
+ ngOnChanges(_changes) {
1584
+ if (!this.svg) {
1585
+ return;
1586
+ }
1587
+ requestAnimationFrame(() => this.updateChart());
1588
+ }
1589
+ ngAfterViewInit() {
1590
+ this.svg = select(this.chartRef().nativeElement);
1591
+ super.ngAfterViewInit();
1592
+ }
1593
+ updateChart() {
1594
+ this.svg.selectAll('*').remove();
1595
+ if (this.loading() || !this.hasData()) {
1596
+ return;
1597
+ }
1598
+ const { width, height } = this.getContainerDimensions();
1599
+ const radius = Math.min(width, height) / 2;
1600
+ const innerRadius = this.donut() ? radius * 0.55 : 0;
1601
+ const outerRadius = radius * 0.9;
1602
+ this.arcGen = arc().innerRadius(innerRadius).outerRadius(outerRadius);
1603
+ const hoverArc = arc()
1604
+ .innerRadius(innerRadius)
1605
+ .outerRadius(outerRadius * 1.06);
1606
+ const pieGen = pie()
1607
+ .value(d => d.value)
1608
+ .sort(null);
1609
+ const arcs = pieGen(this.data().filter(d => d.value > 0));
1610
+ const g = this.svg
1611
+ .attr('width', width)
1612
+ .attr('height', height)
1613
+ .append('g')
1614
+ .attr('transform', `translate(${width / 2},${height / 2})`);
1615
+ // Slices
1616
+ g.selectAll('.slice')
1617
+ .data(arcs, (d) => d.data.key)
1618
+ .join('path')
1619
+ .attr('class', 'slice')
1620
+ .attr('d', this.arcGen)
1621
+ .style('fill', (d) => toChartColor(d.data.color))
1622
+ .attr('stroke', '#fff')
1623
+ .attr('stroke-width', 2)
1624
+ .style('cursor', 'pointer')
1625
+ .on('mouseover', (e) => {
1626
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1627
+ select(e.currentTarget).attr('d', hoverArc);
1628
+ })
1629
+ .on('mouseout', (e) => {
1630
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1631
+ select(e.currentTarget).attr('d', this.arcGen);
1632
+ })
1633
+ .on('click', (event, d) => {
1634
+ event.stopPropagation();
1635
+ this.openTooltip(d, width, height);
1636
+ });
1637
+ // Slice labels – only show when the slice angle is large enough to fit text
1638
+ const MIN_ANGLE = 0.4; // ~23°
1639
+ const labelRadius = this.donut() ? (innerRadius + outerRadius) / 2 : outerRadius * 0.65;
1640
+ const labelArc = arc().innerRadius(labelRadius).outerRadius(labelRadius);
1641
+ const total = this.total();
1642
+ const labelGroups = g
1643
+ .selectAll('.slice-label')
1644
+ .data(arcs.filter((d) => d.endAngle - d.startAngle >= MIN_ANGLE), (d) => d.data.key)
1645
+ .join('g')
1646
+ .attr('class', 'slice-label')
1647
+ .attr('transform', (d) => {
1648
+ const [x, y] = labelArc.centroid(d);
1649
+ return `translate(${x},${y})`;
1650
+ })
1651
+ .style('pointer-events', 'none');
1652
+ labelGroups
1653
+ .append('text')
1654
+ .attr('class', 'label-value')
1655
+ .attr('text-anchor', 'middle')
1656
+ .attr('dy', '-0.3em')
1657
+ .style('font-size', '11px')
1658
+ .style('font-weight', '600')
1659
+ .style('fill', '#fff')
1660
+ .style('pointer-events', 'none')
1661
+ .text((d) => format('~s')(d.data.value));
1662
+ labelGroups
1663
+ .append('text')
1664
+ .attr('class', 'label-percent')
1665
+ .attr('text-anchor', 'middle')
1666
+ .attr('dy', '0.9em')
1667
+ .style('font-size', '10px')
1668
+ .style('fill', 'rgba(255, 255, 255, 0.9)')
1669
+ .style('pointer-events', 'none')
1670
+ .text((d) => `${((100 * d.data.value) / total).toFixed(1)}%`);
1671
+ // Center label (donut only)
1672
+ if (this.donut()) {
1673
+ g.append('text')
1674
+ .attr('class', 'center-label')
1675
+ .attr('text-anchor', 'middle')
1676
+ .attr('dy', '-0.1em')
1677
+ .style('font-size', '1.4rem')
1678
+ .style('font-weight', '600')
1679
+ .style('fill', 'var(--clr-color-neutral-900, #21333b)')
1680
+ .text(format('~s')(this.total()));
1681
+ g.append('text')
1682
+ .attr('class', 'center-sublabel')
1683
+ .attr('text-anchor', 'middle')
1684
+ .attr('dy', '1.2em')
1685
+ .style('font-size', '0.65rem')
1686
+ .style('fill', 'var(--clr-color-neutral-600, #666)')
1687
+ .style('text-transform', 'uppercase')
1688
+ .style('letter-spacing', '0.05em')
1689
+ .text('Total');
1690
+ }
1691
+ }
1692
+ openTooltip(d, width, height) {
1693
+ const [cx, cy] = this.arcGen.centroid(d);
1694
+ this.tooltipPosition.set({
1695
+ x: width / 2 + cx,
1696
+ y: height / 2 + cy,
1697
+ });
1698
+ this.selectedItem.set(d.data);
1699
+ }
1700
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: PieChartComponent, deps: null, target: i0.ɵɵFactoryTarget.Component }); }
1701
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.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 [svgRef]=\"svgElement()\" [filename]=\"exportFilename()\" [buttonTitle]=\"exportButtonTitle()\" />\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(--clr-color-neutral-900, #21333b)}.center-sublabel{font-size:.65rem;fill:var(--clr-color-neutral-600, #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"] }, { 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 }); }
1702
+ }
1703
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: PieChartComponent, decorators: [{
1704
+ type: Component,
1705
+ 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 [svgRef]=\"svgElement()\" [filename]=\"exportFilename()\" [buttonTitle]=\"exportButtonTitle()\" />\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(--clr-color-neutral-900, #21333b)}.center-sublabel{font-size:.65rem;fill:var(--clr-color-neutral-600, #666);text-transform:uppercase;letter-spacing:.05em}\n"] }]
1706
+ }] });
1707
+
1708
+ /*
1709
+ * Copyright (c) 2018-2026 Porsche Informatik. All Rights Reserved.
1710
+ * This software is released under MIT license.
1711
+ * The full license information can be found in LICENSE in the root directory of this project.
1712
+ */
1713
+ // ─── Constants ────────────────────────────────────────────────────────────────
1714
+ const DEFAULT_SECTION_COLORS = ['#009ADB', '#66D1FF', '#D9D9D9', '#A6A6A6'];
1715
+ const DEFAULT_TEXT_COLORS = ['#eee', '#eee', '#555', '#eee'];
1716
+ const DEFAULT_CHART_LABELS = {
1717
+ total: 'Total',
1718
+ all: 'All',
1719
+ };
1720
+ class FunnelChartComponent extends ChartBase {
1721
+ constructor() {
1722
+ super(...arguments);
1723
+ // ── Inputs ──────────────────────────────────────────────────────────────────
1724
+ this.data = input([]);
1725
+ this.showExportButton = input(false);
1726
+ this.exportButtonTitle = input('Export');
1727
+ this.exportFilename = input('funnel-chart');
1728
+ /** Rendering mode. 'default' = horizontal bars with sections; 'centered' = centered trapezoid funnel. */
1729
+ this.orientation = input('default');
1730
+ /** Width reserved on each side for labels (px). */
1731
+ this.textSize = input(200);
1732
+ /** Gap between funnel bars and their side-line labels (px). */
1733
+ this.lineTextPadding = input(8);
1734
+ /** Vertical gap between funnel bars – default mode only (px). */
1735
+ this.barGap = input(2);
1736
+ /** Vertical padding inside the side measurement lines (px). */
1737
+ this.sideLineVerticalPadding = input(4);
1738
+ // ── Centered-mode specific inputs ────────────────────────────────────────────
1739
+ /** Minimum length of the horizontal measurement lines on each side (px). Centered mode only. */
1740
+ this.minLineSize = input(30);
1741
+ /** Ratio of bar height to spacer height. Centered mode only. */
1742
+ this.barToSpacerRatio = input(2);
1743
+ /** Vertical padding inside the center divider lines (px). Centered mode only. */
1744
+ this.middleLineVerticalPadding = input(4);
1745
+ /** Fill color of the centered-mode bars. Accepts hex or CSS custom property. Centered mode only. */
1746
+ this.barColor = input(DEFAULT_SECTION_COLORS[0]);
1747
+ /** Fill color of the trapezoid spacers. Accepts hex or CSS custom property. Centered mode only. */
1748
+ this.spacerColor = input('#D9D9D9');
1749
+ /**
1750
+ * Override the chart labels shown in the tooltip (total / all rows).
1751
+ * Partial – any key not provided falls back to the English default.
1752
+ */
1753
+ this.chartLabels = input({});
1754
+ /**
1755
+ * Override section colors by section key.
1756
+ * Accepts hex values ('#009ADB') or CSS custom properties ('--cds-global-color-blue-800').
1757
+ * Per-section `color` in the data takes priority over this map; this map takes priority over
1758
+ * the built-in default palette.
1759
+ */
1760
+ this.sectionColors = input({});
1761
+ // ── Outputs ─────────────────────────────────────────────────────────────────
1762
+ this.valueClicked = output();
1763
+ // ── Computed ─────────────────────────────────────────────────────────────────
1764
+ this.resolvedChartLabels = computed(() => ({
1765
+ ...DEFAULT_CHART_LABELS,
1766
+ ...this.chartLabels(),
1767
+ }));
1768
+ /** CSS-ready color for the centered-mode bar. */
1769
+ this.centeredBarCssColor = computed(() => toChartColor(this.barColor()));
1770
+ /** CSS-ready spacer color. */
1771
+ this.centeredSpacerCssColor = computed(() => toChartColor(this.spacerColor()));
1772
+ this.total = computed(() => this.data()[0]?.value ?? 0);
1773
+ this.hasData = computed(() => this.data().length > 0);
1774
+ // ── State ─────────────────────────────────────────────────────────────────────
1775
+ this.selectedItemLabel = computed(() => this.selectedItem()?.fullLabel ?? this.selectedItem()?.label ?? '');
1776
+ this.selectedSpacerItem = signal(undefined);
1777
+ this.sectionTooltips = signal([]);
1778
+ }
1779
+ // ── Lifecycle ────────────────────────────────────────────────────────────────
1780
+ ngOnChanges(_changes) {
1781
+ if (!this.svg) {
1782
+ return;
1783
+ }
1784
+ requestAnimationFrame(() => this.updateChart());
1785
+ }
1786
+ ngAfterViewInit() {
1787
+ this.svg = select(this.chartRef().nativeElement);
1788
+ super.ngAfterViewInit();
1789
+ }
1790
+ // ── Chart rendering ──────────────────────────────────────────────────────────
1791
+ updateChart() {
1792
+ if (!this.svg) {
1793
+ return;
1794
+ }
1795
+ this.svg.selectAll('*').remove();
1796
+ if (this.loading() || !this.hasData()) {
1797
+ return;
1798
+ }
1799
+ const { width: containerWidth, height: containerHeight } = this.getContainerDimensions();
1800
+ if (this.orientation() === 'centered') {
1801
+ this.renderCenteredChart(containerWidth, containerHeight);
1802
+ }
1803
+ else {
1804
+ const funnelWidth = this.getFunnelWidth(containerWidth);
1805
+ const barHeight = this.getBarHeight(containerHeight);
1806
+ const dataPoints = this.calculateDataPoints(funnelWidth, barHeight);
1807
+ this.renderRightSideText(dataPoints, containerWidth, barHeight);
1808
+ this.renderLeftSideText(dataPoints, barHeight);
1809
+ this.renderSideLines(dataPoints, containerWidth, funnelWidth, barHeight);
1810
+ this.renderFunnel(dataPoints, containerWidth, containerHeight, funnelWidth, barHeight);
1811
+ }
1812
+ }
1813
+ // ── Data calculation ─────────────────────────────────────────────────────────
1814
+ calculateDataPoints(funnelWidth, barHeight) {
1815
+ const maxValue = max(this.data(), d => d.value) ?? 1;
1816
+ const widthScale = scaleLinear().domain([0, maxValue]).range([0, funnelWidth]);
1817
+ const sectionColorOverrides = this.sectionColors();
1818
+ return this.data().map((d, i) => {
1819
+ let xOffset = 0;
1820
+ const sections = (d.sections ?? []).map((s, si) => {
1821
+ const rawColor = s.color ?? sectionColorOverrides[s.key] ?? DEFAULT_SECTION_COLORS[si % DEFAULT_SECTION_COLORS.length];
1822
+ const cssColor = toChartColor(rawColor);
1823
+ const hoverColor = rawColor.startsWith('--')
1824
+ ? cssColor
1825
+ : color(rawColor)?.darker(0.5)?.formatHex() ?? cssColor;
1826
+ const textColor = s.textColor ?? DEFAULT_TEXT_COLORS[si % DEFAULT_TEXT_COLORS.length];
1827
+ const sWidth = widthScale(s.value);
1828
+ const sec = {
1829
+ key: s.key,
1830
+ label: s.label,
1831
+ x: xOffset,
1832
+ width: sWidth,
1833
+ value: s.value,
1834
+ percentage: d.value ? this.round((s.value / d.value) * 100) : 0,
1835
+ cssColor,
1836
+ hoverColor,
1837
+ textColor,
1838
+ };
1839
+ xOffset += sWidth;
1840
+ return sec;
1841
+ });
1842
+ const point = {
1843
+ ...d,
1844
+ sections,
1845
+ y: i * (barHeight + this.barGap()),
1846
+ width: widthScale(d.value),
1847
+ percentage: this.total() ? this.round((d.value / this.total()) * 100) : 0,
1848
+ };
1849
+ if (i > 0) {
1850
+ const previousValue = this.data()[i - 1].value;
1851
+ point.delta = previousValue - d.value;
1852
+ point.deltaPercentage = previousValue === 0 ? 0 : this.round((point.delta / previousValue) * 100);
1853
+ }
1854
+ return point;
1855
+ });
1856
+ }
1857
+ // ── Rendering helpers ────────────────────────────────────────────────────────
1858
+ renderRightSideText(dataPoints, containerWidth, barHeight) {
1859
+ const rightG = this.svg.append('g').attr('transform', `translate(${containerWidth - this.textSize()},0)`);
1860
+ dataPoints.forEach((d, i) => {
1861
+ if (i === 0) {
1862
+ return;
1863
+ }
1864
+ const prev = dataPoints[i - 1];
1865
+ const labelY = (prev.y + barHeight + d.y) / 2 + 4;
1866
+ rightG
1867
+ .append('text')
1868
+ .attr('x', 0)
1869
+ .attr('y', labelY)
1870
+ .attr('text-anchor', 'start')
1871
+ .style('font-size', '12px')
1872
+ .style('fill', 'var(--clr-color-neutral-600, #666)')
1873
+ .text(`-${this.round(d.deltaPercentage)}% (${d.delta})`);
1874
+ });
1875
+ }
1876
+ renderLeftSideText(dataPoints, barHeight) {
1877
+ const leftG = this.svg.append('g');
1878
+ const ICON_SPACE = 20;
1879
+ const positions = [];
1880
+ dataPoints.forEach(d => {
1881
+ const labelY = (d.y + barHeight + d.y) / 2 + 4;
1882
+ const iconSpacing = d.description ? ICON_SPACE : 0;
1883
+ const textLength = leftG
1884
+ .append('text')
1885
+ .attr('x', this.textSize() - iconSpacing)
1886
+ .attr('y', labelY - 8)
1887
+ .attr('text-anchor', 'end')
1888
+ .style('font-size', '13px')
1889
+ .style('font-weight', '600')
1890
+ .style('fill', 'var(--clr-color-neutral-900, #21333b)')
1891
+ .style('cursor', 'pointer')
1892
+ .text(d.label)
1893
+ .on('click', () => this.valueClicked.emit({ value: d.value, label: d.label, key: d.key }))
1894
+ .node()
1895
+ .getComputedTextLength();
1896
+ if (d.description) {
1897
+ positions.push({
1898
+ x: this.textSize() - iconSpacing + 5,
1899
+ y: labelY - 24,
1900
+ key: d.key,
1901
+ description: d.description,
1902
+ });
1903
+ }
1904
+ leftG
1905
+ .append('line')
1906
+ .attr('x1', this.textSize() - textLength - iconSpacing)
1907
+ .attr('y1', labelY - 5)
1908
+ .attr('x2', this.textSize() - iconSpacing)
1909
+ .attr('y2', labelY - 5)
1910
+ .attr('stroke', 'var(--clr-color-neutral-600, #666)')
1911
+ .attr('stroke-width', 2)
1912
+ .attr('stroke-dasharray', '2,2');
1913
+ leftG
1914
+ .append('text')
1915
+ .attr('x', this.textSize())
1916
+ .attr('y', labelY + 10)
1917
+ .attr('text-anchor', 'end')
1918
+ .style('font-size', '12px')
1919
+ .style('fill', 'var(--clr-color-neutral-600, #666)')
1920
+ .text(`${this.round(d.percentage)}% (${d.value})`);
1921
+ });
1922
+ this.sectionTooltips.set(positions);
1923
+ }
1924
+ renderSideLines(dataPoints, containerWidth, funnelWidth, barHeight) {
1925
+ const lineG = this.svg.append('g').attr('transform', `translate(${(containerWidth - funnelWidth) / 2},0)`);
1926
+ dataPoints.forEach(d => {
1927
+ const midY = d.y + barHeight / 2;
1928
+ lineG
1929
+ .append('line')
1930
+ .attr('x1', 0)
1931
+ .attr('y1', midY)
1932
+ .attr('x2', funnelWidth)
1933
+ .attr('y2', midY)
1934
+ .attr('stroke', '#D9D9D9')
1935
+ .attr('stroke-width', 1);
1936
+ this.renderSideLine(lineG, funnelWidth, d.y, barHeight);
1937
+ if (d.value === 0) {
1938
+ this.renderSideLine(lineG, 0, d.y, barHeight);
1939
+ }
1940
+ });
1941
+ }
1942
+ renderSideLine(g, x, y, barHeight) {
1943
+ g.append('line')
1944
+ .attr('x1', x)
1945
+ .attr('y1', y + this.sideLineVerticalPadding())
1946
+ .attr('x2', x)
1947
+ .attr('y2', y + barHeight - this.sideLineVerticalPadding())
1948
+ .attr('stroke', '#D9D9D9')
1949
+ .attr('stroke-width', 1);
1950
+ }
1951
+ renderFunnel(dataPoints, containerWidth, containerHeight, funnelWidth, barHeight) {
1952
+ const funnelG = this.svg
1953
+ .attr('width', containerWidth)
1954
+ .attr('height', containerHeight)
1955
+ .append('g')
1956
+ .attr('transform', `translate(${(containerWidth - funnelWidth) / 2},0)`);
1957
+ // First pass: all rects (z-order: rects below texts)
1958
+ dataPoints.forEach(dp => {
1959
+ dp.sections.forEach(section => {
1960
+ funnelG
1961
+ .append('rect')
1962
+ .attr('x', section.x)
1963
+ .attr('y', dp.y)
1964
+ .attr('width', section.width)
1965
+ .attr('height', barHeight)
1966
+ .style('fill', section.cssColor)
1967
+ .style('cursor', 'pointer')
1968
+ .on('mouseover', e => {
1969
+ select(e.currentTarget).style('fill', section.hoverColor);
1970
+ })
1971
+ .on('mouseout', e => {
1972
+ select(e.currentTarget).style('fill', section.cssColor);
1973
+ })
1974
+ .on('click', (event) => {
1975
+ this.handleSectionClick(event, dp, section.key);
1976
+ });
1977
+ });
1978
+ });
1979
+ // Second pass: texts on top
1980
+ dataPoints.forEach(dp => {
1981
+ dp.sections.forEach(section => {
1982
+ if (section.width > 40) {
1983
+ funnelG
1984
+ .append('text')
1985
+ .attr('x', section.x + section.width / 2)
1986
+ .attr('y', dp.y + barHeight / 2)
1987
+ .attr('text-anchor', 'middle')
1988
+ .attr('dominant-baseline', 'middle')
1989
+ .style('font-size', '13px')
1990
+ .style('font-weight', '600')
1991
+ .style('pointer-events', 'none')
1992
+ .style('fill', section.textColor)
1993
+ .text(`${section.percentage}%`);
1994
+ }
1995
+ });
1996
+ });
1997
+ }
1998
+ // ── Event handlers ────────────────────────────────────────────────────────────
1999
+ handleSectionClick(event, d, _sectionKey) {
2000
+ event.stopPropagation();
2001
+ const target = event.currentTarget;
2002
+ const rect = target.getBoundingClientRect();
2003
+ const container = this.chartRef().nativeElement.getBoundingClientRect();
2004
+ this.tooltipPosition.set({
2005
+ x: rect.left - container.left + rect.width / 2,
2006
+ y: rect.bottom - container.top,
2007
+ });
2008
+ this.selectedItem.set(d);
2009
+ }
2010
+ resetTooltip() {
2011
+ if (this.selectedItem() || this.selectedSpacerItem() || this.tooltipPosition()) {
2012
+ this.selectedItem.set(undefined);
2013
+ this.selectedSpacerItem.set(undefined);
2014
+ this.tooltipPosition.set(undefined);
2015
+ }
2016
+ }
2017
+ /** Formats a percentage to 2 decimal places for template use. */
2018
+ pct(value, total) {
2019
+ return this.round(total ? (value / total) * 100 : 0);
2020
+ }
2021
+ // ── Centered chart ────────────────────────────────────────────────────────────
2022
+ renderCenteredChart(containerWidth, containerHeight) {
2023
+ const maxFunnelWidth = this.getMaxFunnelWidth(containerWidth);
2024
+ const barHeight = this.getCenteredBarHeight(containerHeight);
2025
+ const spacerHeight = this.getCenteredSpacerHeight(containerHeight);
2026
+ const dataPoints = this.calculateCenteredDataPoints(maxFunnelWidth, barHeight, spacerHeight);
2027
+ const spacerPoints = this.calculateSpacerDataPoints(dataPoints, maxFunnelWidth, barHeight);
2028
+ this.renderCenteredLeftSideText(dataPoints, barHeight);
2029
+ this.renderCenteredLeftSideLines(dataPoints, maxFunnelWidth, barHeight);
2030
+ this.renderCenteredRightSideText(dataPoints, containerWidth, barHeight);
2031
+ this.renderCenteredRightSideLines(dataPoints, containerWidth, maxFunnelWidth, barHeight);
2032
+ this.renderCenteredMiddleLines(dataPoints, spacerPoints, containerWidth, barHeight);
2033
+ this.renderCenteredFunnel(dataPoints, spacerPoints, containerWidth, containerHeight, maxFunnelWidth, barHeight, spacerHeight);
2034
+ }
2035
+ calculateCenteredDataPoints(maxFunnelWidth, barHeight, spacerHeight) {
2036
+ const total = this.data()[0]?.value ?? 1;
2037
+ const maxValue = max(this.data(), d => d.value) ?? 1;
2038
+ const scale = scaleLinear().domain([0, maxValue]).range([0, maxFunnelWidth]);
2039
+ return this.data().map((d, i) => {
2040
+ const point = {
2041
+ ...d,
2042
+ y: i * (barHeight + spacerHeight),
2043
+ width: scale(d.value),
2044
+ percentage: total ? this.round((d.value / total) * 100) : 0,
2045
+ };
2046
+ if (i > 0) {
2047
+ const prev = this.data()[i - 1].value;
2048
+ point.delta = prev - d.value;
2049
+ point.deltaPercentage = prev === 0 ? 0 : this.round((point.delta / prev) * 100);
2050
+ }
2051
+ return point;
2052
+ });
2053
+ }
2054
+ calculateSpacerDataPoints(dataPoints, maxFunnelWidth, barHeight) {
2055
+ const spacers = [];
2056
+ for (let i = 0; i < dataPoints.length - 1; i++) {
2057
+ const cur = dataPoints[i];
2058
+ const next = dataPoints[i + 1];
2059
+ spacers.push({
2060
+ index: i,
2061
+ topWidth: cur.width,
2062
+ bottomWidth: next.width,
2063
+ topY: cur.y + barHeight,
2064
+ bottomY: next.y,
2065
+ topX: (maxFunnelWidth - cur.width) / 2,
2066
+ bottomX: (maxFunnelWidth - next.width) / 2,
2067
+ deltaPercentage: next.deltaPercentage ?? 0,
2068
+ });
2069
+ }
2070
+ return spacers;
2071
+ }
2072
+ renderCenteredLeftSideText(dataPoints, barHeight) {
2073
+ const g = this.svg.append('g').attr('transform', `translate(${this.textSize()},0)`);
2074
+ const ICON_SPACE = 20;
2075
+ const positions = [];
2076
+ dataPoints.forEach(d => {
2077
+ const cy = d.y + barHeight / 2;
2078
+ const iconSpacing = d.description ? ICON_SPACE : 0;
2079
+ const textLen = g
2080
+ .append('text')
2081
+ .attr('x', -iconSpacing)
2082
+ .attr('y', cy - 8)
2083
+ .attr('text-anchor', 'end')
2084
+ .style('font-size', '13px')
2085
+ .style('font-weight', '600')
2086
+ .style('fill', 'var(--clr-color-neutral-900, #21333b)')
2087
+ .style('cursor', 'pointer')
2088
+ .text(d.label)
2089
+ .on('click', () => this.valueClicked.emit({ value: d.value, label: d.label, key: d.key }))
2090
+ .node()
2091
+ .getComputedTextLength();
2092
+ if (d.description) {
2093
+ positions.push({ key: d.key, x: this.textSize() - iconSpacing + 5, y: cy - 24, description: d.description });
2094
+ }
2095
+ g.append('line')
2096
+ .attr('x1', -textLen - iconSpacing)
2097
+ .attr('y1', cy - 5)
2098
+ .attr('x2', -iconSpacing)
2099
+ .attr('y2', cy - 5)
2100
+ .attr('stroke', 'var(--clr-color-neutral-600, #666)')
2101
+ .attr('stroke-width', 2)
2102
+ .attr('stroke-dasharray', '2,2');
2103
+ g.append('text')
2104
+ .attr('x', 0)
2105
+ .attr('y', cy + 10)
2106
+ .attr('text-anchor', 'end')
2107
+ .style('font-size', '12px')
2108
+ .style('fill', 'var(--clr-color-neutral-600, #666)')
2109
+ .text(`${this.round(d.percentage)}% (${d.value})`);
2110
+ });
2111
+ this.sectionTooltips.set(positions);
2112
+ }
2113
+ renderCenteredLeftSideLines(dataPoints, maxFunnelWidth, barHeight) {
2114
+ const g = this.svg.append('g').attr('transform', `translate(${this.textSize() + this.lineTextPadding()},0)`);
2115
+ dataPoints.forEach(d => {
2116
+ g.append('line')
2117
+ .attr('x1', 0)
2118
+ .attr('y1', d.y + barHeight / 2)
2119
+ .attr('x2', maxFunnelWidth / 2 + this.minLineSize())
2120
+ .attr('y2', d.y + barHeight / 2)
2121
+ .attr('stroke', '#D9D9D9')
2122
+ .attr('stroke-width', 1);
2123
+ });
2124
+ }
2125
+ renderCenteredRightSideText(dataPoints, containerWidth, barHeight) {
2126
+ const g = this.svg.append('g').attr('transform', `translate(${containerWidth - this.textSize()},0)`);
2127
+ dataPoints.forEach((d, i) => {
2128
+ if (i === 0) {
2129
+ return;
2130
+ }
2131
+ const prev = dataPoints[i - 1];
2132
+ const cy = (prev.y + barHeight + d.y) / 2 + 4;
2133
+ g.append('text')
2134
+ .attr('x', 0)
2135
+ .attr('y', cy)
2136
+ .attr('text-anchor', 'start')
2137
+ .style('font-size', '12px')
2138
+ .style('fill', 'var(--clr-color-neutral-600, #666)')
2139
+ .text(`-${this.round(d.deltaPercentage)}% (${d.delta})`);
2140
+ });
2141
+ }
2142
+ renderCenteredRightSideLines(dataPoints, containerWidth, maxFunnelWidth, barHeight) {
2143
+ const g = this.svg.append('g').attr('transform', `translate(${containerWidth / 2},0)`);
2144
+ dataPoints.forEach((d, i) => {
2145
+ if (i === 0) {
2146
+ return;
2147
+ }
2148
+ const prev = dataPoints[i - 1];
2149
+ const cy = (prev.y + barHeight + d.y) / 2;
2150
+ g.append('line')
2151
+ .attr('x1', 0)
2152
+ .attr('y1', cy)
2153
+ .attr('x2', maxFunnelWidth / 2 + this.minLineSize())
2154
+ .attr('y2', cy)
2155
+ .attr('stroke', '#D9D9D9')
2156
+ .attr('stroke-width', 1);
2157
+ });
2158
+ }
2159
+ renderCenteredMiddleLines(dataPoints, spacerPoints, containerWidth, barHeight) {
2160
+ const vPad = this.middleLineVerticalPadding();
2161
+ const g = this.svg.append('g').attr('transform', `translate(${containerWidth / 2},0)`);
2162
+ g.selectAll('.bar-mid')
2163
+ .data(dataPoints)
2164
+ .enter()
2165
+ .append('line')
2166
+ .attr('x1', 0)
2167
+ .attr('y1', d => d.y + vPad)
2168
+ .attr('x2', 0)
2169
+ .attr('y2', d => d.y + barHeight - vPad)
2170
+ .attr('stroke', '#D9D9D9')
2171
+ .attr('stroke-width', 1);
2172
+ g.selectAll('.spacer-mid')
2173
+ .data(spacerPoints)
2174
+ .enter()
2175
+ .append('line')
2176
+ .attr('x1', 0)
2177
+ .attr('y1', d => d.topY + vPad)
2178
+ .attr('x2', 0)
2179
+ .attr('y2', d => d.bottomY - vPad)
2180
+ .attr('stroke', '#D9D9D9')
2181
+ .attr('stroke-width', 1);
2182
+ }
2183
+ renderCenteredFunnel(dataPoints, spacerPoints, containerWidth, containerHeight, maxFunnelWidth, barHeight, spacerHeight) {
2184
+ const g = this.svg
2185
+ .attr('width', containerWidth)
2186
+ .attr('height', containerHeight)
2187
+ .append('g')
2188
+ .attr('transform', `translate(${(containerWidth - maxFunnelWidth) / 2},0)`);
2189
+ // ── Bars ──────────────────────────────────────────────────────────────────
2190
+ const rawBarColor = this.barColor();
2191
+ const cssBarColor = toChartColor(rawBarColor);
2192
+ const hoverBarColor = rawBarColor.startsWith('--')
2193
+ ? cssBarColor
2194
+ : color(rawBarColor)?.darker(0.5)?.formatHex() ?? cssBarColor;
2195
+ const bars = g
2196
+ .selectAll('.c-bar')
2197
+ .data(dataPoints)
2198
+ .enter()
2199
+ .append('g')
2200
+ .attr('class', 'c-bar');
2201
+ bars
2202
+ .append('rect')
2203
+ .attr('x', d => (maxFunnelWidth - d.width) / 2)
2204
+ .attr('y', d => d.y)
2205
+ .attr('width', d => d.width)
2206
+ .attr('height', barHeight)
2207
+ .style('fill', cssBarColor)
2208
+ .style('cursor', 'pointer')
2209
+ .on('mouseover', e => {
2210
+ select(e.currentTarget).style('fill', hoverBarColor);
2211
+ })
2212
+ .on('mouseout', e => {
2213
+ select(e.currentTarget).style('fill', cssBarColor);
2214
+ })
2215
+ .on('click', (event, d) => {
2216
+ event.stopPropagation();
2217
+ const rect = event.currentTarget.getBoundingClientRect();
2218
+ const cont = this.chartRef().nativeElement.getBoundingClientRect();
2219
+ this.tooltipPosition.set({ x: rect.left - cont.left + rect.width / 2, y: rect.top - cont.top });
2220
+ this.selectedSpacerItem.set(undefined);
2221
+ this.selectedItem.set(d);
2222
+ });
2223
+ bars
2224
+ .selectAll('.c-bar-label')
2225
+ .data(dataPoints.filter(d => d.width > 40))
2226
+ .enter()
2227
+ .append('text')
2228
+ .attr('x', maxFunnelWidth / 2)
2229
+ .attr('y', d => d.y + barHeight / 2)
2230
+ .attr('text-anchor', 'middle')
2231
+ .attr('dominant-baseline', 'middle')
2232
+ .style('font-size', '13px')
2233
+ .style('font-weight', '600')
2234
+ .style('pointer-events', 'none')
2235
+ .style('fill', '#eee')
2236
+ .text(d => `${this.round(d.percentage)}%`);
2237
+ // ── Spacers ───────────────────────────────────────────────────────────────
2238
+ const rawSpacerColor = this.spacerColor();
2239
+ const cssSpacerColor = toChartColor(rawSpacerColor);
2240
+ const hoverSpacerColor = rawSpacerColor.startsWith('--')
2241
+ ? cssSpacerColor
2242
+ : color(rawSpacerColor)?.darker(0.2)?.formatHex() ?? cssSpacerColor;
2243
+ g.selectAll('.c-spacer')
2244
+ .data(spacerPoints)
2245
+ .enter()
2246
+ .append('polygon')
2247
+ .attr('class', 'c-spacer')
2248
+ .attr('points', d => `${d.topX},${d.topY} ${d.topX + d.topWidth},${d.topY} ${d.bottomX + d.bottomWidth},${d.bottomY} ${d.bottomX},${d.bottomY}`)
2249
+ .style('fill', cssSpacerColor)
2250
+ .style('cursor', 'pointer')
2251
+ .on('mouseover', e => {
2252
+ select(e.currentTarget).style('fill', hoverSpacerColor);
2253
+ })
2254
+ .on('mouseout', e => {
2255
+ select(e.currentTarget).style('fill', cssSpacerColor);
2256
+ })
2257
+ .on('click', (event, d) => {
2258
+ event.stopPropagation();
2259
+ const rect = event.currentTarget.getBoundingClientRect();
2260
+ const cont = this.chartRef().nativeElement.getBoundingClientRect();
2261
+ this.tooltipPosition.set({ x: rect.left - cont.left + rect.width / 2, y: rect.top - cont.top });
2262
+ this.selectedItem.set(undefined);
2263
+ this.selectedSpacerItem.set(d);
2264
+ });
2265
+ g.selectAll('.c-spacer-label')
2266
+ .data(spacerPoints.filter(d => d.bottomWidth > 40))
2267
+ .enter()
2268
+ .append('text')
2269
+ .attr('x', maxFunnelWidth / 2)
2270
+ .attr('y', d => d.topY + spacerHeight / 2)
2271
+ .attr('text-anchor', 'middle')
2272
+ .attr('dominant-baseline', 'middle')
2273
+ .style('font-size', '13px')
2274
+ .style('font-weight', '600')
2275
+ .style('pointer-events', 'none')
2276
+ .style('fill', '#333')
2277
+ .text(d => `-${this.round(d.deltaPercentage)}%`);
2278
+ }
2279
+ // ── Helpers ───────────────────────────────────────────────────────────────────
2280
+ getMaxFunnelWidth(containerWidth) {
2281
+ return containerWidth - this.textSize() * 2 - this.minLineSize() * 2 - this.lineTextPadding() * 2;
2282
+ }
2283
+ getCenteredFractionHeight(containerHeight) {
2284
+ return containerHeight / (this.data().length * (this.barToSpacerRatio() + 1) - 1);
2285
+ }
2286
+ getCenteredBarHeight(containerHeight) {
2287
+ return this.getCenteredFractionHeight(containerHeight) * this.barToSpacerRatio();
2288
+ }
2289
+ getCenteredSpacerHeight(containerHeight) {
2290
+ return this.getCenteredFractionHeight(containerHeight);
2291
+ }
2292
+ getFunnelWidth(containerWidth) {
2293
+ return containerWidth - this.textSize() * 2 - this.lineTextPadding() * 2;
2294
+ }
2295
+ getBarHeight(containerHeight) {
2296
+ const n = this.data().length;
2297
+ if (n === 0) {
2298
+ return 0;
2299
+ }
2300
+ return (containerHeight - (n - 1) * this.barGap()) / n;
2301
+ }
2302
+ round(value, decimals = 2) {
2303
+ const factor = 10 ** decimals;
2304
+ return Math.round(factor * value) / factor;
2305
+ }
2306
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: FunnelChartComponent, deps: null, target: i0.ɵɵFactoryTarget.Component }); }
2307
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.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 }}:&nbsp;</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 &nbsp;({{ 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 }}:&nbsp;</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 &nbsp;({{ section.percentage }}%)\n </p>\n }\n\n <p class=\"tooltip-row mt-0-5\">\n <strong>{{ resolvedChartLabels().total }}:&nbsp;</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 }}:&nbsp;</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: "directive", type: i1.CdsIconCustomTag, selector: "cds-icon" }, { kind: "component", type: i1.ClrSignpost, selector: "clr-signpost", inputs: ["clrSignpostTriggerAriaLabel"] }, { 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"] }, { 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 }); }
2308
+ }
2309
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: FunnelChartComponent, decorators: [{
2310
+ type: Component,
2311
+ 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 }}:&nbsp;</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 &nbsp;({{ 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 }}:&nbsp;</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 &nbsp;({{ section.percentage }}%)\n </p>\n }\n\n <p class=\"tooltip-row mt-0-5\">\n <strong>{{ resolvedChartLabels().total }}:&nbsp;</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 }}:&nbsp;</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"] }]
2312
+ }] });
2313
+
2314
+ const PREFERRED_ORDER = ['top-right', 'top-left', 'bottom-right', 'bottom-left'];
2315
+ class AutoPositionDirective {
2316
+ constructor() {
2317
+ this.signpostContent = inject(ClrSignpostContent, { optional: true });
2318
+ this.elementRef = inject(ElementRef);
2319
+ this.handleMouseDown = () => {
2320
+ // Only calculate when signpost is closed (about to open)
2321
+ if (this.elementRef.nativeElement.classList.contains('is-off-screen')) {
2322
+ this.updatePosition();
2323
+ }
2324
+ };
2325
+ }
2326
+ ngOnInit() {
2327
+ const signpost = this.elementRef.nativeElement.closest('clr-signpost');
2328
+ this.trigger = signpost?.querySelector('[clrSignpostTrigger]');
2329
+ if (this.trigger) {
2330
+ this.trigger.addEventListener('mousedown', this.handleMouseDown);
2331
+ }
2332
+ this.resizeObserver = new ResizeObserver(() => {
2333
+ if (!this.elementRef.nativeElement.classList.contains('is-off-screen')) {
2334
+ this.updatePosition();
2335
+ }
2336
+ });
2337
+ this.resizeObserver.observe(document.body);
2338
+ }
2339
+ updatePosition() {
2340
+ if (!this.signpostContent || !this.trigger) {
2341
+ return;
2342
+ }
2343
+ const triggerRect = this.trigger.getBoundingClientRect();
2344
+ this.signpostContent.position = this.getBestPosition(triggerRect);
2345
+ }
2346
+ getBestPosition(triggerRect) {
2347
+ const contentWidth = 300;
2348
+ const contentHeight = 200;
2349
+ const buffer = 50;
2350
+ const contentHeaderHeight = document.querySelector('.content-header')?.getBoundingClientRect().height ?? 0;
2351
+ const appHeaderHight = document.querySelector('.header')?.getBoundingClientRect().height ?? 0;
2352
+ const headerHeight = contentHeaderHeight + appHeaderHight;
2353
+ // Find first position with enough space
2354
+ for (const pos of PREFERRED_ORDER) {
2355
+ if (this.hasSpace(triggerRect, pos, contentWidth, contentHeight, headerHeight, buffer)) {
2356
+ return pos;
2357
+ }
2358
+ }
2359
+ return PREFERRED_ORDER[0]; // Fallback to first preference
2360
+ }
2361
+ hasSpace(rect, position, width, height, headerHeight, buffer) {
2362
+ const [vertical, horizontal] = position.split('-');
2363
+ const verticalSpace = vertical === 'top'
2364
+ ? rect.top - headerHeight - height - buffer
2365
+ : window.innerHeight - rect.bottom - height - buffer;
2366
+ const horizontalSpace = horizontal === 'left' ? rect.left - width - buffer : window.innerWidth - rect.right - width - buffer;
2367
+ return Math.min(verticalSpace, horizontalSpace) > 0;
2368
+ }
2369
+ ngOnDestroy() {
2370
+ if (this.trigger) {
2371
+ this.trigger.removeEventListener('mousedown', this.handleMouseDown);
2372
+ }
2373
+ this.resizeObserver?.disconnect();
2374
+ }
2375
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: AutoPositionDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
2376
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "19.2.18", type: AutoPositionDirective, isStandalone: true, selector: "clr-signpost-content[autoPosition]", ngImport: i0 }); }
2377
+ }
2378
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: AutoPositionDirective, decorators: [{
2379
+ type: Directive,
2380
+ args: [{
2381
+ selector: 'clr-signpost-content[autoPosition]',
2382
+ standalone: true,
2383
+ }]
2384
+ }] });
2385
+
2386
+ class TenantFullDateRangeDirective {
2387
+ constructor() {
2388
+ this.start = contentChild(ClrStartDateInput);
2389
+ this.end = contentChild(ClrEndDateInput);
2390
+ }
2391
+ ngAfterContentInit() {
2392
+ if (this.start()) {
2393
+ const originalStartEmit = this.start().dateChange.emit.bind(this.start().dateChange);
2394
+ this.start().dateChange.emit = date => {
2395
+ originalStartEmit(this.toTenantDateTime(date, '00:00'));
2396
+ };
2397
+ }
2398
+ if (this.end()) {
2399
+ const originalEndEmit = this.end().dateChange.emit.bind(this.end().dateChange);
2400
+ this.end().dateChange.emit = date => {
2401
+ originalEndEmit(this.toTenantDateTime(date, '23:59'));
2402
+ };
2403
+ }
2404
+ }
2405
+ toTenantDateTime(date, time) {
2406
+ if (!date) {
2407
+ return date;
2408
+ }
2409
+ const [hours, minutes] = time.split(':').map(Number);
2410
+ const result = new Date(date);
2411
+ result.setHours(hours, minutes, 0, 0);
2412
+ return result;
2413
+ }
2414
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: TenantFullDateRangeDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
2415
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.2.0", version: "19.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 }); }
2416
+ }
2417
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: TenantFullDateRangeDirective, decorators: [{
2418
+ type: Directive,
2419
+ args: [{
2420
+ selector: 'clr-date-range-container[cngTenantFullDateRange]',
2421
+ standalone: true,
2422
+ }]
2423
+ }] });
2424
+
2425
+ /*
2426
+ * Copyright (c) 2026 Porsche Informatik. All Rights Reserved.
2427
+ * This software is released under MIT license.
2428
+ * The full license information can be found in LICENSE in the root directory of this project.
2429
+ */
2430
+ const CLR_CHARTS_DECLARATIONS = [
2431
+ AreaChartComponent,
2432
+ BarChartComponent,
2433
+ ComboChartComponent,
2434
+ FunnelChartComponent,
2435
+ LineChartComponent,
2436
+ PieChartComponent,
2437
+ ];
2438
+ class ClrChartsModule {
2439
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: ClrChartsModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule }); }
2440
+ static { this.ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "19.2.18", ngImport: i0, type: ClrChartsModule, declarations: [AreaChartComponent,
2441
+ BarChartComponent,
2442
+ ComboChartComponent,
2443
+ FunnelChartComponent,
2444
+ LineChartComponent,
2445
+ PieChartComponent], imports: [CommonModule,
2446
+ DecimalPipe,
2447
+ ClrAlertModule,
2448
+ ClrIconModule,
2449
+ ClrSignpostModule,
2450
+ // standalone helpers consumed by the non-standalone chart components
2451
+ ChartAlertOverlayComponent,
2452
+ ChartExportButtonComponent,
2453
+ ChartLegendComponent,
2454
+ ChartSkeletonComponent,
2455
+ ChartTooltipComponent,
2456
+ OutsideClickDirective,
2457
+ WindowResizeDirective], exports: [AreaChartComponent,
2458
+ BarChartComponent,
2459
+ ComboChartComponent,
2460
+ FunnelChartComponent,
2461
+ LineChartComponent,
2462
+ PieChartComponent] }); }
2463
+ static { this.ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: ClrChartsModule, imports: [CommonModule,
2464
+ ClrAlertModule,
2465
+ ClrIconModule,
2466
+ ClrSignpostModule,
2467
+ // standalone helpers consumed by the non-standalone chart components
2468
+ ChartAlertOverlayComponent,
2469
+ ChartExportButtonComponent,
2470
+ ChartSkeletonComponent,
2471
+ ChartTooltipComponent] }); }
2472
+ }
2473
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: ClrChartsModule, decorators: [{
2474
+ type: NgModule,
2475
+ args: [{
2476
+ imports: [
2477
+ CommonModule,
2478
+ DecimalPipe,
2479
+ ClrAlertModule,
2480
+ ClrIconModule,
2481
+ ClrSignpostModule,
2482
+ // standalone helpers consumed by the non-standalone chart components
2483
+ ChartAlertOverlayComponent,
2484
+ ChartExportButtonComponent,
2485
+ ChartLegendComponent,
2486
+ ChartSkeletonComponent,
2487
+ ChartTooltipComponent,
2488
+ OutsideClickDirective,
2489
+ WindowResizeDirective,
2490
+ ],
2491
+ declarations: [...CLR_CHARTS_DECLARATIONS],
2492
+ exports: [...CLR_CHARTS_DECLARATIONS],
2493
+ }]
2494
+ }] });
2495
+
2496
+ /*
2497
+ * Copyright (c) 2026 Porsche Informatik. All Rights Reserved.
2498
+ * This software is released under MIT license.
2499
+ * The full license information can be found in LICENSE in the root directory of this project.
2500
+ */
2501
+
2502
+ /**
2503
+ * Generated bundle index. Do not edit.
2504
+ */
2505
+
2506
+ export { AreaChartComponent, BarChartComponent, ClrChartsModule, ComboChartComponent, FunnelChartComponent, LineChartComponent, PieChartComponent };
2507
+ //# sourceMappingURL=porscheinformatik-clr-addons-charts.mjs.map