@acorex/charts 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,758 @@
1
+ import { AX_CHART_COLOR_PALETTE, getChartColor } from '@acorex/charts';
2
+ import { AXChartTooltipComponent } from '@acorex/charts/chart-tooltip';
3
+ import * as i0 from '@angular/core';
4
+ import { InjectionToken, inject, ChangeDetectorRef, input, output, viewChild, signal, computed, afterNextRender, effect, untracked, ChangeDetectionStrategy, ViewEncapsulation, Component } from '@angular/core';
5
+ import { AX_GLOBAL_CONFIG } from '@acorex/core/config';
6
+ import { set } from 'lodash-es';
7
+
8
+ const AXDonutChartDefaultConfig = {
9
+ showTooltip: true,
10
+ showDataLabels: true,
11
+ donutWidth: 35,
12
+ cornerRadius: 4,
13
+ animationDuration: 800,
14
+ animationEasing: 'cubic-out',
15
+ };
16
+ const AX_DONUT_CHART_CONFIG = new InjectionToken('AX_DONUT_CHART_CONFIG', {
17
+ providedIn: 'root',
18
+ factory: () => {
19
+ const global = inject(AX_GLOBAL_CONFIG);
20
+ set(global, 'chart.donutChart', AXDonutChartDefaultConfig);
21
+ return AXDonutChartDefaultConfig;
22
+ },
23
+ });
24
+ function donutChartConfig(config = {}) {
25
+ const result = {
26
+ ...AXDonutChartDefaultConfig,
27
+ ...config,
28
+ };
29
+ return result;
30
+ }
31
+
32
+ /**
33
+ * Donut Chart Component
34
+ * Displays data in a circular donut chart with interactive segments
35
+ */
36
+ class AXDonutChartComponent {
37
+ // Dependency Injection
38
+ cdr = inject(ChangeDetectorRef);
39
+ // Inputs
40
+ /** Chart data input */
41
+ data = input([]);
42
+ /** Chart options input */
43
+ options = input({});
44
+ // Outputs
45
+ /** Emitted when a segment is clicked */
46
+ segmentClick = output();
47
+ /** Emitted when a segment has mouse hover
48
+ * Can be used by external elements to highlight this segment
49
+ */
50
+ segmentHover = output();
51
+ // Chart container reference
52
+ chartContainerEl = viewChild.required('chartContainer');
53
+ // D3 reference - loaded asynchronously
54
+ d3;
55
+ // Chart SVG reference
56
+ svg;
57
+ pieData = [];
58
+ // State management
59
+ hiddenSegments = new Set();
60
+ _initialized = signal(false);
61
+ _rendered = signal(false);
62
+ _isInitialAnimating = signal(false);
63
+ // Tooltip state
64
+ _tooltipVisible = signal(false);
65
+ _tooltipPosition = signal({ x: 0, y: 0 });
66
+ _tooltipData = signal({
67
+ title: '',
68
+ value: 0,
69
+ percentage: '0%',
70
+ color: '',
71
+ });
72
+ // Public computed properties for the template
73
+ tooltipVisible = this._tooltipVisible.asReadonly();
74
+ tooltipPosition = this._tooltipPosition.asReadonly();
75
+ tooltipData = this._tooltipData.asReadonly();
76
+ // Inject configuration
77
+ configToken = inject(AX_DONUT_CHART_CONFIG);
78
+ // Inject the chart colors
79
+ chartColors = inject(AX_CHART_COLOR_PALETTE);
80
+ // Computed configuration options
81
+ effectiveOptions = computed(() => {
82
+ return {
83
+ ...this.configToken,
84
+ ...this.options(),
85
+ };
86
+ });
87
+ // Data accessor for handling different incoming data formats
88
+ chartDataArray = computed(() => {
89
+ return this.data() || [];
90
+ });
91
+ // Color accessor method
92
+ getColor(index) {
93
+ return getChartColor(index, this.chartColors);
94
+ }
95
+ // Segment visibility check
96
+ isSegmentHidden(id) {
97
+ return this.hiddenSegments.has(id);
98
+ }
99
+ constructor() {
100
+ // Dynamically load D3 and initialize the chart when the component is ready
101
+ afterNextRender(() => {
102
+ this._initialized.set(true);
103
+ this.loadD3();
104
+ });
105
+ }
106
+ #eff = effect(() => {
107
+ // Access inputs to track them
108
+ this.data();
109
+ // this.effectiveOptions();
110
+ // Only update if already rendered
111
+ untracked(() => {
112
+ if (this._rendered()) {
113
+ this.updateChart();
114
+ }
115
+ });
116
+ });
117
+ /**
118
+ * Highlights a specific segment by ID
119
+ * @param id The segment ID to highlight, or null to clear highlight
120
+ */
121
+ highlightSegment(id) {
122
+ if (this._isInitialAnimating())
123
+ return;
124
+ if (!this.svg)
125
+ return;
126
+ const transitionDuration = 150;
127
+ // Helper to unhighlight all segments and reset them to their normal state
128
+ const unhighlightAllSegments = () => {
129
+ this.svg
130
+ .selectAll('path')
131
+ .classed('ax-donut-chart-highlighted', false)
132
+ .classed('ax-donut-chart-dimmed', false)
133
+ .transition()
134
+ .duration(transitionDuration)
135
+ .attr('transform', 'scale(1)')
136
+ .style('filter', null)
137
+ .style('opacity', 1);
138
+ this.segmentHover.emit(null);
139
+ };
140
+ // Helper to get full data for the segment ID
141
+ const getSegmentDataById = (segmentId) => {
142
+ return untracked(() => this.chartDataArray()).find((d) => d.id === segmentId) || null;
143
+ };
144
+ if (id === null) {
145
+ unhighlightAllSegments();
146
+ return;
147
+ }
148
+ const targetSegment = this.svg.selectAll('path').filter((d) => d?.data?.id === id);
149
+ if (targetSegment.empty()) {
150
+ unhighlightAllSegments(); // Target not found, so unhighlight all
151
+ return;
152
+ }
153
+ const isCurrentlyHighlighted = targetSegment.classed('ax-donut-chart-highlighted');
154
+ if (isCurrentlyHighlighted) {
155
+ unhighlightAllSegments(); // Toggle off if already highlighted
156
+ }
157
+ else {
158
+ // Dim all segments that are NOT the target
159
+ this.svg
160
+ .selectAll('path')
161
+ .filter((d) => d?.data?.id !== id)
162
+ .classed('ax-donut-chart-highlighted', false) // Ensure not highlighted
163
+ .classed('ax-donut-chart-dimmed', true)
164
+ .transition()
165
+ .duration(transitionDuration)
166
+ .attr('transform', 'scale(1)') // Reset scale if it was previously highlighted
167
+ .style('filter', null) // Remove filter if it had one
168
+ .style('opacity', 0.5); // Dim opacity
169
+ // Highlight the target segment
170
+ targetSegment
171
+ .classed('ax-donut-chart-dimmed', false) // Ensure not dimmed
172
+ .classed('ax-donut-chart-highlighted', true)
173
+ .transition()
174
+ .duration(transitionDuration)
175
+ .attr('transform', 'scale(1.02)')
176
+ .style('filter', 'url(#ax-donut-chart-segment-shadow)') // Apply shadow
177
+ .style('opacity', 1); // Ensure full opacity
178
+ this.segmentHover.emit(getSegmentDataById(id));
179
+ }
180
+ }
181
+ /**
182
+ * Toggles visibility of a segment by ID
183
+ * @param id Segment ID to toggle
184
+ * @returns New visibility state (true = visible, false = hidden)
185
+ */
186
+ toggleSegment(id) {
187
+ const wasAllHidden = this.chartDataArray().length > 0 && this.chartDataArray().every((d) => this.isSegmentHidden(d.id));
188
+ if (this.hiddenSegments.has(id)) {
189
+ // Making a segment visible
190
+ this.hiddenSegments.delete(id);
191
+ // If all segments were previously hidden, we need to ensure the message is cleared
192
+ if (wasAllHidden) {
193
+ // Force complete DOM cleanup and redraw
194
+ if (this.chartContainerEl()?.nativeElement) {
195
+ // Clear everything in the container
196
+ this.d3?.select(this.chartContainerEl().nativeElement).selectAll('*').remove();
197
+ // Force full redraw
198
+ setTimeout(() => {
199
+ this.createChart();
200
+ }, 0);
201
+ }
202
+ }
203
+ else {
204
+ this.updateChart();
205
+ }
206
+ }
207
+ else {
208
+ // Hiding a segment
209
+ this.hiddenSegments.add(id);
210
+ this.updateChart();
211
+ }
212
+ return !this.isSegmentHidden(id);
213
+ }
214
+ ngOnDestroy() {
215
+ this.cleanupChart();
216
+ }
217
+ /**
218
+ * Loads D3.js dynamically
219
+ */
220
+ async loadD3() {
221
+ try {
222
+ this.d3 = await import('d3');
223
+ // If container is ready, create chart
224
+ if (this._initialized() && this.chartContainerEl()) {
225
+ this.createChart();
226
+ this._rendered.set(true);
227
+ }
228
+ }
229
+ catch (error) {
230
+ console.error('Failed to load D3.js:', error);
231
+ }
232
+ }
233
+ onSegmentClick(item) {
234
+ // this.toggleSegmentVisibility(item.id);
235
+ this.segmentClick.emit(item);
236
+ }
237
+ /**
238
+ * Creates the donut chart
239
+ */
240
+ createChart() {
241
+ if (!this.d3 || !this.chartContainerEl()?.nativeElement)
242
+ return;
243
+ try {
244
+ const containerElement = this.chartContainerEl().nativeElement;
245
+ this.clearChart(containerElement);
246
+ const data = this.chartDataArray();
247
+ if (!data || data.length === 0) {
248
+ this.showNoDataMessage();
249
+ return;
250
+ }
251
+ // Filter out hidden segments
252
+ const visibleData = data.filter((item) => !this.hiddenSegments.has(item.id));
253
+ // If all segments are hidden, show message
254
+ if (visibleData.length === 0) {
255
+ this.showAllSegmentsHiddenMessage();
256
+ return;
257
+ }
258
+ const options = this.effectiveOptions();
259
+ const { width, height } = this.setupDimensions(containerElement, options);
260
+ this.renderDonutChart(containerElement, width, height, visibleData);
261
+ }
262
+ catch (error) {
263
+ console.error('Error creating donut chart:', error);
264
+ this.handleChartError();
265
+ }
266
+ }
267
+ /**
268
+ * Updates the chart with new data
269
+ */
270
+ updateChart() {
271
+ this.createChart(); // Recreate the chart with updated data
272
+ }
273
+ /**
274
+ * Clears the chart container
275
+ */
276
+ clearChart(container) {
277
+ this.d3.select(container).selectAll('svg').remove();
278
+ this._tooltipVisible.set(false);
279
+ }
280
+ /**
281
+ * Shows a message when no data is available
282
+ */
283
+ showNoDataMessage() {
284
+ if (!this.chartContainerEl()?.nativeElement)
285
+ return;
286
+ const container = this.chartContainerEl().nativeElement;
287
+ this.d3.select(container).selectAll('*').remove();
288
+ const messageContainer = this.d3
289
+ .select(container)
290
+ .append('div')
291
+ // Apply generic container class and specific background class
292
+ .attr('class', 'ax-donut-chart-no-data-message');
293
+ messageContainer
294
+ .append('div')
295
+ // Apply generic icon class and specific donut chart icon class
296
+ .attr('class', 'ax-chart-message-icon ax-donut-chart-no-data-icon')
297
+ .html('<i class="fa-light fa-chart-pie fa-2x"></i>'); // Donut chart icon
298
+ messageContainer
299
+ .append('div')
300
+ // Apply generic text class and specific donut chart text class
301
+ .attr('class', 'ax-chart-message-text ax-donut-chart-no-data-text')
302
+ .text('No data available');
303
+ messageContainer
304
+ .append('div')
305
+ // Apply generic help class and specific donut chart help class
306
+ .attr('class', 'ax-chart-message-help ax-donut-chart-no-data-help')
307
+ .text('Please provide data to display the chart');
308
+ }
309
+ /**
310
+ * Shows a message when all segments are hidden
311
+ */
312
+ showAllSegmentsHiddenMessage() {
313
+ if (!this.chartContainerEl()?.nativeElement)
314
+ return;
315
+ const container = this.chartContainerEl().nativeElement;
316
+ this.d3.select(container).selectAll('*').remove();
317
+ const messageContainer = this.d3
318
+ .select(container)
319
+ .append('div')
320
+ // Apply generic container class and specific background class
321
+ .attr('class', 'ax-donut-chart-no-data-message');
322
+ messageContainer
323
+ .append('div')
324
+ // Apply generic icon class and specific donut chart icon class if any distinction is needed later
325
+ .attr('class', 'ax-chart-message-icon ax-donut-chart-no-data-icon')
326
+ .html('<i class="fa-light fa-eye-slash fa-2x"></i>');
327
+ messageContainer
328
+ .append('div')
329
+ // Apply generic text class and specific donut chart text class if needed
330
+ .attr('class', 'ax-chart-message-text ax-donut-chart-no-data-text')
331
+ .text('All segments are hidden');
332
+ messageContainer
333
+ .append('div')
334
+ // Apply generic help class and specific donut chart help class if needed
335
+ .attr('class', 'ax-chart-message-help ax-donut-chart-no-data-help')
336
+ .text('Please toggle visibility of at least one segment');
337
+ }
338
+ /**
339
+ * Setups chart dimensions based on container and options
340
+ */
341
+ setupDimensions(container, options) {
342
+ // Get container dimensions or use defaults
343
+ const containerWidth = container.clientWidth || 400;
344
+ const containerHeight = container.clientHeight || 400;
345
+ // Ensure minimum dimensions for the chart
346
+ const minDim = 200;
347
+ const width = Math.max(options?.width || containerWidth, minDim);
348
+ const height = Math.max(options?.height || containerHeight, minDim);
349
+ return { width, height };
350
+ }
351
+ /**
352
+ * Renders the donut chart with visible data
353
+ */
354
+ renderDonutChart(container, width, height, visibleData) {
355
+ const total = visibleData.reduce((sum, item) => sum + item.value, 0);
356
+ // Create SVG container with filters
357
+ const svg = this.createSvgWithFilters(container, width, height);
358
+ // Create main chart group
359
+ this.svg = svg.append('g').attr('transform', `translate(${width / 2}, ${height / 2})`);
360
+ // Create donut segments
361
+ this.createDonutSegments(width, height, visibleData, total);
362
+ // Add total in center
363
+ this.addCenterDisplay(total);
364
+ }
365
+ /**
366
+ * Create SVG element with filter definitions for shadows
367
+ */
368
+ createSvgWithFilters(container, width, height) {
369
+ const svg = this.d3
370
+ .select(container)
371
+ .append('svg')
372
+ .attr('width', '100%')
373
+ .attr('height', '100%')
374
+ .attr('viewBox', `0 0 ${width} ${height}`)
375
+ .attr('preserveAspectRatio', 'xMidYMid meet');
376
+ // Add drop shadow filter
377
+ const defs = svg.append('defs');
378
+ const filter = defs.append('filter').attr('id', 'ax-donut-chart-segment-shadow').attr('height', '130%');
379
+ filter.append('feGaussianBlur').attr('in', 'SourceAlpha').attr('stdDeviation', 2).attr('result', 'blur');
380
+ filter.append('feOffset').attr('in', 'blur').attr('dx', 1).attr('dy', 1).attr('result', 'offsetBlur');
381
+ const feComponentTransfer = filter
382
+ .append('feComponentTransfer')
383
+ .attr('in', 'offsetBlur')
384
+ .attr('result', 'offsetBlur');
385
+ feComponentTransfer.append('feFuncA').attr('type', 'linear').attr('slope', 0.3);
386
+ const feMerge = filter.append('feMerge');
387
+ feMerge.append('feMergeNode').attr('in', 'offsetBlur');
388
+ feMerge.append('feMergeNode').attr('in', 'SourceGraphic');
389
+ return svg;
390
+ }
391
+ /**
392
+ * Create and render the donut segments with animations
393
+ */
394
+ createDonutSegments(chartWidth, chartHeight, data, total) {
395
+ this._isInitialAnimating.set(true);
396
+ // Create pie layout
397
+ const pie = this.d3
398
+ .pie()
399
+ .value((d) => d.value)
400
+ .sort(null)
401
+ .padAngle(0.02);
402
+ // Calculate the radius of the donut chart
403
+ const radius = (Math.min(chartWidth, chartHeight) / 2) * 0.85;
404
+ // Calculate inner radius based on donutWidth percentage
405
+ const donutWidthPercent = this.effectiveOptions().donutWidth / 100;
406
+ const innerRadius = radius * (1 - donutWidthPercent);
407
+ // Create arc generator with the configured radius and corner radius
408
+ const arc = this.d3
409
+ .arc()
410
+ .innerRadius(innerRadius)
411
+ .outerRadius(radius * 0.95)
412
+ .cornerRadius(this.effectiveOptions().cornerRadius);
413
+ // Create hover arc for animation
414
+ const hoverArc = this.d3
415
+ .arc()
416
+ .innerRadius(innerRadius)
417
+ .outerRadius(radius + 10)
418
+ .cornerRadius(this.effectiveOptions().cornerRadius);
419
+ // Create label arc generator for placing labels (middle of the donut ring)
420
+ const labelArc = this.d3
421
+ .arc()
422
+ .innerRadius(innerRadius + (radius * 0.95 - innerRadius) / 2)
423
+ .outerRadius(innerRadius + (radius * 0.95 - innerRadius) / 2);
424
+ // Get animation options
425
+ const animationDuration = this.effectiveOptions().animationDuration;
426
+ const animationEasing = this.getEasingFunction(this.effectiveOptions().animationEasing);
427
+ // Generate pie data
428
+ this.pieData = pie(data);
429
+ // Add segments with animation
430
+ const segments = this.svg
431
+ .selectAll('path')
432
+ .data(this.pieData)
433
+ .enter()
434
+ .append('path')
435
+ .attr('class', 'ax-donut-chart-segment')
436
+ .attr('fill', (d /* d is PieArcDatum<AXDonutChartData> */) => {
437
+ const chartItem = d.data; // AXDonutChartData from visibleData
438
+ if (chartItem.color) {
439
+ // Prioritize explicit color on the item
440
+ return chartItem.color;
441
+ }
442
+ // Fallback: find original index for consistent palette color
443
+ const originalFullData = untracked(() => this.chartDataArray());
444
+ const originalIndex = originalFullData.findIndex((item) => item.id === chartItem.id);
445
+ return this.getColor(originalIndex !== -1 ? originalIndex : 0); // Ensure valid index
446
+ })
447
+ .style('opacity', 0)
448
+ .style('cursor', 'pointer') // Add cursor pointer to segments
449
+ .on('mouseenter', (event, d) => {
450
+ if (!this.effectiveOptions().showTooltip)
451
+ return;
452
+ this.handleSliceHover(event, d.data);
453
+ })
454
+ .on('mouseleave', (event, d) => {
455
+ this.handleSegmentLeave(event, d, arc);
456
+ })
457
+ .on('mousemove', (event) => {
458
+ if (this._tooltipVisible()) {
459
+ this.updateTooltipPosition(event);
460
+ }
461
+ })
462
+ .on('click', (event, d) => {
463
+ this.onSegmentClick(d.data);
464
+ });
465
+ // Animate segments
466
+ segments
467
+ .transition()
468
+ .duration(animationDuration)
469
+ .ease(animationEasing)
470
+ .delay((d, i) => i * 50)
471
+ .style('opacity', 1)
472
+ .attrTween('d', (d) => {
473
+ const interpolate = this.d3.interpolate({ startAngle: d.startAngle, endAngle: d.startAngle }, d);
474
+ return (t) => arc(interpolate(t));
475
+ });
476
+ // Add data labels if enabled
477
+ if (this.effectiveOptions().showDataLabels) {
478
+ // Calculate optimal font size based on segment size and chart dimensions
479
+ const calculateFontSize = (d) => {
480
+ // Calculate angle size in radians for the segment
481
+ const angleSize = d.endAngle - d.startAngle;
482
+ // Calculate the segment's percentage of the whole
483
+ const segmentPercentage = (d.data.value / total) * 100;
484
+ // Base minimum font size on segment size
485
+ const minFontSize = segmentPercentage < 5
486
+ ? 0 // Hide very small segments
487
+ : segmentPercentage < 10
488
+ ? 7 // Smaller font for small segments
489
+ : segmentPercentage < 15
490
+ ? 8 // Medium font for medium segments
491
+ : 9; // Regular font for large segments
492
+ // Adjust font size based on chart size
493
+ return Math.min(Math.max(minFontSize, (angleSize * radius) / 10), 12);
494
+ };
495
+ // Format percentage value with appropriate precision
496
+ const formatPercentage = (value) => {
497
+ if (value < 1)
498
+ return '<1%';
499
+ if (value < 10)
500
+ return `${value.toFixed(1)}%`;
501
+ return `${Math.round(value)}%`;
502
+ };
503
+ const labels = this.svg
504
+ .selectAll('.ax-donut-chart-data-label')
505
+ .data(this.pieData)
506
+ .enter()
507
+ .append('text')
508
+ .attr('class', 'ax-donut-chart-data-label')
509
+ .style('opacity', 0)
510
+ .style('fill', 'rgb(var(--ax-comp-donut-chart-data-labels-color))')
511
+ .attr('transform', (d) => {
512
+ // Calculate the centroid position for the label
513
+ const centroid = labelArc.centroid(d);
514
+ return `translate(${centroid[0]}, ${centroid[1]})`;
515
+ })
516
+ .attr('text-anchor', 'middle')
517
+ .attr('dominant-baseline', 'middle')
518
+ .style('font-size', (d) => `${calculateFontSize(d)}px`)
519
+ .style('font-weight', (d) => ((d.data.value / total) * 100 >= 15 ? '600' : '500'))
520
+ .text((d) => {
521
+ // Calculate percentage for labels
522
+ const percentage = (d.data.value / total) * 100;
523
+ // Only show if segment is large enough to display text
524
+ if (percentage < 1)
525
+ return '';
526
+ const label = d.data.label || '';
527
+ const percentageText = formatPercentage(percentage);
528
+ return label ? `${label} (${percentageText})` : percentageText;
529
+ });
530
+ // Animate labels
531
+ labels
532
+ .transition()
533
+ .duration(animationDuration)
534
+ .delay((d, i) => i * 50 + 200)
535
+ .style('opacity', 1);
536
+ }
537
+ // Determine when all animations are complete
538
+ const numSegments = this.pieData.length;
539
+ let maxIndividualDelay = 0;
540
+ if (numSegments > 0) {
541
+ // Base delay for segments
542
+ maxIndividualDelay = (numSegments - 1) * 50;
543
+ if (this.effectiveOptions().showDataLabels) {
544
+ // Labels have an additional fixed delay
545
+ maxIndividualDelay = Math.max(maxIndividualDelay, (numSegments - 1) * 50 + 200);
546
+ }
547
+ }
548
+ const totalAnimationCycleTime = maxIndividualDelay + animationDuration;
549
+ setTimeout(() => {
550
+ this._isInitialAnimating.set(false);
551
+ this.cdr.detectChanges();
552
+ }, totalAnimationCycleTime);
553
+ }
554
+ /**
555
+ * Gets the appropriate D3 easing function based on the option string
556
+ */
557
+ getEasingFunction(easing) {
558
+ switch (easing) {
559
+ case 'linear':
560
+ return this.d3.easeLinear;
561
+ case 'ease':
562
+ return this.d3.easePolyInOut;
563
+ case 'ease-in':
564
+ return this.d3.easePolyIn;
565
+ case 'ease-out':
566
+ return this.d3.easePolyOut;
567
+ case 'ease-in-out':
568
+ return this.d3.easePolyInOut;
569
+ case 'cubic':
570
+ return this.d3.easeCubic;
571
+ case 'cubic-in':
572
+ return this.d3.easeCubicIn;
573
+ case 'cubic-out':
574
+ return this.d3.easeCubicOut;
575
+ case 'cubic-in-out':
576
+ return this.d3.easeCubicInOut;
577
+ default:
578
+ return this.d3.easeCubicOut; // Default easing
579
+ }
580
+ }
581
+ /**
582
+ * Handle hover effects on a segment
583
+ */
584
+ handleSliceHover(event, datum) {
585
+ if (this._isInitialAnimating())
586
+ return;
587
+ if (this.effectiveOptions().showTooltip !== false) {
588
+ const index = this.data().findIndex((item) => item.id === datum.id);
589
+ const color = datum.color || getChartColor(index, this.chartColors);
590
+ // Calculate percentage of total
591
+ // Ensure data() is accessed within a tracking context if it's a signal, or use untracked if appropriate
592
+ const total = untracked(() => this.data()).reduce((sum, item) => sum + item.value, 0);
593
+ const percentage = total > 0 ? ((datum.value / total) * 100).toFixed(1) : '0';
594
+ this._tooltipData.set({
595
+ title: datum.tooltipLabel || datum.label,
596
+ value: datum.value.toString(),
597
+ percentage: `${percentage}%`,
598
+ color: color,
599
+ });
600
+ this.updateTooltipPosition(event);
601
+ this._tooltipVisible.set(true);
602
+ }
603
+ this.highlightSegment(datum.id); // This will now also handle emitting segmentHover
604
+ // No direct segmentHover.emit(datum) here anymore
605
+ }
606
+ /**
607
+ * Handles mouse leave from segments
608
+ */
609
+ handleSegmentLeave(event, d, normalArc) {
610
+ if (this._isInitialAnimating())
611
+ return;
612
+ // Hide tooltip
613
+ this._tooltipVisible.set(false);
614
+ this.cdr.detectChanges();
615
+ // Emit null to indicate no segment is hovered - highlightSegment will handle this.
616
+ // this.segmentHover.emit(null); // Removed
617
+ // Remove hover effect (visual style related to classes and scale)
618
+ // Calling highlightSegment with the ID of the segment being left.
619
+ // If it was highlighted, this will toggle it off and emit null for segmentHover.
620
+ this.highlightSegment(d.data.id);
621
+ // Animate the arc back to normal for the specific segment being left.
622
+ this.d3
623
+ .select(event.currentTarget)
624
+ .transition()
625
+ .duration(200)
626
+ .attr('d', (arcData) => normalArc(arcData)); // Ensure arcData is used if 'd' is shadowed
627
+ }
628
+ /**
629
+ * Updates tooltip position
630
+ * Ensures the tooltip is visible by adjusting position when near edges
631
+ */
632
+ updateTooltipPosition(event) {
633
+ const container = this.chartContainerEl().nativeElement.getBoundingClientRect();
634
+ const tooltipEl = this.chartContainerEl().nativeElement.querySelector('.chart-tooltip');
635
+ if (!tooltipEl) {
636
+ const x = event.clientX - container.left;
637
+ const y = event.clientY - container.top;
638
+ this._tooltipPosition.set({ x, y });
639
+ return;
640
+ }
641
+ const tooltipRect = tooltipEl.getBoundingClientRect();
642
+ const cursorX = event.clientX - container.left;
643
+ const cursorY = event.clientY - container.top;
644
+ const gap = 20; // Gap between cursor and tooltip
645
+ // Position tooltip to the right by default
646
+ let x = cursorX + gap / 3;
647
+ // If tooltip would go off the right edge, position it to the left
648
+ if (x + tooltipRect.width > container.width) {
649
+ x = cursorX - tooltipRect.width - gap;
650
+ }
651
+ // Keep tooltip within container bounds
652
+ x = Math.max(gap, Math.min(x, container.width - tooltipRect.width - gap));
653
+ const y = Math.max(gap, Math.min(cursorY, container.height - tooltipRect.height - gap));
654
+ this._tooltipPosition.set({ x, y });
655
+ }
656
+ /**
657
+ * Adds center display with total value
658
+ */
659
+ addCenterDisplay(total) {
660
+ if (!this.svg)
661
+ return;
662
+ // Calculate appropriate font sizes based on chart dimensions
663
+ const chartContainerWidth = this.chartContainerEl().nativeElement.clientWidth;
664
+ const baseFontSize = Math.max(1.4, Math.min(2.4, chartContainerWidth / 200)); // Scale between 1.4rem and 2.4rem
665
+ const subTextFontSize = baseFontSize * 0.5;
666
+ // Create group for the total display
667
+ const totalDisplay = this.svg
668
+ .append('g')
669
+ .attr('class', 'ax-donut-chart-total-display')
670
+ .attr('text-anchor', 'middle');
671
+ // Add total value
672
+ totalDisplay
673
+ .append('text')
674
+ .attr('class', 'ax-donut-chart-total-value')
675
+ .style('font-size', `${baseFontSize}rem`)
676
+ .style('font-weight', '600')
677
+ .style('fill', 'rgb(var(--ax-comp-donut-chart-value-color))')
678
+ .text(total.toLocaleString());
679
+ // Add label
680
+ totalDisplay
681
+ .append('text')
682
+ .attr('class', 'ax-donut-chart-total-label')
683
+ .attr('dy', '1.4em')
684
+ .style('font-size', `${subTextFontSize}rem`)
685
+ .style('fill', 'rgb(var(--ax-comp-donut-chart-value-color))')
686
+ .style('opacity', '0.8')
687
+ .text(this.effectiveOptions().totalLabel || 'Total');
688
+ }
689
+ /**
690
+ * Handles chart rendering errors
691
+ */
692
+ handleChartError() {
693
+ const container = this.chartContainerEl()?.nativeElement;
694
+ if (container) {
695
+ this.showNoDataMessage();
696
+ }
697
+ }
698
+ /**
699
+ * Cleans up chart resources
700
+ */
701
+ cleanupChart() {
702
+ if (this.svg) {
703
+ this.d3.select(this.chartContainerEl()?.nativeElement).selectAll('svg').remove();
704
+ this.svg = null;
705
+ this.pieData = [];
706
+ }
707
+ this.hiddenSegments.clear();
708
+ this._tooltipVisible.set(false);
709
+ }
710
+ /**
711
+ * Gets an accessibility label describing the donut chart for screen readers
712
+ */
713
+ getAccessibilityLabel() {
714
+ const data = this.chartDataArray();
715
+ if (!data || data.length === 0) {
716
+ return 'Empty donut chart. No data available.';
717
+ }
718
+ // Calculate total
719
+ const total = data.reduce((sum, item) => sum + item.value, 0);
720
+ // Generate a description of the chart with percentages
721
+ const segmentDescriptions = data
722
+ .map((segment) => {
723
+ const percentage = ((segment.value / total) * 100).toFixed(1);
724
+ return `${segment.label}: ${segment.value} (${percentage}%)`;
725
+ })
726
+ .join('; ');
727
+ return `Donut chart with ${data.length} segments. Total value: ${total}. ${segmentDescriptions}`;
728
+ }
729
+ /**
730
+ * Returns the data items for the legend
731
+ * @implements AXChartLegendCompatible
732
+ */
733
+ getLegendItems() {
734
+ return this.chartDataArray().map((item, index) => {
735
+ return {
736
+ id: item.id,
737
+ name: item.label || `Segment ${index + 1}`,
738
+ value: item.value,
739
+ color: item.color || this.getColor(index),
740
+ percentage: (item.value / this.data().reduce((sum, item) => sum + item.value, 0)) * 100,
741
+ hidden: this.isSegmentHidden(item.id),
742
+ };
743
+ });
744
+ }
745
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.4", ngImport: i0, type: AXDonutChartComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
746
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "20.0.4", type: AXDonutChartComponent, isStandalone: true, selector: "ax-donut-chart", inputs: { data: { classPropertyName: "data", publicName: "data", isSignal: true, isRequired: false, transformFunction: null }, options: { classPropertyName: "options", publicName: "options", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { segmentClick: "segmentClick", segmentHover: "segmentHover" }, viewQueries: [{ propertyName: "chartContainerEl", first: true, predicate: ["chartContainer"], descendants: true, isSignal: true }], ngImport: i0, template: "<div class=\"ax-donut-chart\" #chartContainer role=\"img\" [attr.aria-label]=\"getAccessibilityLabel()\" tabindex=\"0\">\n <!-- Tooltip component -->\n <ax-chart-tooltip\n [visible]=\"tooltipVisible()\"\n [position]=\"tooltipPosition()\"\n [data]=\"tooltipData()\"\n [showPercentage]=\"true\"\n ></ax-chart-tooltip>\n</div>\n", styles: ["ax-donut-chart{display:block;width:100%;height:100%;--ax-comp-donut-chart-bg-color: var(--ax-sys-color-lightest-surface);--ax-comp-donut-chart-data-labels-color: var(--ax-sys-color-dark);--ax-comp-donut-chart-value-color: var(--ax-sys-color-on-lightest-surface)}ax-donut-chart .ax-donut-chart{width:100%;height:100%;position:relative;display:flex;align-items:center;justify-content:center;border-radius:.5rem;overflow:hidden;color:rgb(var(--ax-comp-donut-chart-data-labels-color));background-color:rgb(var(--ax-comp-donut-chart-bg-color))}ax-donut-chart .ax-donut-chart svg{width:100%;height:100%;max-width:100%;max-height:100%;overflow:visible}ax-donut-chart .ax-donut-chart-no-data-message{text-align:center;background-color:rgb(var(--ax-comp-donut-chart-bg-color));padding:1.5rem;border-radius:.5rem;box-shadow:0 2px 12px #00000014;width:80%;max-width:300px}ax-donut-chart .ax-donut-chart-no-data-message .ax-donut-chart-no-data-icon{opacity:.6;margin-bottom:.75rem}ax-donut-chart .ax-donut-chart-no-data-message .ax-donut-chart-no-data-text{font-size:1rem;font-weight:600;margin-bottom:.5rem}ax-donut-chart .ax-donut-chart-no-data-message .ax-donut-chart-no-data-help{font-size:.8rem;opacity:.6}\n"], dependencies: [{ kind: "component", type: AXChartTooltipComponent, selector: "ax-chart-tooltip", inputs: ["data", "position", "visible", "showPercentage", "style"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None });
747
+ }
748
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.0.4", ngImport: i0, type: AXDonutChartComponent, decorators: [{
749
+ type: Component,
750
+ args: [{ selector: 'ax-donut-chart', encapsulation: ViewEncapsulation.None, imports: [AXChartTooltipComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"ax-donut-chart\" #chartContainer role=\"img\" [attr.aria-label]=\"getAccessibilityLabel()\" tabindex=\"0\">\n <!-- Tooltip component -->\n <ax-chart-tooltip\n [visible]=\"tooltipVisible()\"\n [position]=\"tooltipPosition()\"\n [data]=\"tooltipData()\"\n [showPercentage]=\"true\"\n ></ax-chart-tooltip>\n</div>\n", styles: ["ax-donut-chart{display:block;width:100%;height:100%;--ax-comp-donut-chart-bg-color: var(--ax-sys-color-lightest-surface);--ax-comp-donut-chart-data-labels-color: var(--ax-sys-color-dark);--ax-comp-donut-chart-value-color: var(--ax-sys-color-on-lightest-surface)}ax-donut-chart .ax-donut-chart{width:100%;height:100%;position:relative;display:flex;align-items:center;justify-content:center;border-radius:.5rem;overflow:hidden;color:rgb(var(--ax-comp-donut-chart-data-labels-color));background-color:rgb(var(--ax-comp-donut-chart-bg-color))}ax-donut-chart .ax-donut-chart svg{width:100%;height:100%;max-width:100%;max-height:100%;overflow:visible}ax-donut-chart .ax-donut-chart-no-data-message{text-align:center;background-color:rgb(var(--ax-comp-donut-chart-bg-color));padding:1.5rem;border-radius:.5rem;box-shadow:0 2px 12px #00000014;width:80%;max-width:300px}ax-donut-chart .ax-donut-chart-no-data-message .ax-donut-chart-no-data-icon{opacity:.6;margin-bottom:.75rem}ax-donut-chart .ax-donut-chart-no-data-message .ax-donut-chart-no-data-text{font-size:1rem;font-weight:600;margin-bottom:.5rem}ax-donut-chart .ax-donut-chart-no-data-message .ax-donut-chart-no-data-help{font-size:.8rem;opacity:.6}\n"] }]
751
+ }], ctorParameters: () => [] });
752
+
753
+ /**
754
+ * Generated bundle index. Do not edit.
755
+ */
756
+
757
+ export { AXDonutChartComponent, AXDonutChartDefaultConfig, AX_DONUT_CHART_CONFIG, donutChartConfig };
758
+ //# sourceMappingURL=acorex-charts-donut-chart.mjs.map