@acorex/charts 20.2.0-next.9 → 20.2.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.
@@ -1,4 +1,4 @@
1
- import { AX_CHART_COLOR_PALETTE, getChartColor } from '@acorex/charts';
1
+ import { AX_CHART_COLOR_PALETTE, getChartColor, getEasingFunction, computeTooltipPosition } from '@acorex/charts';
2
2
  import { AXChartTooltipComponent } from '@acorex/charts/chart-tooltip';
3
3
  import * as i0 from '@angular/core';
4
4
  import { InjectionToken, inject, ChangeDetectorRef, input, output, viewChild, signal, computed, afterNextRender, effect, untracked, ChangeDetectionStrategy, ViewEncapsulation, Component } from '@angular/core';
@@ -12,6 +12,14 @@ const AXDonutChartDefaultConfig = {
12
12
  cornerRadius: 4,
13
13
  animationDuration: 800,
14
14
  animationEasing: 'cubic-out',
15
+ messages: {
16
+ noData: 'No data available',
17
+ noDataHelp: 'Please provide data to display the chart',
18
+ allHidden: 'All segments are hidden',
19
+ allHiddenHelp: 'Please toggle visibility of at least one segment',
20
+ noDataIcon: 'fa-light fa-chart-pie',
21
+ allHiddenIcon: 'fa-light fa-eye-slash',
22
+ },
15
23
  };
