@internetstiftelsen/charts 0.13.3 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/theme.d.ts CHANGED
@@ -1,11 +1,26 @@
1
1
  import { type ChartTheme, type ResponsiveConfig } from './types.js';
2
+ export declare const RUBY_COLOR_PALETTE: string[];
3
+ export declare const PEACOCK_COLOR_PALETTE: string[];
4
+ export declare const JADE_COLOR_PALETTE: string[];
5
+ export declare const LEMON_COLOR_PALETTE: string[];
6
+ export declare const OCEAN_COLOR_PALETTE: string[];
2
7
  export declare const DEFAULT_COLOR_PALETTE: string[];
3
8
  export declare const DEFAULT_CHART_WIDTH = 928;
4
9
  export declare const DEFAULT_CHART_HEIGHT = 600;
5
10
  export declare const defaultTheme: ChartTheme;
11
+ export declare const rubyTheme: ChartTheme;
12
+ export declare const peacockTheme: ChartTheme;
13
+ export declare const jadeTheme: ChartTheme;
14
+ export declare const lemonTheme: ChartTheme;
15
+ export declare const oceanTheme: ChartTheme;
6
16
  export declare const newspaperTheme: ChartTheme;
7
17
  export declare const defaultResponsiveConfig: ResponsiveConfig;
8
18
  export declare const themes: {
9
19
  default: ChartTheme;
20
+ ruby: ChartTheme;
21
+ peacock: ChartTheme;
22
+ jade: ChartTheme;
23
+ lemon: ChartTheme;
24
+ ocean: ChartTheme;
10
25
  newspaper: ChartTheme;
11
26
  };
