@porscheinformatik/clr-addons 19.16.0 → 19.18.0

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