16
24
  const AX_DONUT_CHART_CONFIG = new InjectionToken('AX_DONUT_CHART_CONFIG', {
17
25
  providedIn: 'root',
@@ -60,6 +68,7 @@ class AXDonutChartComponent {
60
68
  _initialized = signal(false, ...(ngDevMode ? [{ debugName: "_initialized" }] : []));
61
69
  _rendered = signal(false, ...(ngDevMode ? [{ debugName: "_rendered" }] : []));
62
70
  _isInitialAnimating = signal(false, ...(ngDevMode ? [{ debugName: "_isInitialAnimating" }] : []));
71
+ _currentlyHighlightedSegment = signal(null, ...(ngDevMode ? [{ debugName: "_currentlyHighlightedSegment" }] : []));
63
72
  // Tooltip state
64
73
  _tooltipVisible = signal(false, ...(ngDevMode ? [{ debugName: "_tooltipVisible" }] : []));
65
74
  _tooltipPosition = signal({ x: 0, y: 0 }, ...(ngDevMode ? [{ debugName: "_tooltipPosition" }] : []));
@@ -84,6 +93,23 @@ class AXDonutChartComponent {
84
93
  ...this.options(),
85
94
  };
86
95
  }, ...(ngDevMode ? [{ debugName: "effectiveOptions" }] : []));
96
+ // Tooltip positioning gap in pixels
97
+ TOOLTIP_GAP = 10;
98
+ // Messages with defaults
99
+ effectiveMessages = computed(() => {
100
+ const defaultMessages = {
101
+ noData: 'No data available',
102
+ noDataHelp: 'Please provide data to display the chart',
103
+ allHidden: 'All segments are hidden',
104
+ allHiddenHelp: 'Please toggle visibility of at least one segment',
105
+ noDataIcon: 'fa-light fa-chart-pie',
106
+ allHiddenIcon: 'fa-light fa-eye-slash',
107
+ };
108
+ return {
109
+ ...defaultMessages,
110
+ ...this.effectiveOptions().messages,
111
+ };
112
+ }, ...(ngDevMode ? [{ debugName: "effectiveMessages" }] : []));
87
113
  // Data accessor for handling different incoming data formats
88
114
  chartDataArray = computed(() => {
89
115
  return this.data() || [];
@@ -106,7 +132,8 @@ class AXDonutChartComponent {
106
132
  #eff = effect(() => {
107
133
  // Access inputs to track them
108
134
  this.data();
109
- // this.effectiveOptions();
135
+ // Track options changes to trigger re-render on updates
136
+ this.effectiveOptions();
110
137
  // Only update if already rendered
111
138
  untracked(() => {
112
139
  if (this._rendered()) {
@@ -123,60 +150,61 @@ class AXDonutChartComponent {
123
150
  return;
124
151
  if (!this.svg)
125
152
  return;
153
+ const currentlyHighlighted = this._currentlyHighlightedSegment();
154
+ // If the same segment is already highlighted, do nothing
155
+ if (currentlyHighlighted === id) {
156
+ return;
157
+ }
158
+ // Update the currently highlighted segment state
159
+ this._currentlyHighlightedSegment.set(id);
126
160
  const transitionDuration = 150;
127
- // Helper to unhighlight all segments and reset them to their normal state
128
- const unhighlightAllSegments = () => {
161
+ // Helper to get full data for the segment ID
162
+ const getSegmentDataById = (segmentId) => {
163
+ return untracked(() => this.chartDataArray()).find((d) => d.id === segmentId) || null;
164
+ };
165
+ // Always interrupt any ongoing transitions first
166
+ this.svg.selectAll('path').interrupt();
167
+ if (id === null) {
168
+ // Clear all highlights
129
169
  this.svg
130
170
  .selectAll('path')
131
171
  .classed('ax-donut-chart-highlighted', false)
132
172
  .classed('ax-donut-chart-dimmed', false)
133
173
  .transition()
134
174
  .duration(transitionDuration)
135
- .attr('transform', 'scale(1)')
175
+ .attr('transform', null)
136
176
  .style('filter', null)
137
177
  .style('opacity', 1);
138
178
  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
179
  return;
147
180
  }
148
181
  const targetSegment = this.svg.selectAll('path').filter((d) => d?.data?.id === id);
149
182
  if (targetSegment.empty()) {
150
- unhighlightAllSegments(); // Target not found, so unhighlight all
183
+ // Target not found, clear highlights
184
+ this._currentlyHighlightedSegment.set(null);
185
+ this.highlightSegment(null);
151
186
  return;
152
187
  }
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
- }
188
+ // Dim all segments that are NOT the target
189
+ this.svg
190
+ .selectAll('path')
191
+ .filter((d) => d?.data?.id !== id)
192
+ .classed('ax-donut-chart-highlighted', false)
193
+ .classed('ax-donut-chart-dimmed', true)
194
+ .transition()
195
+ .duration(transitionDuration)
196
+ .attr('transform', null)
197
+ .style('filter', null)
198
+ .style('opacity', 0.5);
199
+ // Highlight the target segment
200
+ targetSegment
201
+ .classed('ax-donut-chart-dimmed', false)
202
+ .classed('ax-donut-chart-highlighted', true)
203
+ .transition()
204
+ .duration(transitionDuration)
205
+ .style('filter', 'url(#ax-donut-chart-segment-shadow)')
206
+ .style('opacity', 1);
207
+ this.segmentHover.emit(getSegmentDataById(id));
180
208
  }
181
209
  /**
182
210
  * Toggles visibility of a segment by ID
@@ -192,8 +220,11 @@ class AXDonutChartComponent {
192
220
  if (wasAllHidden) {
193
221
  // Force complete DOM cleanup and redraw
194
222
  if (this.chartContainerEl()?.nativeElement) {
195
- // Clear everything in the container
196
- this.d3?.select(this.chartContainerEl().nativeElement).selectAll('*').remove();
223
+ // Clear only chart SVG and message containers (preserve tooltip component)
224
+ this.d3
225
+ ?.select(this.chartContainerEl().nativeElement)
226
+ .selectAll('svg, .ax-chart-message-container, .ax-donut-chart-no-data-message')
227
+ .remove();
197
228
  // Force full redraw
198
229
  setTimeout(() => {
199
230
  this.createChart();
@@ -268,13 +299,15 @@ class AXDonutChartComponent {
268
299
  * Updates the chart with new data
269
300
  */
270
301
  updateChart() {
302
+ // Reset highlighted segment state when updating
303
+ this._currentlyHighlightedSegment.set(null);
271
304
  this.createChart(); // Recreate the chart with updated data
272
305
  }
273
306
  /**
274
307
  * Clears the chart container
275
308
  */
276
309
  clearChart(container) {
277
- this.d3.select(container).selectAll('svg').remove();
310
+ this.d3.select(container).selectAll('svg, .ax-chart-message-container, .ax-donut-chart-no-data-message').remove();
278
311
  this._tooltipVisible.set(false);
279
312
  }
280
313
  /**
@@ -284,27 +317,7 @@ class AXDonutChartComponent {
284
317
  if (!this.chartContainerEl()?.nativeElement)
285
318
  return;
286
319
  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');
320
+ this.renderMessage(container, this.effectiveMessages().noDataIcon, this.effectiveMessages().noData, this.effectiveMessages().noDataHelp, 'ax-donut-chart-no-data-message');
308
321
  }
309
322
  /**
310
323
  * Shows a message when all segments are hidden
@@ -313,27 +326,7 @@ class AXDonutChartComponent {
313
326
  if (!this.chartContainerEl()?.nativeElement)
314
327
  return;
315
328
  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');
329
+ this.renderMessage(container, this.effectiveMessages().allHiddenIcon, this.effectiveMessages().allHidden, this.effectiveMessages().allHiddenHelp, 'ax-donut-chart-no-data-message');
337
330
  }
338
331
  /**
339
332
  * Setups chart dimensions based on container and options
@@ -410,12 +403,6 @@ class AXDonutChartComponent {
410
403
  .innerRadius(innerRadius)
411
404
  .outerRadius(radius * 0.95)
412
405
  .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
406
  // Create label arc generator for placing labels (middle of the donut ring)
420
407
  const labelArc = this.d3
421
408
  .arc()
@@ -423,7 +410,7 @@ class AXDonutChartComponent {
423
410
  .outerRadius(innerRadius + (radius * 0.95 - innerRadius) / 2);
424
411
  // Get animation options
425
412
  const animationDuration = this.effectiveOptions().animationDuration;
426
- const animationEasing = this.getEasingFunction(this.effectiveOptions().animationEasing);
413
+ const animationEasing = getEasingFunction(this.d3, this.effectiveOptions().animationEasing);
427
414
  // Generate pie data
428
415
  this.pieData = pie(data);
429
416
  // Add segments with animation
@@ -451,8 +438,8 @@ class AXDonutChartComponent {
451
438
  return;
452
439
  this.handleSliceHover(event, d.data);
453
440
  })
454
- .on('mouseleave', (event, d) => {
455
- this.handleSegmentLeave(event, d, arc);
441
+ .on('mouseleave', () => {
442
+ this.handleSegmentLeave();
456
443
  })
457
444
  .on('mousemove', (event) => {
458
445
  if (this._tooltipVisible()) {
@@ -463,6 +450,8 @@ class AXDonutChartComponent {
463
450
  this.onSegmentClick(d.data);
464
451
  });
465
452
  // Animate segments
453
+ const numSegments = this.pieData.length;
454
+ let completed = 0;
466
455
  segments
467
456
  .transition()
468
457
  .duration(animationDuration)
@@ -472,112 +461,107 @@ class AXDonutChartComponent {
472
461
  .attrTween('d', (d) => {
473
462
  const interpolate = this.d3.interpolate({ startAngle: d.startAngle, endAngle: d.startAngle }, d);
474
463
  return (t) => arc(interpolate(t));
464
+ })
465
+ .on('end', () => {
466
+ completed++;
467
+ if (completed >= numSegments) {
468
+ this._isInitialAnimating.set(false);
469
+ this.cdr.detectChanges();
470
+ }
471
+ })
472
+ .on('interrupt', () => {
473
+ // Treat interrupts as completion to avoid getting stuck in animating state
474
+ completed++;
475
+ if (completed >= numSegments) {
476
+ this._isInitialAnimating.set(false);
477
+ this.cdr.detectChanges();
478
+ }
475
479
  });
476
- // Add data labels if enabled
480
+ // Add data labels if enabled and chart size is sufficient
477
481
  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);
482
+ const minChartDimension = Math.min(chartWidth, chartHeight);
483
+ const isTooSmallChartForLabels = minChartDimension < 260; // Hide labels entirely on small charts
484
+ if (!isTooSmallChartForLabels) {
485
+ // Calculate optimal font size based on segment size and chart dimensions
486
+ const calculateFontSize = (d) => {
487
+ const angleSize = d.endAngle - d.startAngle; // radians
488
+ const segmentPercentage = (d.data.value / total) * 100;
489
+ // Drop labels for very small segments
490
+ if (segmentPercentage < 2)
491
+ return 0;
492
+ // Minimum readable font size; avoid tiny unreadable text
493
+ const minReadable = segmentPercentage < 8 ? 0 : 9;
494
+ // Base size scaled by arc angle and radius, capped
495
+ const scaled = (angleSize * radius) / 9;
496
+ return Math.min(Math.max(minReadable, scaled), 13);
497
+ };
498
+ const formatPercentage = (value) => {
499
+ if (value < 1)
500
+ return '<1%';
501
+ if (value < 10)
502
+ return `${value.toFixed(1)}%`;
503
+ return `${Math.round(value)}%`;
504
+ };
505
+ const labelRadius = innerRadius + (radius * 0.95 - innerRadius) / 2;
506
+ const approximateTextWidth = (text, fontSize) => {
507
+ // Approximate width: 0.55em per char
508
+ return text.length * fontSize * 0.55;
509
+ };
510
+ const arcLength = (d) => (d.endAngle - d.startAngle) * labelRadius;
511
+ const buildLabelText = (d) => {
512
+ const percentage = (d.data.value / total) * 100;
513
+ if (percentage < 1)
514
+ return '';
515
+ const label = d.data.label || '';
516
+ const percentageText = formatPercentage(percentage);
517
+ return label ? `${label} (${percentageText})` : percentageText;
518
+ };
519
+ const dataThatFits = this.pieData.filter((d) => {
520
+ const fontSize = calculateFontSize(d);
521
+ if (!fontSize)
522
+ return false;
523
+ const text = buildLabelText(d);
524
+ if (!text)
525
+ return false;
526
+ const available = arcLength(d);
527
+ const needed = approximateTextWidth(text, fontSize) + 6; // small padding
528
+ return needed <= available;
529
+ });
530
+ const labels = this.svg
531
+ .selectAll('.ax-donut-chart-data-label')
532
+ .data(dataThatFits)
533
+ .enter()
534
+ .append('text')
535
+ .attr('class', 'ax-donut-chart-data-label')
536
+ .style('opacity', 0)
537
+ .style('pointer-events', 'none')
538
+ .style('fill', 'rgb(var(--ax-comp-donut-chart-data-labels-color))')
539
+ .attr('transform', (d) => {
540
+ const centroid = labelArc.centroid(d);
541
+ return `translate(${centroid[0]}, ${centroid[1]})`;
542
+ })
543
+ .attr('text-anchor', 'middle')
544
+ .attr('dominant-baseline', 'middle')
545
+ .style('font-size', (d) => `${calculateFontSize(d)}px`)
546
+ .style('font-weight', (d) => ((d.data.value / total) * 100 >= 15 ? '600' : '500'))
547
+ .text((d) => buildLabelText(d));
548
+ labels
549
+ .transition()
550
+ .duration(animationDuration)
551
+ .delay((d, i) => i * 50 + 200)
552
+ .style('opacity', 1);
546
553
  }
547
554
  }
548
- const totalAnimationCycleTime = maxIndividualDelay + animationDuration;
549
- setTimeout(() => {
555
+ // Determine when all segment animations are complete (handle empty case)
556
+ if (numSegments === 0) {
550
557
  this._isInitialAnimating.set(false);
551
558
  this.cdr.detectChanges();
552
- }, totalAnimationCycleTime);
559
+ }
553
560
  }
554
561
  /**
555
562
  * Gets the appropriate D3 easing function based on the option string
556
563
  */
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
- }
564
+ // getEasingFunction moved to shared chart utils
581
565
  /**
582
566
  * Handle hover effects on a segment
583
567
  */
@@ -606,52 +590,28 @@ class AXDonutChartComponent {
606
590
  /**
607
591
  * Handles mouse leave from segments
608
592
  */
609
- handleSegmentLeave(event, d, normalArc) {
593
+ handleSegmentLeave() {
610
594
  if (this._isInitialAnimating())
611
595
  return;
612
596
  // Hide tooltip
613
597
  this._tooltipVisible.set(false);
614
598
  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
599
+ // Clear all highlights to ensure segments return to normal state
600
+ this.highlightSegment(null);
627
601
  }
628
602
  /**
629
603
  * Updates tooltip position
630
604
  * Ensures the tooltip is visible by adjusting position when near edges
631
605
  */
632
606
  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 });
607
+ const containerEl = this.chartContainerEl()?.nativeElement;
608
+ if (!containerEl)
639
609
  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 });
610
+ const containerRect = containerEl.getBoundingClientRect();
611
+ const tooltipEl = containerEl.querySelector('.chart-tooltip');
612
+ const tooltipRect = tooltipEl ? tooltipEl.getBoundingClientRect() : null;
613
+ const pos = computeTooltipPosition(containerRect, tooltipRect, event.clientX, event.clientY, this.TOOLTIP_GAP);
614
+ this._tooltipPosition.set(pos);
655
615
  }
656
616
  /**
657
617
  * Adds center display with total value
@@ -706,7 +666,24 @@ class AXDonutChartComponent {
706
666
  }
707
667
  this.hiddenSegments.clear();
708
668
  this._tooltipVisible.set(false);
669
+ this._currentlyHighlightedSegment.set(null);
670
+ }
671
+ // ===== Helper utilities for modularity and maintainability =====
672
+ renderMessage(container, iconClass, messageText, helpText, specificClass) {
673
+ // Clear container area first (preserve tooltip component)
674
+ this.d3.select(container).selectAll('svg, .ax-chart-message-container, .ax-donut-chart-no-data-message').remove();
675
+ const messageContainer = this.d3
676
+ .select(container)
677
+ .append('div')
678
+ .attr('class', `ax-chart-message-container ax-donut-chart-message-background ${specificClass}`);
679
+ messageContainer
680
+ .append('div')
681
+ .attr('class', 'ax-chart-message-icon ax-donut-chart-no-data-icon')
682
+ .html(`<i class="${iconClass} fa-2x"></i>`);
683
+ messageContainer.append('div').attr('class', 'ax-chart-message-text ax-donut-chart-no-data-text').text(messageText);
684
+ messageContainer.append('div').attr('class', 'ax-chart-message-help ax-donut-chart-no-data-help').text(helpText);
709
685
  }
686
+ // computeTooltipPosition moved to shared chart utils
710
687
  /**
711
688
  * Gets an accessibility label describing the donut chart for screen readers
712
689
  */
@@ -742,12 +719,12 @@ class AXDonutChartComponent {
742
719
  };
743
720
  });
744
721
  }
745
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.1.3", ngImport: i0, type: AXDonutChartComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
746
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "20.1.3", 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 });
722
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXDonutChartComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
723
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "20.3.3", 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()\">\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: 0, 0, 0, 0;--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;background-color:rgba(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 svg g:has(text){font-family:inherit}ax-donut-chart .ax-donut-chart-no-data-message{text-align:center;background-color:rgb(var(--ax-comp-donut-chart-bg-color));border:1px solid rgba(var(--ax-sys-color-surface));padding:1.5rem;border-radius:.5rem;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
724
  }
748
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.1.3", ngImport: i0, type: AXDonutChartComponent, decorators: [{
725
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXDonutChartComponent, decorators: [{
749
726
  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"] }]
727
+ 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()\">\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: 0, 0, 0, 0;--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;background-color:rgba(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 svg g:has(text){font-family:inherit}ax-donut-chart .ax-donut-chart-no-data-message{text-align:center;background-color:rgb(var(--ax-comp-donut-chart-bg-color));border:1px solid rgba(var(--ax-sys-color-surface));padding:1.5rem;border-radius:.5rem;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
728
  }], ctorParameters: () => [] });
752
729
 
753
730
  /**