@internetstiftelsen/charts 0.13.2 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
1
  import { arc, easeBounceOut, easeCubicIn, easeCubicInOut, easeCubicOut, easeElasticOut, easeLinear, interpolateNumber, } from 'd3';
2
+ import { createCubicBezierEasing } from './easing.js';
2
3
  import { DEFAULT_COLOR_PALETTE } from './theme.js';
3
4
  import { ChartValidator } from './validation.js';
4
5
  import { RadialChartBase } from './radial-chart-base.js';
@@ -37,6 +38,7 @@ const DEFAULT_TICK_LABEL_OFFSET = 12;
37
38
  const DEFAULT_TICK_LABEL_FONT_SIZE = 11;
38
39
  const DEFAULT_TICK_LABEL_FONT_WEIGHT = 'normal';
39
40
  const DEFAULT_TICK_LABEL_COLOR = '#4b5563';
41
+ const DEFAULT_LABEL_OVERSIZED_BEHAVIOR = 'truncate';
40
42
  const DEFAULT_VALUE_LABEL_FONT_SIZE = 28;
41
43
  const DEFAULT_VALUE_LABEL_FONT_WEIGHT = '700';
42
44
  const DEFAULT_VALUE_LABEL_COLOR = '#111827';
@@ -49,6 +51,7 @@ const DEFAULT_PROGRESS_RADIUS_INSET = 2;
49
51
  const MIN_PROGRESS_BAND_THICKNESS = 1;
50
52
  const DEFAULT_HALF_CIRCLE_VALUE_TEXT_Y = 32;
51
53
  const DEFAULT_FULL_CIRCLE_VALUE_TEXT_MIN_Y = 22;
