@acorex/charts 21.0.0-next.2 → 21.0.0-next.21

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,9 +1,7 @@
1
- import { AX_CHART_COLOR_PALETTE, getChartColor, getEasingFunction, computeTooltipPosition } from '@acorex/charts';
1
+ import { AXChartComponent, 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';
5
- import { AX_GLOBAL_CONFIG } from '@acorex/core/config';
6
- import { set } from 'lodash-es';
7
5
 
8
6
  const AXDonutChartDefaultConfig = {
9
7
  showTooltip: true,
@@ -23,11 +21,7 @@ const AXDonutChartDefaultConfig = {
23
21
  };
24
22
  const AX_DONUT_CHART_CONFIG = new InjectionToken('AX_DONUT_CHART_CONFIG', {
25
23
  providedIn: 'root',
26
- factory: () => {
27
- const global = inject(AX_GLOBAL_CONFIG);
28
- set(global, 'chart.donutChart', AXDonutChartDefaultConfig);
29
- return AXDonutChartDefaultConfig;
30
- },
24
+ factory: () => AXDonutChartDefaultConfig,
31
25
  });
32
26
  function donutChartConfig(config = {}) {
33
27
  const result = {
@@ -41,7 +35,7 @@ function donutChartConfig(config = {}) {
41
35
  * Donut Chart Component
42
36
  * Displays data in a circular donut chart with interactive segments
43
37
  */
44
- class AXDonutChartComponent {
38
+ class AXDonutChartComponent extends AXChartComponent {
45
39
  // Dependency Injection
46
40
  cdr = inject(ChangeDetectorRef);
47
41
  // Inputs
@@ -60,8 +54,10 @@ class AXDonutChartComponent {
60
54
  chartContainerEl = viewChild.required('chartContainer');
61
55
  // D3 reference - loaded asynchronously
62
56
  d3;
63
- // Chart SVG reference
64
- svg;
57
+ // Chart SVG reference (using 'any' since D3 is loaded dynamically at runtime)
58
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
59
+ svg = null;
60
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
65
61
  pieData = [];
66
62
  // State management
67
63
  hiddenSegments = new Set();
@@ -93,8 +89,16 @@ class AXDonutChartComponent {
93
89
  ...this.options(),
94
90
  };
95
91
  }, ...(ngDevMode ? [{ debugName: "effectiveOptions" }] : []));
96
- // Tooltip positioning gap in pixels
92
+ // Constants
97
93
  TOOLTIP_GAP = 10;
94
+ HIGHLIGHT_TRANSITION_DURATION = 150;
95
+ HIGHLIGHT_TRANSITION_NAME = 'highlight';
96
+ SEGMENT_ANIMATION_DELAY = 50;
97
+ LABEL_ANIMATION_DELAY = 200;
98
+ MIN_CHART_DIMENSION = 200;
99
+ MIN_CHART_FOR_LABELS = 260;
100
+ DIMMED_OPACITY = 0.5;
101
+ VISIBLE_OPACITY = 1;
98
102
  // Messages with defaults
99
103
  effectiveMessages = computed(() => {
100
104
  const defaultMessages = {
@@ -123,16 +127,16 @@ class AXDonutChartComponent {
123
127
  return this.hiddenSegments.has(id);
124
128
  }
125
129
  constructor() {
130
+ super();
126
131
  // Dynamically load D3 and initialize the chart when the component is ready
127
132
  afterNextRender(() => {
128
133
  this._initialized.set(true);
129
134
  this.loadD3();
130
135
  });
131
136
  }
132
- #eff = effect(() => {
133
- // Access inputs to track them
137
+ dataChangeEffect = effect(() => {
138
+ // Track data and options changes
134
139
  this.data();
135
- // Track options changes to trigger re-render on updates
136
140
  this.effectiveOptions();
137
141
  // Only update if already rendered
138
142
  untracked(() => {
@@ -140,71 +144,75 @@ class AXDonutChartComponent {
140
144
  this.updateChart();
141
145
  }
142
146
  });
143
- }, ...(ngDevMode ? [{ debugName: "#eff" }] : []));
147
+ }, ...(ngDevMode ? [{ debugName: "dataChangeEffect" }] : []));
144
148
  /**
145
149
  * Highlights a specific segment by ID
146
150
  * @param id The segment ID to highlight, or null to clear highlight
147
151
  */
148
152
  highlightSegment(id) {
149
- if (this._isInitialAnimating())
150
- return;
151
- if (!this.svg)
153
+ if (this._isInitialAnimating() || !this.svg)
152
154
  return;
153
- const currentlyHighlighted = this._currentlyHighlightedSegment();
154
- // If the same segment is already highlighted, do nothing
155
- if (currentlyHighlighted === id) {
155
+ if (this._currentlyHighlightedSegment() === id)
156
156
  return;
157
- }
158
- // Update the currently highlighted segment state
159
157
  this._currentlyHighlightedSegment.set(id);
160
- const transitionDuration = 150;
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();
158
+ // Only interrupt ongoing HIGHLIGHT transitions (not initial animations)
159
+ this.svg.selectAll('path').interrupt(this.HIGHLIGHT_TRANSITION_NAME);
167
160
  if (id === null) {
168
- // Clear all highlights
169
- this.svg
170
- .selectAll('path')
171
- .classed('ax-donut-chart-highlighted', false)
172
- .classed('ax-donut-chart-dimmed', false)
173
- .transition()
174
- .duration(transitionDuration)
175
- .attr('transform', null)
176
- .style('filter', null)
177
- .style('opacity', 1);
178
- this.segmentHover.emit(null);
161
+ this.clearAllHighlights();
179
162
  return;
180
163
  }
164
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
181
165
  const targetSegment = this.svg.selectAll('path').filter((d) => d?.data?.id === id);
182
166
  if (targetSegment.empty()) {
183
- // Target not found, clear highlights
184
167
  this._currentlyHighlightedSegment.set(null);
185
168
  this.highlightSegment(null);
186
169
  return;
187
170
  }
188
- // Dim all segments that are NOT the target
171
+ this.dimNonTargetSegments(id);
172
+ this.highlightTargetSegment(targetSegment);
173
+ this.segmentHover.emit(this.getSegmentDataById(id));
174
+ }
175
+ clearAllHighlights() {
176
+ if (!this.svg)
177
+ return;
189
178
  this.svg
190
179
  .selectAll('path')
191
- .filter((d) => d?.data?.id !== id)
180
+ .classed('ax-donut-chart-highlighted', false)
181
+ .classed('ax-donut-chart-dimmed', false)
182
+ .transition(this.HIGHLIGHT_TRANSITION_NAME)
183
+ .duration(this.HIGHLIGHT_TRANSITION_DURATION)
184
+ .attr('transform', null)
185
+ .style('filter', null)
186
+ .style('opacity', this.VISIBLE_OPACITY);
187
+ this.segmentHover.emit(null);
188
+ }
189
+ dimNonTargetSegments(targetId) {
190
+ if (!this.svg)
191
+ return;
192
+ this.svg
193
+ .selectAll('path')
194
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
195
+ .filter((d) => d?.data?.id !== targetId)
192
196
  .classed('ax-donut-chart-highlighted', false)
193
197
  .classed('ax-donut-chart-dimmed', true)
194
- .transition()
195
- .duration(transitionDuration)
198
+ .transition(this.HIGHLIGHT_TRANSITION_NAME)
199
+ .duration(this.HIGHLIGHT_TRANSITION_DURATION)
196
200
  .attr('transform', null)
197
201
  .style('filter', null)
198
- .style('opacity', 0.5);
199
- // Highlight the target segment
202
+ .style('opacity', this.DIMMED_OPACITY);
203
+ }
204
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
205
+ highlightTargetSegment(targetSegment) {
200
206
  targetSegment
201
207
  .classed('ax-donut-chart-dimmed', false)
202
208
  .classed('ax-donut-chart-highlighted', true)
203
- .transition()
204
- .duration(transitionDuration)
209
+ .transition(this.HIGHLIGHT_TRANSITION_NAME)
210
+ .duration(this.HIGHLIGHT_TRANSITION_DURATION)
205
211
  .style('filter', 'url(#ax-donut-chart-segment-shadow)')
206
- .style('opacity', 1);
207
- this.segmentHover.emit(getSegmentDataById(id));
212
+ .style('opacity', this.VISIBLE_OPACITY);
213
+ }
214
+ getSegmentDataById(segmentId) {
215
+ return untracked(() => this.chartDataArray()).find((d) => d.id === segmentId) || null;
208
216
  }
209
217
  /**
210
218
  * Toggles visibility of a segment by ID
@@ -212,7 +220,8 @@ class AXDonutChartComponent {
212
220
  * @returns New visibility state (true = visible, false = hidden)
213
221
  */
214
222
  toggleSegment(id) {
215
- const wasAllHidden = this.chartDataArray().length > 0 && this.chartDataArray().every((d) => this.isSegmentHidden(d.id));
223
+ const chartData = this.chartDataArray();
224
+ const wasAllHidden = chartData.length > 0 && chartData.every((d) => this.isSegmentHidden(d.id));
216
225
  if (this.hiddenSegments.has(id)) {
217
226
  // Making a segment visible
218
227
  this.hiddenSegments.delete(id);
@@ -262,11 +271,10 @@ class AXDonutChartComponent {
262
271
  }
263
272
  }
264
273
  onSegmentClick(item) {
265
- // this.toggleSegmentVisibility(item.id);
266
274
  this.segmentClick.emit(item);
267
275
  }
268
276
  /**
269
- * Creates the donut chart
277
+ * Creates the donut chart from current data
270
278
  */
271
279
  createChart() {
272
280
  if (!this.d3 || !this.chartContainerEl()?.nativeElement)
@@ -279,9 +287,7 @@ class AXDonutChartComponent {
279
287
  this.showNoDataMessage();
280
288
  return;
281
289
  }
282
- // Filter out hidden segments
283
290
  const visibleData = data.filter((item) => !this.hiddenSegments.has(item.id));
284
- // If all segments are hidden, show message
285
291
  if (visibleData.length === 0) {
286
292
  this.showAllSegmentsHiddenMessage();
287
293
  return;
@@ -332,13 +338,10 @@ class AXDonutChartComponent {
332
338
  * Setups chart dimensions based on container and options
333
339
  */
334
340
  setupDimensions(container, options) {
335
- // Get container dimensions or use defaults
336
341
  const containerWidth = container.clientWidth || 400;
337
342
  const containerHeight = container.clientHeight || 400;
338
- // Ensure minimum dimensions for the chart
339
- const minDim = 200;
340
- const width = Math.max(options?.width || containerWidth, minDim);
341
- const height = Math.max(options?.height || containerHeight, minDim);
343
+ const width = Math.max(options?.width || containerWidth, this.MIN_CHART_DIMENSION);
344
+ const height = Math.max(options?.height || containerHeight, this.MIN_CHART_DIMENSION);
342
345
  return { width, height };
343
346
  }
344
347
  /**
@@ -357,7 +360,9 @@ class AXDonutChartComponent {
357
360
  }
358
361
  /**
359
362
  * Create SVG element with filter definitions for shadows
363
+ * @returns D3 SVG selection (any type due to dynamic D3 loading)
360
364
  */
365
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
361
366
  createSvgWithFilters(container, width, height) {
362
367
  const svg = this.d3
363
368
  .select(container)
@@ -420,33 +425,24 @@ class AXDonutChartComponent {
420
425
  .enter()
421
426
  .append('path')
422
427
  .attr('class', 'ax-donut-chart-segment')
423
- .attr('fill', (d /* d is PieArcDatum<AXDonutChartData> */) => {
424
- const chartItem = d.data; // AXDonutChartData from visibleData
425
- if (chartItem.color) {
426
- // Prioritize explicit color on the item
427
- return chartItem.color;
428
- }
429
- // Fallback: find original index for consistent palette color
430
- const originalFullData = untracked(() => this.chartDataArray());
431
- const originalIndex = originalFullData.findIndex((item) => item.id === chartItem.id);
432
- return this.getColor(originalIndex !== -1 ? originalIndex : 0); // Ensure valid index
433
- })
428
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
429
+ .attr('fill', (d) => this.getSegmentColor(d.data))
434
430
  .style('opacity', 0)
435
- .style('cursor', 'pointer') // Add cursor pointer to segments
431
+ .style('cursor', 'pointer')
432
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
436
433
  .on('mouseenter', (event, d) => {
437
- if (!this.effectiveOptions().showTooltip)
438
- return;
439
- this.handleSliceHover(event, d.data);
440
- })
441
- .on('mouseleave', () => {
442
- this.handleSegmentLeave();
434
+ if (this.effectiveOptions().showTooltip) {
435
+ this.handleSliceHover(event, d.data);
436
+ }
443
437
  })
438
+ .on('mouseleave', () => this.handleSegmentLeave())
444
439
  .on('mousemove', (event) => {
445
440
  if (this._tooltipVisible()) {
446
441
  this.updateTooltipPosition(event);
447
442
  }
448
443
  })
449
- .on('click', (event, d) => {
444
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
445
+ .on('click', (_event, d) => {
450
446
  this.onSegmentClick(d.data);
451
447
  });
452
448
  // Animate segments
@@ -456,8 +452,10 @@ class AXDonutChartComponent {
456
452
  .transition()
457
453
  .duration(animationDuration)
458
454
  .ease(animationEasing)
459
- .delay((d, i) => i * 50)
460
- .style('opacity', 1)
455
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
456
+ .delay((_d, i) => i * this.SEGMENT_ANIMATION_DELAY)
457
+ .style('opacity', this.VISIBLE_OPACITY)
458
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
461
459
  .attrTween('d', (d) => {
462
460
  const interpolate = this.d3.interpolate({ startAngle: d.startAngle, endAngle: d.startAngle }, d);
463
461
  return (t) => arc(interpolate(t));
@@ -478,79 +476,8 @@ class AXDonutChartComponent {
478
476
  }
479
477
  });
480
478
  // Add data labels if enabled and chart size is sufficient
481
- if (this.effectiveOptions().showDataLabels) {
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);
553
- }
479
+ if (this.effectiveOptions().showDataLabels && this.isChartLargeEnoughForLabels(chartWidth, chartHeight)) {
480
+ this.addDataLabels(labelArc, radius, innerRadius, total, animationDuration);
554
481
  }
555
482
  // Determine when all segment animations are complete (handle empty case)
556
483
  if (numSegments === 0) {
@@ -558,10 +485,184 @@ class AXDonutChartComponent {
558
485
  this.cdr.detectChanges();
559
486
  }
560
487
  }
488
+ isChartLargeEnoughForLabels(width, height) {
489
+ return Math.min(width, height) >= this.MIN_CHART_FOR_LABELS;
490
+ }
491
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
492
+ addDataLabels(labelArc, radius, innerRadius, total, animationDuration) {
493
+ if (!this.svg)
494
+ return;
495
+ const labelRadius = innerRadius + (radius * 0.95 - innerRadius) / 2;
496
+ // Show labels for all segments with valid font size
497
+ const dataWithLabels = this.pieData.filter((d) => {
498
+ const fontSize = this.calculateLabelFontSize(d, radius, total, labelRadius);
499
+ return fontSize > 0; // Only show if font size is valid
500
+ });
501
+ const labels = this.svg
502
+ .selectAll('.ax-donut-chart-data-label')
503
+ .data(dataWithLabels)
504
+ .enter()
505
+ .append('text')
506
+ .attr('class', 'ax-donut-chart-data-label')
507
+ .style('opacity', 0)
508
+ .style('pointer-events', 'none')
509
+ .style('fill', 'rgb(var(--ax-comp-donut-chart-data-labels-color))')
510
+ .attr('transform', (d) => {
511
+ const centroid = labelArc.centroid(d);
512
+ return `translate(${centroid[0]}, ${centroid[1]})`;
513
+ })
514
+ .attr('text-anchor', 'middle')
515
+ .attr('dominant-baseline', 'middle')
516
+ .style('font-size', (d) => `${this.calculateLabelFontSize(d, radius, total, labelRadius)}px`)
517
+ .style('font-weight', (d) => ((d.data.value / total) * 100 >= 15 ? '600' : '500'))
518
+ .text((d) => this.getTruncatedLabelText(d, radius, labelRadius, total));
519
+ labels
520
+ .transition()
521
+ .duration(animationDuration)
522
+ .delay((_d, i) => i * this.SEGMENT_ANIMATION_DELAY + this.LABEL_ANIMATION_DELAY)
523
+ .style('opacity', this.VISIBLE_OPACITY);
524
+ }
525
+ /**
526
+ * Calculates optimal font size for label based on segment size
527
+ * Uses smart dynamic sizing with readability constraints
528
+ */
529
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
530
+ calculateLabelFontSize(d, radius, total, labelRadius) {
531
+ const angle = d.endAngle - d.startAngle;
532
+ const segmentPercentage = (d.data.value / total) * 100;
533
+ // Don't show labels for very small segments (< 1%)
534
+ if (segmentPercentage < 1)
535
+ return 0;
536
+ // Calculate chord length (available horizontal space)
537
+ const chordLength = 2 * labelRadius * Math.sin(angle / 2);
538
+ // Constants for font sizing
539
+ const MIN_FONT_SIZE = 9; // Minimum readable size
540
+ const MAX_FONT_SIZE = 14; // Maximum for aesthetics
541
+ const MIN_CHORD_FOR_LABEL = 20; // Minimum space needed for any text
542
+ // Don't show labels if space is too small
543
+ if (chordLength < MIN_CHORD_FOR_LABEL)
544
+ return 0;
545
+ // Calculate font size based on multiple factors
546
+ // 1. Based on arc angle (larger segments = larger font)
547
+ const angleBasedSize = (angle * radius) / 8;
548
+ // 2. Based on chord length (available horizontal space)
549
+ const chordBasedSize = chordLength / 8;
550
+ // 3. Based on segment percentage (proportional to data importance)
551
+ const percentageBasedSize = 9 + (segmentPercentage / 100) * 5; // 9-14px range
552
+ // Take the minimum of all calculations to ensure fit
553
+ const calculatedSize = Math.min(angleBasedSize, chordBasedSize, percentageBasedSize);
554
+ // Apply min/max constraints
555
+ const finalSize = Math.max(MIN_FONT_SIZE, Math.min(calculatedSize, MAX_FONT_SIZE));
556
+ // Round to .5 for crisp rendering
557
+ return Math.round(finalSize * 2) / 2;
558
+ }
559
+ formatPercentage(value) {
560
+ if (value < 1)
561
+ return '<1%';
562
+ if (value < 10)
563
+ return `${value.toFixed(1)}%`;
564
+ return `${Math.round(value)}%`;
565
+ }
566
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
567
+ buildLabelText(d, total) {
568
+ const percentage = (d.data.value / total) * 100;
569
+ if (percentage < 1)
570
+ return '';
571
+ const label = d.data.label || '';
572
+ const percentageText = this.formatPercentage(percentage);
573
+ return label ? `${label} (${percentageText})` : percentageText;
574
+ }
575
+ /**
576
+ * Truncates label text to fit within the arc segment
577
+ * Uses smart truncation: tries full label, then label only, then percentage only, then truncated percentage
578
+ * Calculates chord length (straight line) since text is rendered horizontally, not along the arc
579
+ */
580
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
581
+ getTruncatedLabelText(d, radius, labelRadius, total) {
582
+ const fontSize = this.calculateLabelFontSize(d, radius, total, labelRadius);
583
+ // If font size is 0, segment is too small for labels
584
+ if (fontSize === 0)
585
+ return '';
586
+ // Calculate chord length (straight line distance) instead of arc length
587
+ // Formula: chord = 2 * radius * sin(angle/2)
588
+ const angle = d.endAngle - d.startAngle;
589
+ const chordLength = 2 * labelRadius * Math.sin(angle / 2);
590
+ // Use chord length as available width with dynamic safety margin
591
+ // Smaller segments need larger safety margins (percentage-based)
592
+ const safetyMarginPercent = Math.max(0.15, Math.min(0.3, 1 / angle)); // 15-30% margin
593
+ const availableWidth = chordLength * (1 - safetyMarginPercent);
594
+ const percentage = (d.data.value / total) * 100;
595
+ if (percentage < 1)
596
+ return '';
597
+ const label = d.data.label || '';
598
+ const percentageText = this.formatPercentage(percentage);
599
+ // Try different label formats in order of preference
600
+ const fullText = label ? `${label} (${percentageText})` : percentageText;
601
+ const labelOnly = label;
602
+ const percentageOnly = percentageText;
603
+ // 1. Try full text (label + percentage)
604
+ if (this.doesTextFit(fullText, fontSize, availableWidth)) {
605
+ return fullText;
606
+ }
607
+ // 2. Try label only (if exists)
608
+ if (label && this.doesTextFit(labelOnly, fontSize, availableWidth)) {
609
+ return labelOnly;
610
+ }
611
+ // 3. Try percentage only
612
+ if (this.doesTextFit(percentageOnly, fontSize, availableWidth)) {
613
+ return percentageOnly;
614
+ }
615
+ // 4. Truncate the label with ellipsis
616
+ if (label) {
617
+ return this.truncateTextToFit(label, fontSize, availableWidth);
618
+ }
619
+ // 5. Last resort: truncate percentage (very small segments)
620
+ return this.truncateTextToFit(percentageText, fontSize, availableWidth);
621
+ }
561
622
  /**
562
- * Gets the appropriate D3 easing function based on the option string
623
+ * Checks if text fits within available width using conservative estimation
624
+ * Uses 0.65em per character to account for font variations
563
625
  */
564
- // getEasingFunction moved to shared chart utils
626
+ doesTextFit(text, fontSize, availableWidth) {
627
+ if (!text)
628
+ return false;
629
+ // Use conservative character width (0.65em for safety)
630
+ // Wider fonts like bold text need more space
631
+ const estimatedWidth = text.length * fontSize * 0.65;
632
+ return estimatedWidth <= availableWidth;
633
+ }
634
+ /**
635
+ * Truncates text to fit within available width with ellipsis
636
+ * Uses conservative character width to prevent overflow
637
+ * Adapts character width estimation based on font size
638
+ */
639
+ truncateTextToFit(text, fontSize, availableWidth) {
640
+ if (!text)
641
+ return '';
642
+ // Adaptive character width: smaller fonts are relatively wider
643
+ // Larger fonts: ~0.6em, Smaller fonts: ~0.7em
644
+ const charWidthRatio = fontSize <= 10 ? 0.7 : 0.65;
645
+ const charWidth = fontSize * charWidthRatio;
646
+ // Reserve space for ellipsis
647
+ const ellipsisWidth = fontSize * 0.7;
648
+ const availableForText = availableWidth - ellipsisWidth;
649
+ if (availableForText <= 0)
650
+ return '';
651
+ const maxChars = Math.floor(availableForText / charWidth);
652
+ if (maxChars <= 0)
653
+ return '';
654
+ if (text.length <= maxChars)
655
+ return text;
656
+ // Ensure at least 1 character before ellipsis
657
+ return text.substring(0, Math.max(1, maxChars)) + '…';
658
+ }
659
+ getSegmentColor(data) {
660
+ if (data.color)
661
+ return data.color;
662
+ const originalFullData = untracked(() => this.chartDataArray());
663
+ const originalIndex = originalFullData.findIndex((item) => item.id === data.id);
664
+ return this.getColor(originalIndex !== -1 ? originalIndex : 0);
665
+ }
565
666
  /**
566
667
  * Handle hover effects on a segment
567
668
  */
@@ -569,23 +670,22 @@ class AXDonutChartComponent {
569
670
  if (this._isInitialAnimating())
570
671
  return;
571
672
  if (this.effectiveOptions().showTooltip !== false) {
572
- const index = this.data().findIndex((item) => item.id === datum.id);
573
- const color = datum.color || getChartColor(index, this.chartColors);
574
- // Calculate percentage of total
575
- // Ensure data() is accessed within a tracking context if it's a signal, or use untracked if appropriate
576
- const total = untracked(() => this.data()).reduce((sum, item) => sum + item.value, 0);
577
- const percentage = total > 0 ? ((datum.value / total) * 100).toFixed(1) : '0';
578
- this._tooltipData.set({
579
- title: datum.tooltipLabel || datum.label,
580
- value: datum.value.toString(),
581
- percentage: `${percentage}%`,
582
- color: color,
583
- });
584
- this.updateTooltipPosition(event);
585
- this._tooltipVisible.set(true);
673
+ this.showTooltip(event, datum);
586
674
  }
587
- this.highlightSegment(datum.id); // This will now also handle emitting segmentHover
588
- // No direct segmentHover.emit(datum) here anymore
675
+ this.highlightSegment(datum.id);
676
+ }
677
+ showTooltip(event, datum) {
678
+ const total = untracked(() => this.data()).reduce((sum, item) => sum + item.value, 0);
679
+ const percentage = total > 0 ? ((datum.value / total) * 100).toFixed(1) : '0';
680
+ const color = this.getSegmentColor(datum);
681
+ this._tooltipData.set({
682
+ title: datum.tooltipLabel || datum.label,
683
+ value: datum.value.toString(),
684
+ percentage: `${percentage}%`,
685
+ color,
686
+ });
687
+ this.updateTooltipPosition(event);
688
+ this._tooltipVisible.set(true);
589
689
  }
590
690
  /**
591
691
  * Handles mouse leave from segments
@@ -619,9 +719,9 @@ class AXDonutChartComponent {
619
719
  addCenterDisplay(total) {
620
720
  if (!this.svg)
621
721
  return;
622
- // Calculate appropriate font sizes based on chart dimensions
623
722
  const chartContainerWidth = this.chartContainerEl().nativeElement.clientWidth;
624
- const baseFontSize = Math.max(1.4, Math.min(2.4, chartContainerWidth / 200)); // Scale between 1.4rem and 2.4rem
723
+ // Improved font scaling: better minimum for small sizes, better ratio for scaling
724
+ const baseFontSize = Math.max(1.8, Math.min(3.2, chartContainerWidth / 150));
625
725
  const subTextFontSize = baseFontSize * 0.5;
626
726
  // Create group for the total display
627
727
  const totalDisplay = this.svg
@@ -659,11 +759,11 @@ class AXDonutChartComponent {
659
759
  * Cleans up chart resources
660
760
  */
661
761
  cleanupChart() {
662
- if (this.svg) {
663
- this.d3.select(this.chartContainerEl()?.nativeElement).selectAll('svg').remove();
664
- this.svg = null;
665
- this.pieData = [];
666
- }
762
+ if (!this.svg)
763
+ return;
764
+ this.d3.select(this.chartContainerEl()?.nativeElement).selectAll('svg').remove();
765
+ this.svg = null;
766
+ this.pieData = [];
667
767
  this.hiddenSegments.clear();
668
768
  this._tooltipVisible.set(false);
669
769
  this._currentlyHighlightedSegment.set(null);
@@ -686,18 +786,17 @@ class AXDonutChartComponent {
686
786
  // computeTooltipPosition moved to shared chart utils
687
787
  /**
688
788
  * Gets an accessibility label describing the donut chart for screen readers
789
+ * @returns Descriptive string for screen readers
689
790
  */
690
791
  getAccessibilityLabel() {
691
792
  const data = this.chartDataArray();
692
793
  if (!data || data.length === 0) {
693
794
  return 'Empty donut chart. No data available.';
694
795
  }
695
- // Calculate total
696
796
  const total = data.reduce((sum, item) => sum + item.value, 0);
697
- // Generate a description of the chart with percentages
698
797
  const segmentDescriptions = data
699
798
  .map((segment) => {
700
- const percentage = ((segment.value / total) * 100).toFixed(1);
799
+ const percentage = total > 0 ? ((segment.value / total) * 100).toFixed(1) : '0';
701
800
  return `${segment.label}: ${segment.value} (${percentage}%)`;
702
801
  })
703
802
  .join('; ');
@@ -708,24 +807,23 @@ class AXDonutChartComponent {
708
807
  * @implements AXChartLegendCompatible
709
808
  */
710
809
  getLegendItems() {
711
- return this.chartDataArray().map((item, index) => {
712
- return {
713
- id: item.id,
714
- name: item.label || `Segment ${index + 1}`,
715
- value: item.value,
716
- color: item.color || this.getColor(index),
717
- percentage: (item.value / this.data().reduce((sum, item) => sum + item.value, 0)) * 100,
718
- hidden: this.isSegmentHidden(item.id),
719
- };
720
- });
810
+ const total = this.data().reduce((sum, item) => sum + item.value, 0);
811
+ return this.chartDataArray().map((item, index) => ({
812
+ id: item.id,
813
+ name: item.label || `Segment ${index + 1}`,
814
+ value: item.value,
815
+ color: this.getSegmentColor(item),
816
+ percentage: total > 0 ? (item.value / total) * 100 : 0,
817
+ hidden: this.isSegmentHidden(item.id),
818
+ }));
721
819
  }
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 });
820
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.9", ngImport: i0, type: AXDonutChartComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
821
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "20.3.9", 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 }], usesInheritance: 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;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 });
724
822
  }
725
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXDonutChartComponent, decorators: [{
823
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.9", ngImport: i0, type: AXDonutChartComponent, decorators: [{
726
824
  type: Component,
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"] }]
728
- }], ctorParameters: () => [] });
825
+ 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;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"] }]
826
+ }], ctorParameters: () => [], propDecorators: { data: [{ type: i0.Input, args: [{ isSignal: true, alias: "data", required: false }] }], options: [{ type: i0.Input, args: [{ isSignal: true, alias: "options", required: false }] }], segmentClick: [{ type: i0.Output, args: ["segmentClick"] }], segmentHover: [{ type: i0.Output, args: ["segmentHover"] }], chartContainerEl: [{ type: i0.ViewChild, args: ['chartContainer', { isSignal: true }] }] } });
729
827
 
730
828
  /**
731
829
  * Generated bundle index. Do not edit.