@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.
- package/bar-chart/index.d.ts +58 -2
- package/donut-chart/index.d.ts +42 -3
- package/fesm2022/acorex-charts-bar-chart.mjs +258 -119
- package/fesm2022/acorex-charts-bar-chart.mjs.map +1 -1
- package/fesm2022/acorex-charts-chart-legend.mjs.map +1 -1
- package/fesm2022/acorex-charts-donut-chart.mjs +289 -186
- package/fesm2022/acorex-charts-donut-chart.mjs.map +1 -1
- package/fesm2022/acorex-charts-gauge-chart.mjs +80 -56
- package/fesm2022/acorex-charts-gauge-chart.mjs.map +1 -1
- package/fesm2022/acorex-charts-line-chart.mjs +286 -134
- package/fesm2022/acorex-charts-line-chart.mjs.map +1 -1
- package/fesm2022/acorex-charts.mjs +23 -1
- package/fesm2022/acorex-charts.mjs.map +1 -1
- package/gauge-chart/index.d.ts +4 -1
- package/index.d.ts +5 -1
- package/line-chart/index.d.ts +66 -1
- package/package.json +8 -8
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
128
|
-
//
|
|
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: "
|
|
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
|
-
|
|
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
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
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(
|
|
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',
|
|
194
|
-
|
|
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(
|
|
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',
|
|
202
|
-
|
|
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
|
|
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
|
-
|
|
334
|
-
const
|
|
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
|
-
|
|
419
|
-
|
|
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')
|
|
431
|
+
.style('cursor', 'pointer')
|
|
432
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
431
433
|
.on('mouseenter', (event, d) => {
|
|
432
|
-
if (
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
|
|
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
|
-
|
|
455
|
-
.
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
|
583
|
-
|
|
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
|
-
|
|
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
|
-
|
|
659
|
-
|
|
660
|
-
|
|
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
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
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 });
|