@acorex/charts 21.0.0-next.13 → 21.0.0-next.15

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.
@@ -54,8 +54,10 @@ class AXDonutChartComponent extends AXChartComponent {
54
54
  chartContainerEl = viewChild.required('chartContainer');
55
55
  // D3 reference - loaded asynchronously
56
56
  d3;
57
- // Chart SVG reference
58
- 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
59
61
  pieData = [];
60
62
  // State management
61
63
  hiddenSegments = new Set();
@@ -87,8 +89,16 @@ class AXDonutChartComponent extends AXChartComponent {
87
89
  ...this.options(),
88
90
  };
89
91
  }, ...(ngDevMode ? [{ debugName: "effectiveOptions" }] : []));
90
- // Tooltip positioning gap in pixels
92
+ // Constants
91
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;
92
102
  // Messages with defaults
93
103
  effectiveMessages = computed(() => {
94
104
  const defaultMessages = {
@@ -124,10 +134,9 @@ class AXDonutChartComponent extends AXChartComponent {
124
134
  this.loadD3();
125
135
  });
126
136
  }
127
- #eff = effect(() => {
128
- // Access inputs to track them
137
+ dataChangeEffect = effect(() => {
138
+ // Track data and options changes
129
139
  this.data();
130
- // Track options changes to trigger re-render on updates
131
140
  this.effectiveOptions();
132
141
  // Only update if already rendered
133
142
  untracked(() => {
@@ -135,71 +144,75 @@ class AXDonutChartComponent extends AXChartComponent {
135
144
  this.updateChart();
136
145
  }
137
146
  });
138
- }, ...(ngDevMode ? [{ debugName: "#eff" }] : []));
147
+ }, ...(ngDevMode ? [{ debugName: "dataChangeEffect" }] : []));
139
148
  /**
140
149
  * Highlights a specific segment by ID
141
150
  * @param id The segment ID to highlight, or null to clear highlight
142
151
  */
143
152
  highlightSegment(id) {
144
- if (this._isInitialAnimating())
145
- return;
146
- if (!this.svg)
153
+ if (this._isInitialAnimating() || !this.svg)
147
154
  return;
148
- const currentlyHighlighted = this._currentlyHighlightedSegment();
149
- // If the same segment is already highlighted, do nothing
150
- if (currentlyHighlighted === id) {
155
+ if (this._currentlyHighlightedSegment() === id)
151
156
  return;
152
- }
153
- // Update the currently highlighted segment state
154
157
  this._currentlyHighlightedSegment.set(id);
155
- const transitionDuration = 150;
156
- // Helper to get full data for the segment ID
157
- const getSegmentDataById = (segmentId) => {
158
- return untracked(() => this.chartDataArray()).find((d) => d.id === segmentId) || null;
159
- };
160
- // Always interrupt any ongoing transitions first
161
- this.svg.selectAll('path').interrupt();
158
+ // Only interrupt ongoing HIGHLIGHT transitions (not initial animations)
159
+ this.svg.selectAll('path').interrupt(this.HIGHLIGHT_TRANSITION_NAME);
162
160
  if (id === null) {
163
- // Clear all highlights
164
- this.svg
165
- .selectAll('path')
166
- .classed('ax-donut-chart-highlighted', false)
167
- .classed('ax-donut-chart-dimmed', false)
168
- .transition()
169
- .duration(transitionDuration)
170
- .attr('transform', null)
171
- .style('filter', null)
172
- .style('opacity', 1);
173
- this.segmentHover.emit(null);
161
+ this.clearAllHighlights();
174
162
  return;
175
163
  }
164
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
176
165
  const targetSegment = this.svg.selectAll('path').filter((d) => d?.data?.id === id);
177
166
  if (targetSegment.empty()) {
178
- // Target not found, clear highlights
179
167
  this._currentlyHighlightedSegment.set(null);
180
168
  this.highlightSegment(null);
181
169
  return;
182
170
  }
183
- // 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;
184
178
  this.svg
185
179
  .selectAll('path')
186
- .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)
187
196
  .classed('ax-donut-chart-highlighted', false)
188
197
  .classed('ax-donut-chart-dimmed', true)
189
- .transition()
190
- .duration(transitionDuration)
198
+ .transition(this.HIGHLIGHT_TRANSITION_NAME)
199
+ .duration(this.HIGHLIGHT_TRANSITION_DURATION)
191
200
  .attr('transform', null)
192
201
  .style('filter', null)