package/dist/theme.js CHANGED
@@ -1,3 +1,39 @@
1
+ const SYSTEM_FONT = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"';
2
+ export const RUBY_COLOR_PALETTE = [
3
+ '#ff4069',
4
+ '#c51f46',
5
+ '#ff7f99',
6
+ '#ffb3c2',
7
+ '#7f102d',
8
+ ];
9
+ export const PEACOCK_COLOR_PALETTE = [
10
+ '#c27fec',
11
+ '#934bc5',
12
+ '#d5a4f3',
13
+ '#ead2fa',
14
+ '#5f287f',
15
+ ];
16
+ export const JADE_COLOR_PALETTE = [
17
+ '#55c7b4',
18
+ '#2f8f80',
19
+ '#88dacd',
20
+ '#c4eee8',
21
+ '#1b5f55',
22
+ ];
23
+ export const LEMON_COLOR_PALETTE = [
24
+ '#ffce2e',
25
+ '#c89200',
26
+ '#ffe07a',
27
+ '#fff0b8',
28
+ '#7a5900',
29
+ ];
30
+ export const OCEAN_COLOR_PALETTE = [
31
+ '#50b2fc',
32
+ '#147eca',
33
+ '#8bcbfd',
34
+ '#c6e6fe',
35
+ '#0f4f7f',
36
+ ];
1
37
  export const DEFAULT_COLOR_PALETTE = [
2
38
  '#50b2fc', // ocean
3
39
  '#ff4069', // ruby
@@ -11,7 +47,7 @@ export const DEFAULT_COLOR_PALETTE = [
11
47
  export const DEFAULT_CHART_WIDTH = 928;
12
48
  export const DEFAULT_CHART_HEIGHT = 600;
13
49
  export const defaultTheme = {
14
- fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"',
50
+ fontFamily: SYSTEM_FONT,
15
51
  margins: {
16
52
  top: 20,
17
53
  right: 20,
@@ -24,7 +60,7 @@ export const defaultTheme = {
24
60
  },
25
61
  colorPalette: [...DEFAULT_COLOR_PALETTE],
26
62
  axis: {
27
- fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"',
63
+ fontFamily: SYSTEM_FONT,
28
64
  fontSize: 14,
29
65
  fontWeight: 'normal',
30
66
  groupLabel: {
@@ -72,7 +108,7 @@ export const defaultTheme = {
72
108
  background: '#ffffff',
73
109
  border: '#dddddd',
74
110
  color: '#1f2a36',
75
- fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"',
111
+ fontFamily: SYSTEM_FONT,
76
112
  fontSize: 12,
77
113
  fontWeight: 'normal',
78
114
  },
@@ -86,7 +122,7 @@ export const defaultTheme = {
86
122
  },
87
123
  valueLabel: {
88
124
  fontSize: 12,
89
- fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"',
125
+ fontFamily: SYSTEM_FONT,
90
126
  fontWeight: '600',
91
127
  color: '#1f2a36',
92
128
  background: '#ffffff',
@@ -117,6 +153,51 @@ export const defaultTheme = {
117
153
  },
118
154
  },
119
155
  };
156
+ function cloneTheme(theme) {
157
+ return {
158
+ ...theme,
159
+ margins: { ...theme.margins },
160
+ grid: { ...theme.grid },
161
+ colorPalette: [...theme.colorPalette],
162
+ axis: {
163
+ ...theme.axis,
164
+ groupLabel: theme.axis.groupLabel
165
+ ? { ...theme.axis.groupLabel }
166
+ : undefined,
167
+ },
168
+ legend: { ...theme.legend },
169
+ text: {
170
+ variants: Object.fromEntries(Object.entries(theme.text.variants).map(([name, style]) => [
171
+ name,
172
+ { ...style },
173
+ ])),
174
+ },
175
+ tooltip: { ...theme.tooltip },
176
+ line: {
177
+ ...theme.line,
178
+ point: { ...theme.line.point },
179
+ },
180
+ valueLabel: { ...theme.valueLabel },
181
+ donut: {
182
+ ...theme.donut,
183
+ centerContent: {
184
+ mainValue: { ...theme.donut.centerContent.mainValue },
185
+ title: { ...theme.donut.centerContent.title },
186
+ subtitle: { ...theme.donut.centerContent.subtitle },
187
+ },
188
+ },
189
+ };
190
+ }
191
+ function createAccentTheme(colorPalette) {
192
+ const theme = cloneTheme(defaultTheme);
193
+ theme.colorPalette = [...colorPalette];
194
+ return theme;
195
+ }
196
+ export const rubyTheme = createAccentTheme(RUBY_COLOR_PALETTE);
197
+ export const peacockTheme = createAccentTheme(PEACOCK_COLOR_PALETTE);
198
+ export const jadeTheme = createAccentTheme(JADE_COLOR_PALETTE);
199
+ export const lemonTheme = createAccentTheme(LEMON_COLOR_PALETTE);
200
+ export const oceanTheme = createAccentTheme(OCEAN_COLOR_PALETTE);
120
201
  export const newspaperTheme = {
121
202
  fontFamily: 'Georgia, "Times New Roman", Times, serif',
122
203
  margins: {
@@ -261,5 +342,10 @@ export const defaultResponsiveConfig = {
261
342
  };
262
343
  export const themes = {
263
344
  default: defaultTheme,
345
+ ruby: rubyTheme,
346
+ peacock: peacockTheme,
347
+ jade: jadeTheme,
348
+ lemon: lemonTheme,
349
+ ocean: oceanTheme,
264
350
  newspaper: newspaperTheme,
265
351
  };
package/dist/types.d.ts CHANGED
@@ -180,6 +180,7 @@ export type ValueLabelConfig = {
180
180
  borderRadius?: number;
181
181
  padding?: number;
182
182
  formatter?: SeriesValueFormatter;
183
+ forceVisible?: boolean;
183
184
  };
184
185
  export type LineValueLabelConfig = ValueLabelConfig & {
185
186
  show?: boolean;
@@ -2,6 +2,12 @@ import { BaseChart, type BaseChartConfig, type BaseRenderContext } from './base-
2
2
  import type { ChartData } from './types.js';
3
3
  export type WordCloudRotationMode = 'none' | 'right-angle';
4
4
  export type WordCloudSpiral = 'archimedean' | 'rectangular';
5
+ export type WordCloudAnimationEasingPreset = 'linear' | 'ease-in' | 'ease-out' | 'ease-in-out' | 'bounce-out' | 'elastic-out' | 'spring-out';
6
+ export type WordCloudAnimationConfig = {
7
+ show?: boolean;
8
+ duration?: number;
9
+ easing?: WordCloudAnimationEasingPreset | `linear(${string})` | ((progress: number) => number);
10
+ };
5
11
  export type WordCloudConfig = {
6
12
  maxWords?: number;
7
13
  minWordLength?: number;
@@ -14,19 +20,43 @@ export type WordCloudConfig = {
14
20
  };
15
21
  export type WordCloudChartConfig = Pick<BaseChartConfig, 'data' | 'width' | 'height' | 'theme' | 'responsive'> & {
16
22
  wordCloud?: WordCloudConfig;
23
+ animate?: boolean | WordCloudAnimationConfig;
17
24
  };
18
25
  export declare class WordCloudChart extends BaseChart {
19
26
  private readonly options;
27
+ private readonly animation;
20
28
  private layout;
21
29
  private layoutRunId;
22
30
  private resolvePendingReady;
31
+ private hasRenderedLive;
32
+ private nextRenderShouldAnimate;
33
+ private previousWordSnapshot;
23
34
  constructor(config: WordCloudChartConfig);
35
+ update(data: ChartData): void;
24
36
  destroy(): void;
25
37
  protected validateSourceData(data: ChartData): void;
26
38
  protected renderChart({ svg, plotArea }: BaseRenderContext): void;
27
39
  protected createExportChart(): BaseChart;
40
+ private prepareAnimationForUpdate;
28
41
  private startLayout;
29
42
  private renderWords;
43
+ private shouldAnimateWords;
44
+ private createInitialWordTransform;
45
+ private createInitialWordTransformState;
46
+ private createWordTransform;
47
+ private createWordTransformState;
48
+ private createWordPositionTransform;
49
+ private createInitialScaleTransform;
50
+ private createScaleTransform;
51
+ private createInitialWordOpacity;
52
+ private animateWords;
53
+ private createWordGroupAnimationEntries;
54
+ private createWordTextAnimationEntries;
55
+ private applyWordAnimationFrame;
56
+ private requestAnimationFrame;
57
+ private now;
58
+ private completeRender;
59
+ private createWordSnapshot;
30
60
  private stopLayout;
31
61
  private finishReady;
32
62
  }
@@ -2,6 +2,7 @@ import cloud from 'd3-cloud';
2
2
  import { scaleSqrt } from 'd3';
3
3
  import { BaseChart, } from './base-chart.js';
4
4
  import { isGroupedData } from './grouped-data.js';
5
+ import { normalizeRadialAnimationConfig, } from './radial-animation.js';
5
6
  const DEFAULT_OPTIONS = {
6
7
  maxWords: 75,
7
8
  minWordLength: 1,
@@ -12,6 +13,7 @@ const DEFAULT_OPTIONS = {
12
13
  rotation: undefined,
13
14
  spiral: 'archimedean',
14
15
  };
16
+ const INITIAL_WORD_SCALE = 0.2;
15
17
  const GROUPED_DATA_ERROR = 'WordCloudChart: grouped datasets are not supported; provide a flat array of rows instead';
16
18
  function createPreparedWords(data, plotArea, options, colors) {
17
19
  const counts = new Map();
@@ -61,6 +63,12 @@ export class WordCloudChart extends BaseChart {
61
63
  writable: true,
62
64
  value: void 0
63
65
  });
66
+ Object.defineProperty(this, "animation", {
67
+ enumerable: true,
68
+ configurable: true,
69
+ writable: true,
70
+ value: void 0
71
+ });
64
72
  Object.defineProperty(this, "layout", {
65
73
  enumerable: true,
66
74
  configurable: true,
@@ -79,6 +87,24 @@ export class WordCloudChart extends BaseChart {
79
87
  writable: true,
80
88
  value: null
81
89
  });
90
+ Object.defineProperty(this, "hasRenderedLive", {
91
+ enumerable: true,
92
+ configurable: true,
93
+ writable: true,
94
+ value: false
95
+ });
96
+ Object.defineProperty(this, "nextRenderShouldAnimate", {
97
+ enumerable: true,
98
+ configurable: true,
99
+ writable: true,
100
+ value: false
101
+ });
102
+ Object.defineProperty(this, "previousWordSnapshot", {
103
+ enumerable: true,
104
+ configurable: true,
105
+ writable: true,
106
+ value: new Map()
107
+ });
82
108
  const wordCloud = config.wordCloud ?? {};
83
109
  this.options = {
84
110
  maxWords: wordCloud.maxWords ?? DEFAULT_OPTIONS.maxWords,
@@ -90,8 +116,16 @@ export class WordCloudChart extends BaseChart {
90
116
  rotation: wordCloud.rotation,
91
117
  spiral: wordCloud.spiral ?? DEFAULT_OPTIONS.spiral,
92
118
  };
119
+ this.animation = normalizeRadialAnimationConfig(config.animate, 'WordCloudChart');
120
+ this.nextRenderShouldAnimate = this.animation.show;
93
121
  this.initializeDataState();
94
122
  }
123
+ update(data) {
124
+ if (this.container) {
125
+ this.prepareAnimationForUpdate();
126
+ }
127
+ super.update(data);
128
+ }
95
129
  destroy() {
96
130
  this.layoutRunId += 1;
97
131
  this.stopLayout();
@@ -120,8 +154,15 @@ export class WordCloudChart extends BaseChart {
120
154
  theme: this.theme,
121
155
  responsive: this.responsiveConfig,
122
156
  wordCloud: this.options,
157
+ animate: false,
123
158
  });
124
159
  }
160
+ prepareAnimationForUpdate() {
161
+ this.nextRenderShouldAnimate =
162
+ this.animation.show &&
163
+ this.animation.duration > 0 &&
164
+ this.hasRenderedLive;
165
+ }
125
166
  startLayout(words, plotArea, runId, resolve) {
126
167
  const layout = cloud()
127
168
  .words(words.map((word) => ({ ...word })))
@@ -146,8 +187,10 @@ export class WordCloudChart extends BaseChart {
146
187
  if (placedWords.length < words.length) {
147
188
  console.warn(`[Chart Warning] WordCloudChart: rendered ${placedWords.length} of ${words.length} words within the available area; reduce maxWords or font sizes to fit more words`);
148
189
  }
149
- this.renderWords(this.plotGroup, this.plotArea, placedWords);
150
- this.finishReady(resolve);
190
+ const transitions = this.renderWords(this.plotGroup, this.plotArea, placedWords);
191
+ this.completeRender(placedWords, transitions).then(() => {
192
+ this.finishReady(resolve);
193
+ });
151
194
  });
152
195
  if (this.options.rotation === 'none') {
153
196
  layout.rotate(0);
@@ -170,27 +213,184 @@ export class WordCloudChart extends BaseChart {
170
213
  .attr('fill', 'transparent')
171
214
  .attr('stroke', 'none')
172
215
  .attr('pointer-events', 'none');
173
- plotGroup
216
+ const cloudGroup = plotGroup
174
217
  .append('g')
175
218
  .attr('class', 'word-cloud')
176
- .attr('transform', `translate(${plotArea.width / 2}, ${plotArea.height / 2})`)
177
- .selectAll('text')
219
+ .attr('transform', `translate(${plotArea.width / 2}, ${plotArea.height / 2})`);
220
+ const wordGroupSelection = cloudGroup
221
+ .selectAll('g.word-cloud-word-wrapper')
178
222
  .data(words)
179
- .join('text')
223
+ .join('g')
224
+ .attr('class', 'word-cloud-word-wrapper')
225
+ .attr('transform', (word) => this.createWordTransform(word));
226
+ const wordSelection = wordGroupSelection
227
+ .append('text')
180
228
  .attr('class', 'word-cloud-word')
181
229
  .attr('text-anchor', 'middle')
182
230
  .style('font-family', this.renderTheme.fontFamily)
183
231
  .style('font-weight', String(this.renderTheme.valueLabel.fontWeight))
184
232
  .style('font-size', (word) => `${word.size}px`)
185
233
  .style('fill', (word) => word.color)
186
- .attr('transform', (word) => `translate(${word.x ?? 0}, ${word.y ?? 0}) rotate(${word.rotate ?? 0})`)
187
234
  .text((word) => word.text);
235
+ if (!this.shouldAnimateWords()) {
236
+ return [];
237
+ }
238
+ wordGroupSelection.attr('transform', (word) => this.createInitialWordTransform(word));
239
+ wordSelection.attr('transform', (word) => this.createInitialScaleTransform(word));
240
+ wordSelection.attr('opacity', (word) => String(this.createInitialWordOpacity(word)));
241
+ return [this.animateWords(wordGroupSelection, wordSelection)];
242
+ }
243
+ shouldAnimateWords() {
244
+ return (this.nextRenderShouldAnimate &&
245
+ this.animation.show &&
246
+ this.animation.duration > 0);
247
+ }
248
+ createInitialWordTransform(word) {
249
+ return this.createWordPositionTransform(this.createInitialWordTransformState(word));
250
+ }
251
+ createInitialWordTransformState(word) {
252
+ const previous = this.previousWordSnapshot.get(word.text);
253
+ if (previous) {
254
+ return {
255
+ x: previous.x,
256
+ y: previous.y,
257
+ rotate: previous.rotate,
258
+ scale: word.size > 0 ? previous.size / word.size : 1,
259
+ };
260
+ }
261
+ return {
262
+ x: word.x ?? 0,
263
+ y: word.y ?? 0,
264
+ rotate: word.rotate ?? 0,
265
+ scale: INITIAL_WORD_SCALE,
266
+ };
267
+ }
268
+ createWordTransform(word) {
269
+ return this.createWordPositionTransform({
270
+ x: word.x ?? 0,
271
+ y: word.y ?? 0,
272
+ rotate: word.rotate ?? 0,
273
+ });
274
+ }
275
+ createWordTransformState(word) {
276
+ return {
277
+ x: word.x ?? 0,
278
+ y: word.y ?? 0,
279
+ rotate: word.rotate ?? 0,
280
+ scale: 1,
281
+ };
282
+ }
283
+ createWordPositionTransform(state) {
284
+ return `translate(${state.x}, ${state.y}) rotate(${state.rotate})`;
285
+ }
286
+ createInitialScaleTransform(word) {
287
+ return this.createScaleTransform(this.createInitialWordTransformState(word).scale);
288
+ }
289
+ createScaleTransform(scale) {
290
+ return `scale(${scale})`;
291
+ }
292
+ createInitialWordOpacity(word) {
293
+ return this.previousWordSnapshot.has(word.text) ? 1 : 0;
294
+ }
295
+ animateWords(wordGroupSelection, wordSelection) {
296
+ const groupEntries = this.createWordGroupAnimationEntries(wordGroupSelection);
297
+ const textEntries = this.createWordTextAnimationEntries(wordSelection);
298
+ const startTime = this.now();
299
+ return new Promise((resolve) => {
300
+ const tick = (currentTime) => {
301
+ const progress = Math.min(1, (currentTime - startTime) / this.animation.duration);
302
+ const easedProgress = this.animation.easing(progress);
303
+ this.applyWordAnimationFrame(groupEntries, textEntries, easedProgress);
304
+ if (progress >= 1) {
305
+ resolve();
306
+ return;
307
+ }
308
+ this.requestAnimationFrame(tick);
309
+ };
310
+ this.requestAnimationFrame(tick);
311
+ });
312
+ }
313
+ createWordGroupAnimationEntries(wordGroupSelection) {
314
+ const entries = [];
315
+ wordGroupSelection.each((word, _index, nodes) => {
316
+ const node = nodes[_index];
317
+ entries.push({
318
+ node,
319
+ start: this.createInitialWordTransformState(word),
320
+ end: this.createWordTransformState(word),
321
+ });
322
+ });
323
+ return entries;
324
+ }
325
+ createWordTextAnimationEntries(wordSelection) {
326
+ const entries = [];
327
+ wordSelection.each((word, _index, nodes) => {
328
+ entries.push({
329
+ node: nodes[_index],
330
+ startScale: this.createInitialWordTransformState(word).scale,
331
+ startOpacity: this.createInitialWordOpacity(word),
332
+ });
333
+ });
334
+ return entries;
335
+ }
336
+ applyWordAnimationFrame(groupEntries, textEntries, progress) {
337
+ groupEntries.forEach((entry) => {
338
+ entry.node.setAttribute('transform', this.createWordPositionTransform({
339
+ x: entry.start.x + (entry.end.x - entry.start.x) * progress,
340
+ y: entry.start.y + (entry.end.y - entry.start.y) * progress,
341
+ rotate: entry.start.rotate +
342
+ (entry.end.rotate - entry.start.rotate) * progress,
343
+ }));
344
+ });
345
+ textEntries.forEach((entry) => {
346
+ entry.node.setAttribute('opacity', String(entry.startOpacity + (1 - entry.startOpacity) * progress));
347
+ entry.node.setAttribute('transform', this.createScaleTransform(entry.startScale + (1 - entry.startScale) * progress));
348
+ });
349
+ }
350
+ requestAnimationFrame(callback) {
351
+ if (typeof requestAnimationFrame === 'function') {
352
+ requestAnimationFrame(callback);
353
+ return;
354
+ }
355
+ setTimeout(() => {
356
+ callback(this.now());
357
+ }, 16);
358
+ }
359
+ now() {
360
+ return typeof performance === 'undefined'
361
+ ? Date.now()
362
+ : performance.now();
363
+ }
364
+ completeRender(words, transitions) {
365
+ this.previousWordSnapshot = this.createWordSnapshot(words);
366
+ this.hasRenderedLive = true;
367
+ this.nextRenderShouldAnimate = false;
368
+ if (transitions.length === 0) {
369
+ return Promise.resolve();
370
+ }
371
+ return Promise.allSettled(transitions).then(() => undefined);
372
+ }
373
+ createWordSnapshot(words) {
374
+ return new Map(words.map((word) => {
375
+ return [
376
+ word.text,
377
+ {
378
+ x: word.x ?? 0,
379
+ y: word.y ?? 0,
380
+ rotate: word.rotate ?? 0,
381
+ size: word.size,
382
+ },
383
+ ];
384
+ }));
188
385
  }
189
386
  stopLayout() {
190
387
  if (this.layout) {
191
388
  this.layout.stop();
192
389
  this.layout = null;
193
390
  }
391
+ this.plotGroup
392
+ ?.selectAll('.word-cloud-word-wrapper, .word-cloud-word')
393
+ .interrupt();
194
394
  this.resolvePendingReady?.();
195
395
  this.resolvePendingReady = null;
196
396
  }
@@ -1,8 +1,10 @@
1
1
  import { ChartValidationError, ChartValidator } from '../validation.js';
2
2
  import { easeBounceOut, easeCubicIn, easeCubicInOut, easeCubicOut, easeElasticOut, easeLinear, } from 'd3';
3
+ import { createCubicBezierEasing } from '../easing.js';
3
4
  const DEFAULT_ANIMATE = false;
4
5
  const DEFAULT_ANIMATION_DURATION_MS = 700;
5
6
  const DEFAULT_ANIMATION_EASING_PRESET = 'ease-in-out';
7
+ const easeSpringOut = createCubicBezierEasing(0.85, 0, 0.15, 1);
6
8
  const XY_ANIMATION_EASING_PRESETS = {
7
9
  linear: easeLinear,
8
10
  'ease-in': easeCubicIn,
@@ -10,6 +12,7 @@ const XY_ANIMATION_EASING_PRESETS = {
10
12
  'ease-in-out': easeCubicInOut,
11
13
  'bounce-out': easeBounceOut,
12
14
  'elastic-out': easeElasticOut,
15
+ 'spring-out': easeSpringOut,
13
16
  };
14
17
  export function normalizeXYAnimationConfig(config) {
15
18
  if (config === undefined) {
@@ -1,5 +1,5 @@
1
1
  import type { Selection } from 'd3';
2
- export type XYAnimationEasingPreset = 'linear' | 'ease-in' | 'ease-out' | 'ease-in-out' | 'bounce-out' | 'elastic-out';
2
+ export type XYAnimationEasingPreset = 'linear' | 'ease-in' | 'ease-out' | 'ease-in-out' | 'bounce-out' | 'elastic-out' | 'spring-out';
3
3
  export type XYAnimationConfig = {
4
4
  show?: boolean;
5
5
  duration?: number;
@@ -10,17 +10,18 @@ new DonutChart(config: DonutChartConfig)
10
10
 
11
11
  ### Config Options
12
12
 
13
- | Option | Type | Default | Description |
14
- | ------------ | ------------------------- | --------- | --------------------------------------------------------------------- |
15
- | `data` | `DataItem[]` | required | Array of data objects |
16
- | `width` | `number` | - | Explicit chart width in pixels |
17
- | `height` | `number` | - | Explicit chart height in pixels |
18
- | `valueKey` | `string` | `'value'` | Key for numeric values in data |
19
- | `labelKey` | `string` | `'name'` | Key for segment labels in data |
20
- | `donut` | `DonutConfig` | - | Donut-specific configuration |
21
- | `valueLabel` | `DonutValueLabelConfig` | - | On-chart outside label/value rendering configuration |
22
- | `theme` | `DeepPartial<ChartTheme>` | - | Theme customization |
23
- | `responsive` | `ResponsiveConfig` | - | Declarative container-query responsive overrides (theme + components) |
13
+ | Option | Type | Default | Description |
14
+ | ------------ | --------------------------------- | --------- | --------------------------------------------------------------------- |
15
+ | `data` | `DataItem[]` | required | Array of data objects |
16
+ | `width` | `number` | - | Explicit chart width in pixels |
17
+ | `height` | `number` | - | Explicit chart height in pixels |
18
+ | `valueKey` | `string` | `'value'` | Key for numeric values in data |
19
+ | `labelKey` | `string` | `'name'` | Key for segment labels in data |
20
+ | `donut` | `DonutConfig` | - | Donut-specific configuration |
21
+ | `valueLabel` | `DonutValueLabelConfig` | - | On-chart outside label/value rendering configuration |
22
+ | `animate` | `boolean \| DonutAnimationConfig` | `false` | Opt-in segment animation for initial render and `update()` |
23
+ | `theme` | `DeepPartial<ChartTheme>` | - | Theme customization |
24
+ | `responsive` | `ResponsiveConfig` | - | Declarative container-query responsive overrides (theme + components) |
24
25
 
25
26
  ### Donut Config
26
27
 
@@ -40,13 +41,50 @@ valueLabel: {
40
41
  position?: 'outside' | 'auto', // default: 'auto'
41
42
  outsideOffset?: number, // default: 16
42
43
  minVerticalSpacing?: number, // default: 14
43
- formatter?: (label, value, data, percentage) => string, // default: `${label}: ${value}`
44
+ maxLabelWidth?: number,
45
+ oversizedBehavior?: 'truncate' | 'wrap' | 'hide', // default: 'truncate'
46
+ forceVisible?: boolean, // default: false
47
+ labelFormatter?: (label, value, data, percentage) => string,
48
+ valueFormatter?: (label, value, data, percentage) => string,
49
+ separator?: string, // default: ': '
50
+ formatter?: (label, value, data, percentage) => string,
44
51
  }
45
52
  ```
46
53
 
47
54
  Donut value labels are rendered outside the ring with leader lines. `auto`
48
55
  currently resolves to the same outside placement as `outside`.
49
56
 
57
+ By default, donut value labels render as `{label}: {value}`. Use
58
+ `labelFormatter`, `valueFormatter`, and `separator` to customize those parts
59
+ while keeping the label and value structurally separate. When `maxLabelWidth` is
60
+ set, `oversizedBehavior` applies to the label part only, so long labels can be
61
+ truncated, wrapped, or hidden without truncating the value. The `percentage`
62
+ argument is the computed segment share from `0` to `100`.
63
+
64
+ Use `formatter` for full custom label text. When `formatter` is provided, the
65
+ returned string is treated as a single label and overflow behavior applies to
66
+ that whole string.
67
+
68
+ Set `forceVisible: true` to keep value labels rendered when
69
+ `oversizedBehavior: 'hide'` would normally hide them.
70
+
71
+ ### Animation Config
72
+
73
+ ```typescript
74
+ animate: boolean | {
75
+ show?: boolean, // default: true when object is provided
76
+ duration?: number, // default: 700
77
+ easing?: 'linear' | 'ease-in' | 'ease-out' | 'ease-in-out' |
78
+ 'bounce-out' | 'elastic-out' | 'spring-out' |
79
+ `linear(${string})` | ((progress: number) => number),
80
+ }
81
+ ```
82
+
83
+ Animation is off by default. When enabled, segments grow from zero length on the
84
+ first render and animate from their previous geometry on `chart.update(...)` and
85
+ legend visibility changes. Use `chart.whenReady()` when surrounding UI or tests
86
+ need to wait until the current animation has finished.
87
+
50
88
  ## Example
51
89
 
52
90
  ```javascript
@@ -73,8 +111,13 @@ const chart = new DonutChart({
73
111
  },
74
112
  valueLabel: {
75
113
  show: true,
76
- formatter: (label, _value, _data, percentage) =>
77
- `${label}: ${percentage.toFixed(1)}%`,
114
+ formatter: (label, _value, _data, percentage) => {
115
+ return `${label}: ${percentage.toFixed(1)}%`;
116
+ },
117
+ },
118
+ animate: {
119
+ duration: 700,
120
+ easing: 'ease-in-out',
78
121
  },
79
122
  });
80
123