54
+ const easeSpringOut = createCubicBezierEasing(0.85, 0, 0.15, 1);
52
55
  const GAUGE_ANIMATION_EASING_PRESETS = {
53
56
  linear: easeLinear,
54
57
  'ease-in': easeCubicIn,
@@ -56,6 +59,7 @@ const GAUGE_ANIMATION_EASING_PRESETS = {
56
59
  'ease-in-out': easeCubicInOut,
57
60
  'bounce-out': easeBounceOut,
58
61
  'elastic-out': easeElasticOut,
62
+ 'spring-out': easeSpringOut,
59
63
  };
60
64
  const DUMMY_ARC_DATUM = {
61
65
  innerRadius: 0,
@@ -537,21 +541,31 @@ export class GaugeChart extends RadialChartBase {
537
541
  return lastPoint.value;
538
542
  }
539
543
  normalizeTickLabelStyle(config) {
540
- return {
541
- fontSize: config?.fontSize ?? DEFAULT_TICK_LABEL_FONT_SIZE,
542
- fontFamily: config?.fontFamily ?? this.theme.axis.fontFamily,
543
- fontWeight: config?.fontWeight ??
544
- this.theme.axis.fontWeight ??
545
- DEFAULT_TICK_LABEL_FONT_WEIGHT,
546
- color: config?.color ?? DEFAULT_TICK_LABEL_COLOR,
547
- };
544
+ return this.normalizeGaugeLabelStyle(config, {
545
+ fontSize: DEFAULT_TICK_LABEL_FONT_SIZE,
546
+ fontFamily: this.theme.axis.fontFamily,
547
+ fontWeight: this.theme.axis.fontWeight ?? DEFAULT_TICK_LABEL_FONT_WEIGHT,
548
+ color: DEFAULT_TICK_LABEL_COLOR,
549
+ });
548
550
  }
549
551
  normalizeValueLabelStyle(config) {
552
+ return this.normalizeGaugeLabelStyle(config, {
553
+ fontSize: DEFAULT_VALUE_LABEL_FONT_SIZE,
554
+ fontFamily: this.theme.axis.fontFamily,
555
+ fontWeight: DEFAULT_VALUE_LABEL_FONT_WEIGHT,
556
+ color: DEFAULT_VALUE_LABEL_COLOR,
557
+ });
558
+ }
559
+ normalizeGaugeLabelStyle(config, defaults) {
560
+ const resolvedConfig = config ?? {};
550
561
  return {
551
- fontSize: config?.fontSize ?? DEFAULT_VALUE_LABEL_FONT_SIZE,
552
- fontFamily: config?.fontFamily ?? this.theme.axis.fontFamily,
553
- fontWeight: config?.fontWeight ?? DEFAULT_VALUE_LABEL_FONT_WEIGHT,
554
- color: config?.color ?? DEFAULT_VALUE_LABEL_COLOR,
562
+ fontSize: resolveDefault(resolvedConfig.fontSize, defaults.fontSize),
563
+ fontFamily: resolveDefault(resolvedConfig.fontFamily, defaults.fontFamily),
564
+ fontWeight: resolveDefault(resolvedConfig.fontWeight, defaults.fontWeight),
565
+ color: resolveDefault(resolvedConfig.color, defaults.color),
566
+ maxLabelWidth: resolvedConfig.maxLabelWidth,
567
+ oversizedBehavior: resolveDefault(resolvedConfig.oversizedBehavior, DEFAULT_LABEL_OVERSIZED_BEHAVIOR),
568
+ forceVisible: resolvedConfig.forceVisible ?? false,
555
569
  };
556
570
  }
557
571
  validateGaugeConfig() {
@@ -1107,7 +1121,8 @@ export class GaugeChart extends RadialChartBase {
1107
1121
  : labelPoint.x > 0
1108
1122
  ? 'start'
1109
1123
  : 'end';
1110
- tickGroup
1124
+ const tickLabelText = this.ticks.formatter(value);
1125
+ const textElement = tickGroup
1111
1126
  .append('text')
1112
1127
  .attr('class', 'gauge-tick-label')
1113
1128
  .attr('x', labelPoint.x)
@@ -1117,8 +1132,8 @@ export class GaugeChart extends RadialChartBase {
1117
1132
  .attr('font-size', this.tickLabelStyle.fontSize)
1118
1133
  .attr('font-family', this.tickLabelStyle.fontFamily)
1119
1134
  .attr('font-weight', this.tickLabelStyle.fontWeight)
1120
- .attr('fill', this.tickLabelStyle.color)
1121
- .text(this.ticks.formatter(value));
1135
+ .attr('fill', this.tickLabelStyle.color);
1136
+ this.renderGaugeLabelText(textElement, tickLabelText, this.tickLabelStyle);
1122
1137
  }
1123
1138
  }
1124
1139
  renderTargetMarker(gaugeGroup, innerRadius, outerRadius) {
@@ -1243,8 +1258,8 @@ export class GaugeChart extends RadialChartBase {
1243
1258
  .attr('font-size', this.valueLabelStyle.fontSize)
1244
1259
  .attr('font-weight', this.valueLabelStyle.fontWeight)
1245
1260
  .attr('font-family', this.valueLabelStyle.fontFamily)
1246
- .attr('fill', this.valueLabelStyle.color)
1247
- .text(this.valueFormatter(initialValue));
1261
+ .attr('fill', this.valueLabelStyle.color);
1262
+ this.renderGaugeLabelText(valueText, this.valueFormatter(initialValue), this.valueLabelStyle, 'baseline');
1248
1263
  if (shouldAnimate) {
1249
1264
  valueText
1250
1265
  .transition()
@@ -1253,11 +1268,21 @@ export class GaugeChart extends RadialChartBase {
1253
1268
  .tween('text', () => {
1254
1269
  return (progress) => {
1255
1270
  const currentValue = startValue + (this.value - startValue) * progress;
1256
- valueText.text(this.valueFormatter(currentValue));
1271
+ this.renderGaugeLabelText(valueText, this.valueFormatter(currentValue), this.valueLabelStyle, 'baseline');
1257
1272
  };
1258
1273
  });
1259
1274
  }
1260
1275
  }
1276
+ renderGaugeLabelText(textElement, text, labelStyle, verticalAnchor = 'middle') {
1277
+ this.renderRadialLabelText(textElement, text, {
1278
+ maxLabelWidth: labelStyle.maxLabelWidth,
1279
+ oversizedBehavior: labelStyle.oversizedBehavior,
1280
+ forceVisible: labelStyle.forceVisible,
1281
+ fontSize: labelStyle.fontSize,
1282
+ fontFamily: labelStyle.fontFamily,
1283
+ fontWeight: labelStyle.fontWeight,
1284
+ }, verticalAnchor);
1285
+ }
1261
1286
  attachTooltipLayer(gaugeGroup, innerRadius, outerRadius, progressColor) {
1262
1287
  const interactionArc = arc()
1263
1288
  .innerRadius(Math.max(0, innerRadius - 20))
package/dist/line.js CHANGED
@@ -267,6 +267,7 @@ export class Line {
267
267
  const border = config.border ?? theme.valueLabel.border;
268
268
  const borderRadius = config.borderRadius ?? theme.valueLabel.borderRadius;
269
269
  const padding = config.padding ?? theme.valueLabel.padding;
270
+ const forceVisible = config.forceVisible === true;
270
271
  const labelGroup = plotGroup
271
272
  .append('g')
272
273
  .attr('class', `line-value-labels-${sanitizeForCSS(this.dataKey)}`);
@@ -299,7 +300,7 @@ export class Line {
299
300
  labelY = yPos + boxHeight / 2 + theme.line.point.size + 4;
300
301
  // Check if it fits below
301
302
  if (labelY + boxHeight / 2 > plotBottom - 4) {
302
- shouldRender = false;
303
+ shouldRender = forceVisible;
303
304
  }
304
305
  }
305
306
  tempText.remove();
@@ -1,7 +1,10 @@
1
- import type { DataItem, LegendSeries } from './types.js';
1
+ import type { DataItem, LabelOversizedBehavior, LegendSeries } from './types.js';
2
2
  import { type BaseChartConfig, type BaseRenderContext } from './base-chart.js';
3
3
  import type { ChartComponentBase } from './chart-interface.js';
4
4
  import { RadialChartBase } from './radial-chart-base.js';
5
+ import { type RadialAnimationConfig, type RadialAnimationEasingPreset } from './radial-animation.js';
6
+ export type PieAnimationConfig = RadialAnimationConfig;
7
+ export type PieAnimationEasingPreset = RadialAnimationEasingPreset;
5
8
  export type PieSort = 'none' | 'ascending' | 'descending' | ((a: PieSegmentData, b: PieSegmentData) => number);
6
9
  export type PieConfig = {
7
10
  innerRadius?: number;
@@ -12,6 +15,7 @@ export type PieConfig = {
12
15
  sort?: PieSort;
13
16
  };
14
17
  export type PieValueLabelPosition = 'inside' | 'outside' | 'auto';
18
+ export type PieValueLabelFormatter = (label: string, value: number, data: DataItem, percentage: number) => string;
15
19
  export type PieValueLabelConfig = {
16
20
  show?: boolean;
17
21
  position?: PieValueLabelPosition;
@@ -19,11 +23,18 @@ export type PieValueLabelConfig = {
19
23
  outsideOffset?: number;
20
24
  insideMargin?: number;
21
25
  minVerticalSpacing?: number;
22
- formatter?: (label: string, value: number, data: DataItem, percentage: number) => string;
26
+ maxLabelWidth?: number;
27
+ oversizedBehavior?: LabelOversizedBehavior;
28
+ forceVisible?: boolean;
29
+ labelFormatter?: PieValueLabelFormatter;
30
+ valueFormatter?: PieValueLabelFormatter;
31
+ separator?: string;
32
+ formatter?: PieValueLabelFormatter;
23
33
  };
24
34
  export type PieChartConfig = BaseChartConfig & {
25
35
  pie?: PieConfig;
26
36
  valueLabel?: PieValueLabelConfig;
37
+ animate?: boolean | PieAnimationConfig;
27
38
  valueKey?: string;
28
39
  labelKey?: string;
29
40
  };
@@ -43,6 +54,7 @@ export declare class PieChart extends RadialChartBase {
43
54
  private readonly valueKey;
44
55
  private readonly labelKey;
45
56
  private readonly valueLabel;
57
+ private readonly motionController;
46
58
  private segments;
47
59
  constructor(config: PieChartConfig);
48
60
  private validatePieData;
@@ -53,7 +65,9 @@ export declare class PieChart extends RadialChartBase {
53
65
  private warnOnTinySlices;
54
66
  protected getExportComponents(): ChartComponentBase[];
55
67
  update(data: DataItem[]): void;
56
- private formatValueLabelText;
68
+ protected prepareForLegendChange(): void;
69
+ private resolveValueLabelText;
70
+ private getValueLabelPercentage;
57
71
  protected createExportChart(): RadialChartBase;
58
72
  protected syncDerivedState(): void;
59
73
  protected renderChart({ svg, plotGroup, plotArea, }: BaseRenderContext): void;
@@ -69,6 +83,8 @@ export declare class PieChart extends RadialChartBase {
69
83
  private resolveValueLabelPlacement;
70
84
  private canFitInsideLabel;
71
85
  private resolveOutsideLabel;
86
+ private measureValueLabelDimensions;
72
87
  private adjustOutsideLabelPositions;
88
+ private getOutsideLabelSpacing;
73
89
  }
74
90
  export {};
package/dist/pie-chart.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import { arc, pie, select } from 'd3';
2
2
  import { ChartValidator } from './validation.js';
3
- import { getContrastTextColor, measureTextWidth, sanitizeForCSS, } from './utils.js';
3
+ import { getContrastTextColor, sanitizeForCSS } from './utils.js';
4
4
  import { RadialChartBase } from './radial-chart-base.js';
5
+ import { buildRadialAnimatedArcData, buildRadialExitTargetPieData, createTransitionCompletionPromise, interpolateRadialArcDatum, interpolateRadialArcShape, normalizeRadialAnimationConfig, renderRadialArcDatum, RadialMotionController, } from './radial-animation.js';
5
6
  const HOVER_EXPAND_PX = 8;
6
- const ANIMATION_DURATION_MS = 150;
7
+ const HOVER_ANIMATION_DURATION_MS = 150;
7
8
  const FULL_CIRCLE_RADIANS = Math.PI * 2;
8
9
  const OUTSIDE_LABEL_TEXT_OFFSET_PX = 10;
9
10
  const OUTSIDE_LABEL_LINE_INSET_PX = 4;
@@ -15,9 +16,9 @@ const DEFAULT_PIE_VALUE_LABEL = {
15
16
  outsideOffset: 16,
16
17
  insideMargin: 8,
17
18
  minVerticalSpacing: 14,
18
- formatter: (label, value, _data, _percentage) => {
19
- return `${label}: ${value}`;
20
- },
19
+ oversizedBehavior: 'truncate',
20
+ forceVisible: false,
21
+ separator: ': ',
21
22
  };
22
23
  export class PieChart extends RadialChartBase {
23
24
  constructor(config) {
@@ -76,6 +77,12 @@ export class PieChart extends RadialChartBase {
76
77
  writable: true,
77
78
  value: void 0
78
79
  });
80
+ Object.defineProperty(this, "motionController", {
81
+ enumerable: true,
82
+ configurable: true,
83
+ writable: true,
84
+ value: void 0
85
+ });
79
86
  Object.defineProperty(this, "segments", {
80
87
  enumerable: true,
81
88
  configurable: true,
@@ -108,6 +115,7 @@ export class PieChart extends RadialChartBase {
108
115
  ...DEFAULT_PIE_VALUE_LABEL,
109
116
  ...config.valueLabel,
110
117
  };
118
+ this.motionController = new RadialMotionController(normalizeRadialAnimationConfig(config.animate, 'PieChart'));
111
119
  this.initializeDataState();
112
120
  }
113
121
  validatePieData() {
@@ -203,10 +211,36 @@ export class PieChart extends RadialChartBase {
203
211
  });
204
212
  }
205
213
  update(data) {
214
+ this.motionController.prepareForUpdate();
206
215
  super.update(data);
207
216
  }
208
- formatValueLabelText(segment, total) {
209
- return this.valueLabel.formatter(segment.label, segment.value, segment.source, total > 0 ? (segment.value / total) * 100 : 0);
217
+ prepareForLegendChange() {
218
+ this.motionController.prepareForUpdate();
219
+ }
220
+ resolveValueLabelText(segment, total) {
221
+ const percentage = this.getValueLabelPercentage(segment, total);
222
+ if (this.valueLabel.formatter) {
223
+ const fullText = this.valueLabel.formatter(segment.label, segment.value, segment.source, percentage);
224
+ return {
225
+ label: fullText,
226
+ value: '',
227
+ separator: '',
228
+ fullText,
229
+ isCustom: true,
230
+ };
231
+ }
232
+ const label = this.valueLabel.labelFormatter?.(segment.label, segment.value, segment.source, percentage) ?? segment.label;
233
+ const value = this.valueLabel.valueFormatter?.(segment.label, segment.value, segment.source, percentage) ?? String(segment.value);
234
+ return {
235
+ label,
236
+ value,
237
+ separator: this.valueLabel.separator,
238
+ fullText: `${label}${this.valueLabel.separator}${value}`,
239
+ isCustom: false,
240
+ };
241
+ }
242
+ getValueLabelPercentage(segment, total) {
243
+ return total > 0 ? (segment.value / total) * 100 : 0;
210
244
  }
211
245
  createExportChart() {
212
246
  return new PieChart({
@@ -224,6 +258,7 @@ export class PieChart extends RadialChartBase {
224
258
  sort: this.sort,
225
259
  },
226
260
  valueLabel: this.valueLabel,
261
+ animate: false,
227
262
  valueKey: this.valueKey,
228
263
  labelKey: this.labelKey,
229
264
  });
@@ -235,16 +270,16 @@ export class PieChart extends RadialChartBase {
235
270
  renderChart({ svg, plotGroup, plotArea, }) {
236
271
  this.renderTitle(svg);
237
272
  const visibleSegments = this.getVisibleRadialItems(this.segments);
273
+ const animationContext = this.motionController.getAnimationContext();
274
+ const { cx, cy, outerRadius, innerRadius, fontScale } = this.getRadialLayout(plotArea, this.innerRadiusRatio);
238
275
  this.initializeTooltip();
239
- if (visibleSegments.length > 0) {
240
- const { cx, cy, outerRadius, innerRadius, fontScale } = this.getRadialLayout(plotArea, this.innerRadiusRatio);
241
- const { segmentGroup, pieData } = this.renderSegments(plotGroup, visibleSegments, cx, cy, innerRadius, outerRadius);
242
- if (this.valueLabel.show) {
243
- this.renderLabels(segmentGroup, pieData, innerRadius, outerRadius, visibleSegments.reduce((sum, segment) => {
244
- return sum + segment.value;
245
- }, 0), fontScale);
246
- }
276
+ const { segmentGroup, pieData, transitions } = this.renderSegments(plotGroup, visibleSegments, cx, cy, innerRadius, outerRadius, animationContext, this.segments);
277
+ if (this.valueLabel.show && visibleSegments.length > 0) {
278
+ this.renderLabels(segmentGroup, pieData, innerRadius, outerRadius, visibleSegments.reduce((sum, segment) => {
279
+ return sum + segment.value;
280
+ }, 0), fontScale);
247
281
  }
282
+ this.setReadyPromise(this.motionController.completeRender(pieData, transitions));
248
283
  this.renderInlineLegend(svg);
249
284
  }
250
285
  getLegendSeries() {
@@ -262,7 +297,7 @@ export class PieChart extends RadialChartBase {
262
297
  }
263
298
  return null;
264
299
  }
265
- renderSegments(plotGroup, segments, cx, cy, innerRadius, outerRadius) {
300
+ renderSegments(plotGroup, segments, cx, cy, innerRadius, outerRadius, animationContext, allSegments) {
266
301
  const pieGenerator = pie()
267
302
  .value((d) => d.value)
268
303
  .startAngle(this.startAngle)
@@ -284,30 +319,72 @@ export class PieChart extends RadialChartBase {
284
319
  .outerRadius(outerRadius + HOVER_EXPAND_PX)
285
320
  .cornerRadius(this.cornerRadius);
286
321
  const pieData = pieGenerator(segments);
322
+ const exitTargetPieData = buildRadialExitTargetPieData(pieGenerator, segments, allSegments, animationContext, pieData);
323
+ const arcShape = {
324
+ innerRadius,
325
+ outerRadius,
326
+ cornerRadius: this.cornerRadius,
327
+ };
287
328
  const segmentGroup = plotGroup
288
329
  .append('g')
289
330
  .attr('class', 'pie-segments')
290
331
  .attr('transform', `translate(${cx}, ${cy})`);
291
332
  const total = segments.reduce((sum, segment) => sum + segment.value, 0);
333
+ const animatedArcData = buildRadialAnimatedArcData(pieData, allSegments, animationContext, arcShape, exitTargetPieData);
334
+ const animationByDatum = new Map(animatedArcData.map((entry) => [entry.datum, entry]));
335
+ const getAnimation = (datum) => {
336
+ return animationByDatum.get(datum);
337
+ };
338
+ const renderedArcData = animatedArcData.map((entry) => entry.datum);
339
+ const transitions = [];
292
340
  const segmentSelection = segmentGroup
293
341
  .selectAll('.pie-segment')
294
- .data(pieData)
342
+ .data(renderedArcData, (d) => getAnimation(d).key)
295
343
  .join('path')
296
344
  .attr('class', (d) => `pie-segment segment-${sanitizeForCSS(d.data.label)}`)
297
- .attr('d', arcGenerator)
345
+ .attr('d', (d) => {
346
+ const animation = getAnimation(d);
347
+ return renderRadialArcDatum(animation.startDatum, animation.startShape);
348
+ })
298
349
  .attr('fill', (d) => d.data.color)
299
- .attr('tabindex', 0)
300
- .attr('aria-label', (d) => this.buildAriaLabel(d, total))
301
- .style('cursor', 'pointer')
350
+ .attr('opacity', (d) => getAnimation(d).initialOpacity)
351
+ .attr('tabindex', (d) => (getAnimation(d).interactive ? 0 : null))
352
+ .attr('aria-hidden', (d) => getAnimation(d).interactive ? null : 'true')
353
+ .attr('aria-label', (d) => {
354
+ return getAnimation(d).interactive
355
+ ? this.buildAriaLabel(d, total)
356
+ : null;
357
+ })
358
+ .style('cursor', (d) => getAnimation(d).interactive ? 'pointer' : 'default')
359
+ .style('pointer-events', (d) => getAnimation(d).interactive ? 'all' : 'none')
302
360
  .style('transition', 'opacity 0.15s ease');
303
- segmentSelection
361
+ if (animationContext) {
362
+ const transition = segmentSelection
363
+ .transition()
364
+ .duration(animationContext.duration)
365
+ .ease(animationContext.easing)
366
+ .attrTween('d', (d) => {
367
+ const animation = getAnimation(d);
368
+ const datumInterpolator = interpolateRadialArcDatum(animation.startDatum, animation.endDatum);
369
+ const shapeInterpolator = interpolateRadialArcShape(animation.startShape, animation.endShape);
370
+ return (progress) => {
371
+ return renderRadialArcDatum(datumInterpolator(progress), shapeInterpolator(progress));
372
+ };
373
+ })
374
+ .attr('opacity', (d) => getAnimation(d).finalOpacity);
375
+ transitions.push(createTransitionCompletionPromise(transition));
376
+ }
377
+ const interactiveSegmentSelection = segmentSelection.filter((d) => {
378
+ return getAnimation(d).interactive;
379
+ });
380
+ interactiveSegmentSelection
304
381
  .on('mouseenter', (event, d) => {
305
382
  const target = event.currentTarget;
306
383
  select(target)
307
384
  .transition()
308
- .duration(ANIMATION_DURATION_MS)
385
+ .duration(HOVER_ANIMATION_DURATION_MS)
309
386
  .attr('d', hoverArcGenerator(d));
310
- segmentSelection
387
+ interactiveSegmentSelection
311
388
  .filter((_, i, nodes) => nodes[i] !== target)
312
389
  .style('opacity', 0.5);
313
390
  this.showTooltipFromPointer(event, this.buildTooltipContent(d, segments));
@@ -319,18 +396,18 @@ export class PieChart extends RadialChartBase {
319
396
  const target = event.currentTarget;
320
397
  select(target)
321
398
  .transition()
322
- .duration(ANIMATION_DURATION_MS)
399
+ .duration(HOVER_ANIMATION_DURATION_MS)
323
400
  .attr('d', arcGenerator(d));
324
- segmentSelection.style('opacity', 1);
401
+ interactiveSegmentSelection.style('opacity', 1);
325
402
  this.hideTooltip();
326
403
  })
327
404
  .on('focus', (event, d) => {
328
405
  const target = event.currentTarget;
329
406
  select(target)
330
407
  .transition()
331
- .duration(ANIMATION_DURATION_MS)
408
+ .duration(HOVER_ANIMATION_DURATION_MS)
332
409
  .attr('d', hoverArcGenerator(d));
333
- segmentSelection
410
+ interactiveSegmentSelection
334
411
  .filter((_, i, nodes) => nodes[i] !== target)
335
412
  .style('opacity', 0.5);
336
413
  this.showTooltipAtElement(target, this.buildTooltipContent(d, segments));
@@ -339,9 +416,9 @@ export class PieChart extends RadialChartBase {
339
416
  const target = event.currentTarget;
340
417
  select(target)
341
418
  .transition()
342
- .duration(ANIMATION_DURATION_MS)
419
+ .duration(HOVER_ANIMATION_DURATION_MS)
343
420
  .attr('d', arcGenerator(d));
344
- segmentSelection.style('opacity', 1);
421
+ interactiveSegmentSelection.style('opacity', 1);
345
422
  this.hideTooltip();
346
423
  })
347
424
  .on('keydown', (event) => {
@@ -350,6 +427,7 @@ export class PieChart extends RadialChartBase {
350
427
  return {
351
428
  segmentGroup,
352
429
  pieData,
430
+ transitions,
353
431
  };
354
432
  }
355
433
  handleSegmentKeyNavigation(event) {
@@ -415,30 +493,44 @@ export class PieChart extends RadialChartBase {
415
493
  .innerRadius(insideLabelRadius)
416
494
  .outerRadius(insideLabelRadius);
417
495
  const fontSize = this.renderTheme.legend.fontSize * fontScale;
496
+ const fontFamily = this.renderTheme.axis.fontFamily;
418
497
  const fontWeight = this.renderTheme.axis.fontWeight ?? 'normal';
498
+ const labelOverflowOptions = {
499
+ maxLabelWidth: this.valueLabel.maxLabelWidth,
500
+ oversizedBehavior: this.valueLabel.oversizedBehavior,
501
+ forceVisible: this.valueLabel.forceVisible,
502
+ fontSize,
503
+ fontFamily,
504
+ fontWeight,
505
+ };
419
506
  const outsideLabels = [];
420
507
  pieData.forEach((d) => {
421
508
  const percentage = total > 0 ? (d.data.value / total) * 100 : 0;
422
- const labelText = this.formatValueLabelText(d.data, total);
423
- const placement = this.resolveValueLabelPlacement(d, labelText, percentage, innerRadius, outerRadius, insideLabelRadius, fontSize, fontWeight);
509
+ const valueLabel = this.resolveValueLabelText(d.data, total);
510
+ const labelDimensions = this.measureValueLabelDimensions(valueLabel, labelOverflowOptions);
511
+ const placement = this.resolveValueLabelPlacement(d, labelDimensions, percentage, innerRadius, outerRadius, insideLabelRadius);
424
512
  if (placement === 'inside') {
425
513
  const [x, y] = insideArc.centroid(d);
426
- labelGroup
514
+ const textElement = labelGroup
427
515
  .append('text')
428
516
  .attr('class', `pie-label pie-label--inside pie-label-${sanitizeForCSS(d.data.label)}`)
429
517
  .attr('x', x)
430
518
  .attr('y', y)
431
519
  .attr('text-anchor', 'middle')
432
520
  .attr('dominant-baseline', 'middle')
433
- .attr('font-family', this.renderTheme.axis.fontFamily)
521
+ .attr('font-family', fontFamily)
434
522
  .attr('font-size', `${fontSize}px`)
435
523
  .attr('font-weight', fontWeight)
436
- .attr('fill', getContrastTextColor(d.data.color))
437
- .text(labelText);
524
+ .attr('fill', getContrastTextColor(d.data.color));
525
+ if (valueLabel.isCustom) {
526
+ this.renderRadialLabelText(textElement, valueLabel.fullText, labelOverflowOptions);
527
+ return;
528
+ }
529
+ this.renderRadialStructuredLabelText(textElement, valueLabel.label, valueLabel.value, valueLabel.separator, labelOverflowOptions);
438
530
  return;
439
531
  }
440
532
  if (placement === 'outside') {
441
- outsideLabels.push(this.resolveOutsideLabel(d, outerRadius));
533
+ outsideLabels.push(this.resolveOutsideLabel(d, outerRadius, valueLabel, labelDimensions.height));
442
534
  }
443
535
  });
444
536
  if (outsideLabels.length === 0) {
@@ -468,24 +560,30 @@ export class PieChart extends RadialChartBase {
468
560
  .attr('fill', 'none')
469
561
  .attr('stroke', '#9ca3af')
470
562
  .attr('stroke-width', 1);
471
- labelGroup
563
+ const textElement = labelGroup
472
564
  .append('text')
473
565
  .attr('class', `pie-label pie-label--outside pie-label-${sanitizeForCSS(outsideLabel.datum.data.label)}`)
474
566
  .attr('x', textX)
475
567
  .attr('y', outsideLabel.y)
476
568
  .attr('text-anchor', outsideLabel.textAnchor)
477
569
  .attr('dominant-baseline', 'middle')
478
- .attr('font-family', this.renderTheme.axis.fontFamily)
570
+ .attr('font-family', fontFamily)
479
571
  .attr('font-size', `${fontSize}px`)
480
572
  .attr('font-weight', fontWeight)
481
- .attr('fill', this.renderTheme.valueLabel.color)
482
- .text(this.formatValueLabelText(outsideLabel.datum.data, total));
573
+ .attr('fill', this.renderTheme.valueLabel.color);
574
+ if (outsideLabel.valueLabel.isCustom) {
575
+ this.renderRadialLabelText(textElement, outsideLabel.valueLabel.fullText, labelOverflowOptions);
576
+ return;
577
+ }
578
+ this.renderRadialStructuredLabelText(textElement, outsideLabel.valueLabel.label, outsideLabel.valueLabel.value, outsideLabel.valueLabel.separator, labelOverflowOptions);
483
579
  });
484
580
  }
485
- resolveValueLabelPlacement(datum, labelText, percentage, innerRadius, outerRadius, insideLabelRadius, fontSize, fontWeight) {
486
- const fitsInside = this.canFitInsideLabel(datum, labelText, innerRadius, outerRadius, insideLabelRadius, fontSize, fontWeight);
581
+ resolveValueLabelPlacement(datum, labelDimensions, percentage, innerRadius, outerRadius, insideLabelRadius) {
582
+ const fitsInside = this.canFitInsideLabel(datum, labelDimensions, innerRadius, outerRadius, insideLabelRadius);
487
583
  if (this.valueLabel.position === 'inside') {
488
- return fitsInside ? 'inside' : 'hidden';
584
+ return fitsInside || this.valueLabel.forceVisible
585
+ ? 'inside'
586
+ : 'hidden';
489
587
  }
490
588
  if (this.valueLabel.position === 'outside') {
491
589
  return 'outside';
@@ -498,23 +596,14 @@ export class PieChart extends RadialChartBase {
498
596
  }
499
597
  return 'inside';
500
598
  }
501
- canFitInsideLabel(datum, labelText, innerRadius, outerRadius, insideLabelRadius, fontSize, fontWeight) {
502
- if (!this.svg) {
503
- return false;
504
- }
505
- const svgNode = this.svg.node();
506
- if (!svgNode) {
507
- return false;
508
- }
509
- const textWidth = measureTextWidth(labelText, fontSize, this.renderTheme.axis.fontFamily, fontWeight, svgNode);
599
+ canFitInsideLabel(datum, labelDimensions, innerRadius, outerRadius, insideLabelRadius) {
510
600
  const angle = Math.max(0, datum.endAngle - datum.startAngle);
511
601
  const availableArcLength = angle * insideLabelRadius - this.valueLabel.insideMargin * 2;
512
602
  const availableRadialThickness = outerRadius - innerRadius - this.valueLabel.insideMargin * 2;
513
- const verticalFitThreshold = fontSize * 1.15;
514
- return (availableArcLength >= textWidth &&
515
- availableRadialThickness >= verticalFitThreshold);
603
+ return (availableArcLength >= labelDimensions.width &&
604
+ availableRadialThickness >= labelDimensions.height);
516
605
  }
517
- resolveOutsideLabel(datum, outerRadius) {
606
+ resolveOutsideLabel(datum, outerRadius, valueLabel, height) {
518
607
  const midAngle = (datum.startAngle + datum.endAngle) / 2;
519
608
  const point = this.getArcPoint(midAngle, outerRadius + this.valueLabel.outsideOffset);
520
609
  const side = point.x >= 0 ? 'right' : 'left';
@@ -523,8 +612,16 @@ export class PieChart extends RadialChartBase {
523
612
  y: point.y,
524
613
  side,
525
614
  textAnchor: side === 'right' ? 'start' : 'end',
615
+ valueLabel,
616
+ height,
526
617
  };
527
618
  }
619
+ measureValueLabelDimensions(valueLabel, labelOverflowOptions) {
620
+ if (valueLabel.isCustom) {
621
+ return this.measureRadialLabelDimensions(valueLabel.fullText, labelOverflowOptions);
622
+ }
623
+ return this.measureRadialStructuredLabelDimensions(valueLabel.label, valueLabel.value, valueLabel.separator, labelOverflowOptions);
624
+ }
528
625
  adjustOutsideLabelPositions(labels, outerRadius) {
529
626
  const adjustForSide = (side) => {
530
627
  const sideLabels = labels
@@ -537,7 +634,8 @@ export class PieChart extends RadialChartBase {
537
634
  const bottomLimit = outerRadius;
538
635
  sideLabels[0].y = Math.max(topLimit, sideLabels[0].y);
539
636
  for (let i = 1; i < sideLabels.length; i++) {
540
- const minY = sideLabels[i - 1].y + this.valueLabel.minVerticalSpacing;
637
+ const minY = sideLabels[i - 1].y +
638
+ this.getOutsideLabelSpacing(sideLabels[i - 1], sideLabels[i]);
541
639
  sideLabels[i].y = Math.max(sideLabels[i].y, minY);
542
640
  }
543
641
  const overflow = sideLabels[sideLabels.length - 1].y - bottomLimit;
@@ -545,7 +643,7 @@ export class PieChart extends RadialChartBase {
545
643
  sideLabels[sideLabels.length - 1].y -= overflow;
546
644
  for (let i = sideLabels.length - 2; i >= 0; i--) {
547
645
  const maxY = sideLabels[i + 1].y -
548
- this.valueLabel.minVerticalSpacing;
646
+ this.getOutsideLabelSpacing(sideLabels[i], sideLabels[i + 1]);
549
647
  sideLabels[i].y = Math.min(sideLabels[i].y, maxY);
550
648
  }
551
649
  const underflow = topLimit - sideLabels[0].y;
@@ -564,4 +662,7 @@ export class PieChart extends RadialChartBase {
564
662
  const leftLabels = adjustForSide('left');
565
663
  return [...rightLabels, ...leftLabels];
566
664
  }
665
+ getOutsideLabelSpacing(previousLabel, currentLabel) {
666
+ return Math.max(this.valueLabel.minVerticalSpacing, (previousLabel.height + currentLabel.height) / 2);
667
+ }
567
668
  }