193
- .style('opacity', 0.5);
194
- // Highlight the target segment
202
+ .style('opacity', this.DIMMED_OPACITY);
203
+ }
204
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
205
+ highlightTargetSegment(targetSegment) {
195
206
  targetSegment
196
207
  .classed('ax-donut-chart-dimmed', false)
197
208
  .classed('ax-donut-chart-highlighted', true)
198
- .transition()
199
- .duration(transitionDuration)
209
+ .transition(this.HIGHLIGHT_TRANSITION_NAME)
210
+ .duration(this.HIGHLIGHT_TRANSITION_DURATION)
200
211
  .style('filter', 'url(#ax-donut-chart-segment-shadow)')
201
- .style('opacity', 1);
202
- 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;
203
216
  }
204
217
  /**
205
218
  * Toggles visibility of a segment by ID
@@ -207,7 +220,8 @@ class AXDonutChartComponent extends AXChartComponent {
207
220
  * @returns New visibility state (true = visible, false = hidden)
208
221
  */
209
222
  toggleSegment(id) {
210
- 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));
211
225
  if (this.hiddenSegments.has(id)) {
212
226
  // Making a segment visible
213
227
  this.hiddenSegments.delete(id);
@@ -257,11 +271,10 @@ class AXDonutChartComponent extends AXChartComponent {
257
271
  }
258
272
  }
259
273
  onSegmentClick(item) {
260
- // this.toggleSegmentVisibility(item.id);
261
274
  this.segmentClick.emit(item);
262
275
  }
263
276
  /**
264
- * Creates the donut chart
277
+ * Creates the donut chart from current data
265
278
  */
266
279
  createChart() {
267
280
  if (!this.d3 || !this.chartContainerEl()?.nativeElement)
@@ -274,9 +287,7 @@ class AXDonutChartComponent extends AXChartComponent {
274
287
  this.showNoDataMessage();
275
288
  return;
276
289
  }
277
- // Filter out hidden segments
278
290
  const visibleData = data.filter((item) => !this.hiddenSegments.has(item.id));
279
- // If all segments are hidden, show message
280
291
  if (visibleData.length === 0) {
281
292
  this.showAllSegmentsHiddenMessage();
282
293
  return;
@@ -327,13 +338,10 @@ class AXDonutChartComponent extends AXChartComponent {
327
338
  * Setups chart dimensions based on container and options
328
339
  */
329
340
  setupDimensions(container, options) {
330
- // Get container dimensions or use defaults
331
341
  const containerWidth = container.clientWidth || 400;
332
342
  const containerHeight = container.clientHeight || 400;
333
- // Ensure minimum dimensions for the chart
334
- const minDim = 200;
335
- const width = Math.max(options?.width || containerWidth, minDim);
336
- 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);
337
345
  return { width, height };
338
346
  }
339
347
  /**
@@ -352,7 +360,9 @@ class AXDonutChartComponent extends AXChartComponent {
352
360
  }
353
361
  /**
354
362
  * Create SVG element with filter definitions for shadows
363
+ * @returns D3 SVG selection (any type due to dynamic D3 loading)
355
364
  */
365
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
356
366
  createSvgWithFilters(container, width, height) {
357
367
  const svg = this.d3
358
368
  .select(container)
@@ -415,33 +425,24 @@ class AXDonutChartComponent extends AXChartComponent {
415
425
  .enter()
416
426
  .append('path')
417
427
  .attr('class', 'ax-donut-chart-segment')
418
- .attr('fill', (d /* d is PieArcDatum<AXDonutChartData> */) => {
419
- const chartItem = d.data; // AXDonutChartData from visibleData
420
- if (chartItem.color) {
421
- // Prioritize explicit color on the item
422
- return chartItem.color;
423
- }
424
- // Fallback: find original index for consistent palette color
425
- const originalFullData = untracked(() => this.chartDataArray());
426
- const originalIndex = originalFullData.findIndex((item) => item.id === chartItem.id);
427
- return this.getColor(originalIndex !== -1 ? originalIndex : 0); // Ensure valid index
428
- })
428
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
429
+ .attr('fill', (d) => this.getSegmentColor(d.data))
429
430
  .style('opacity', 0)
430
- .style('cursor', 'pointer') // Add cursor pointer to segments
431
+ .style('cursor', 'pointer')
432
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
431
433
  .on('mouseenter', (event, d) => {
432
- if (!this.effectiveOptions().showTooltip)
433
- return;
434
- this.handleSliceHover(event, d.data);
435
- })
436
- .on('mouseleave', () => {
437
- this.handleSegmentLeave();
434
+ if (this.effectiveOptions().showTooltip) {
435
+ this.handleSliceHover(event, d.data);
436
+ }
438
437
  })
438
+ .on('mouseleave', () => this.handleSegmentLeave())
439
439
  .on('mousemove', (event) => {
440
440
  if (this._tooltipVisible()) {
441
441
  this.updateTooltipPosition(event);
442
442
  }
443
443
  })
444
- .on('click', (event, d) => {
444
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
445
+ .on('click', (_event, d) => {
445
446
  this.onSegmentClick(d.data);
446
447
  });
447
448
  // Animate segments
@@ -451,8 +452,10 @@ class AXDonutChartComponent extends AXChartComponent {
451
452
  .transition()
452
453
  .duration(animationDuration)
453
454
  .ease(animationEasing)
454
- .delay((d, i) => i * 50)
455
- .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
456
459
  .attrTween('d', (d) => {
457
460
  const interpolate = this.d3.interpolate({ startAngle: d.startAngle, endAngle: d.startAngle }, d);
458
461
  return (t) => arc(interpolate(t));
@@ -473,79 +476,8 @@ class AXDonutChartComponent extends AXChartComponent {
473
476
  }
474
477
  });
475
478
  // Add data labels if enabled and chart size is sufficient
476
- if (this.effectiveOptions().showDataLabels) {
477
- const minChartDimension = Math.min(chartWidth, chartHeight);
478
- const isTooSmallChartForLabels = minChartDimension < 260; // Hide labels entirely on small charts
479
- if (!isTooSmallChartForLabels) {
480
- // Calculate optimal font size based on segment size and chart dimensions
481
- const calculateFontSize = (d) => {
482
- const angleSize = d.endAngle - d.startAngle; // radians
483
- const segmentPercentage = (d.data.value / total) * 100;
484
- // Drop labels for very small segments
485
- if (segmentPercentage < 2)
486
- return 0;
487
- // Minimum readable font size; avoid tiny unreadable text
488
- const minReadable = segmentPercentage < 8 ? 0 : 9;
489
- // Base size scaled by arc angle and radius, capped
490
- const scaled = (angleSize * radius) / 9;
491
- return Math.min(Math.max(minReadable, scaled), 13);
492
- };
493
- const formatPercentage = (value) => {
494
- if (value < 1)
495
- return '<1%';
496
- if (value < 10)
497
- return `${value.toFixed(1)}%`;
498
- return `${Math.round(value)}%`;
499
- };
500
- const labelRadius = innerRadius + (radius * 0.95 - innerRadius) / 2;
501
- const approximateTextWidth = (text, fontSize) => {
502
- // Approximate width: 0.55em per char
503
- return text.length * fontSize * 0.55;
504
- };
505
- const arcLength = (d) => (d.endAngle - d.startAngle) * labelRadius;
506
- const buildLabelText = (d) => {
507
- const percentage = (d.data.value / total) * 100;
508
- if (percentage < 1)
509
- return '';
510
- const label = d.data.label || '';
511
- const percentageText = formatPercentage(percentage);
512
- return label ? `${label} (${percentageText})` : percentageText;
513
- };
514
- const dataThatFits = this.pieData.filter((d) => {
515
- const fontSize = calculateFontSize(d);
516
- if (!fontSize)
517
- return false;
518
- const text = buildLabelText(d);
519
- if (!text)
520
- return false;
521
- const available = arcLength(d);
522
- const needed = approximateTextWidth(text, fontSize) + 6; // small padding
523
- return needed <= available;
524
- });
525
- const labels = this.svg
526
- .selectAll('.ax-donut-chart-data-label')
527
- .data(dataThatFits)
528
- .enter()
529
- .append('text')
530
- .attr('class', 'ax-donut-chart-data-label')
531
- .style('opacity', 0)
532
- .style('pointer-events', 'none')
533
- .style('fill', 'rgb(var(--ax-comp-donut-chart-data-labels-color))')
534
- .attr('transform', (d) => {
535
- const centroid = labelArc.centroid(d);
536
- return `translate(${centroid[0]}, ${centroid[1]})`;
537
- })
538
- .attr('text-anchor', 'middle')
539
- .attr('dominant-baseline', 'middle')
540
- .style('font-size', (d) => `${calculateFontSize(d)}px`)
541
- .style('font-weight', (d) => ((d.data.value / total) * 100 >= 15 ? '600' : '500'))
542
- .text((d) => buildLabelText(d));
543
- labels
544
- .transition()
545
- .duration(animationDuration)
546
- .delay((d, i) => i * 50 + 200)
547
- .style('opacity', 1);
548
- }
479
+ if (this.effectiveOptions().showDataLabels && this.isChartLargeEnoughForLabels(chartWidth, chartHeight)) {
480
+ this.addDataLabels(labelArc, radius, innerRadius, total, animationDuration);
549
481
  }
550
482
  // Determine when all segment animations are complete (handle empty case)
551
483
  if (numSegments === 0) {
@@ -553,10 +485,184 @@ class AXDonutChartComponent extends AXChartComponent {
553
485
  this.cdr.detectChanges();
554
486
  }
555
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
+ }
556
622
  /**
557
- * 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
558
625
  */
559
- // 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
+ }
560
666
  /**
561
667
  * Handle hover effects on a segment
562
668
  */
@@ -564,23 +670,22 @@ class AXDonutChartComponent extends AXChartComponent {
564
670
  if (this._isInitialAnimating())
565
671
  return;
566
672
  if (this.effectiveOptions().showTooltip !== false) {
567
- const index = this.data().findIndex((item) => item.id === datum.id);
568
- const color = datum.color || getChartColor(index, this.chartColors);
569
- // Calculate percentage of total
570
- // Ensure data() is accessed within a tracking context if it's a signal, or use untracked if appropriate
571
- const total = untracked(() => this.data()).reduce((sum, item) => sum + item.value, 0);
572
- const percentage = total > 0 ? ((datum.value / total) * 100).toFixed(1) : '0';
573
- this._tooltipData.set({
574
- title: datum.tooltipLabel || datum.label,
575
- value: datum.value.toString(),
576
- percentage: `${percentage}%`,
577
- color: color,
578
- });
579
- this.updateTooltipPosition(event);
580
- this._tooltipVisible.set(true);
673
+ this.showTooltip(event, datum);
581
674
  }
582
- this.highlightSegment(datum.id); // This will now also handle emitting segmentHover
583
- // 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);
584
689
  }
585
690
  /**
586
691
  * Handles mouse leave from segments
@@ -614,9 +719,9 @@ class AXDonutChartComponent extends AXChartComponent {
614
719
  addCenterDisplay(total) {
615
720
  if (!this.svg)
616
721
  return;
617
- // Calculate appropriate font sizes based on chart dimensions
618
722
  const chartContainerWidth = this.chartContainerEl().nativeElement.clientWidth;
619
- 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));
620
725
  const subTextFontSize = baseFontSize * 0.5;
621
726
  // Create group for the total display
622
727
  const totalDisplay = this.svg
@@ -654,11 +759,11 @@ class AXDonutChartComponent extends AXChartComponent {
654
759
  * Cleans up chart resources
655
760
  */
656
761
  cleanupChart() {
657
- if (this.svg) {
658
- this.d3.select(this.chartContainerEl()?.nativeElement).selectAll('svg').remove();
659
- this.svg = null;
660
- this.pieData = [];
661
- }
762
+ if (!this.svg)
763
+ return;
764
+ this.d3.select(this.chartContainerEl()?.nativeElement).selectAll('svg').remove();
765
+ this.svg = null;
766
+ this.pieData = [];
662
767
  this.hiddenSegments.clear();
663
768
  this._tooltipVisible.set(false);
664
769
  this._currentlyHighlightedSegment.set(null);
@@ -681,18 +786,17 @@ class AXDonutChartComponent extends AXChartComponent {
681
786
  // computeTooltipPosition moved to shared chart utils
682
787
  /**
683
788
  * Gets an accessibility label describing the donut chart for screen readers
789
+ * @returns Descriptive string for screen readers
684
790
  */
685
791
  getAccessibilityLabel() {
686
792
  const data = this.chartDataArray();
687
793
  if (!data || data.length === 0) {
688
794
  return 'Empty donut chart. No data available.';
689
795
  }
690
- // Calculate total
691
796
  const total = data.reduce((sum, item) => sum + item.value, 0);
692
- // Generate a description of the chart with percentages
693
797
  const segmentDescriptions = data
694
798
  .map((segment) => {
695
- const percentage = ((segment.value / total) * 100).toFixed(1);
799
+ const percentage = total > 0 ? ((segment.value / total) * 100).toFixed(1) : '0';
696
800
  return `${segment.label}: ${segment.value} (${percentage}%)`;
697
801
  })
698
802
  .join('; ');
@@ -703,16 +807,15 @@ class AXDonutChartComponent extends AXChartComponent {
703
807
  * @implements AXChartLegendCompatible
704
808
  */
705
809
  getLegendItems() {
706
- return this.chartDataArray().map((item, index) => {
707
- return {
708
- id: item.id,
709
- name: item.label || `Segment ${index + 1}`,
710
- value: item.value,
711
- color: item.color || this.getColor(index),
712
- percentage: (item.value / this.data().reduce((sum, item) => sum + item.value, 0)) * 100,
713
- hidden: this.isSegmentHidden(item.id),
714
- };
715
- });
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
+ }));
716
819
  }
717
820
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.6", ngImport: i0, type: AXDonutChartComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
718
821
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "20.3.6", 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 });