@internetstiftelsen/charts 0.14.0 → 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/README.md CHANGED
@@ -362,6 +362,7 @@ const data = [
362
362
 
363
363
  const chart = new WordCloudChart({
364
364
  data,
365
+ animate: true,
365
366
  wordCloud: {
366
367
  minValue: 5,
367
368
  minWordLength: 3,
@@ -379,6 +380,8 @@ chart.render('#word-cloud');
379
380
  dimension and define the relative size range passed into `d3-cloud`. The chart
380
381
  expects flat `{ word, count }` rows, aggregates duplicate words after trimming,
381
382
  and maps theme typography and colors directly into the layout and rendered SVG.
383
+ Set `animate: true` or pass an animation config to fade and scale words from
384
+ their own centers on initial render and `chart.update(...)`.
382
385
 
383
386
  ## Export
384
387
 
@@ -16,6 +16,7 @@ type RenderDimensions = {
16
16
  svgWidthAttr: number | string;
17
17
  svgHeightAttr: number | string;
18
18
  };
19
+ type PlotAreaOverride = Partial<Pick<PlotAreaBounds, 'top' | 'right' | 'bottom' | 'left'>>;
19
20
  type ResponsiveOverrides = {
20
21
  theme?: DeepPartial<ChartTheme>;
21
22
  components: Map<ChartComponentBase, Record<string, unknown>>;
@@ -123,6 +124,7 @@ export declare abstract class BaseChart {
123
124
  private disconnectedLegendContainer;
124
125
  private renderThemeOverride;
125
126
  private renderSizeOverride;
127
+ private plotAreaOverride;
126
128
  private legendModeOverride;
127
129
  private readonly eventListeners;
128
130
  private renderId;
@@ -165,6 +167,10 @@ export declare abstract class BaseChart {
165
167
  width?: number;
166
168
  height?: number;
167
169
  };
170
+ /** @internal */
171
+ measurePlotArea(width: number, height: number): PlotAreaBounds;
172
+ /** @internal */
173
+ setPlotAreaOverride(override: PlotAreaOverride | null, rerender?: boolean): this;
168
174
  setLegendModeOverride(mode: LegendMode | null, rerender?: boolean): this;
169
175
  on<TEventName extends ChartEventName>(eventName: TEventName, listener: ChartEventListener<TEventName>): this;
170
176
  off<TEventName extends ChartEventName>(eventName: TEventName, listener: ChartEventListener<TEventName>): this;
@@ -202,6 +208,7 @@ export declare abstract class BaseChart {
202
208
  protected prepareForLegendChange(): void;
203
209
  protected initializeDataState(): void;
204
210
  protected prepareLayout(context: BaseLayoutContext): void;
211
+ private applyPlotAreaOverride;
205
212
  /**
206
213
  * Setup ResizeObserver for automatic resize handling
207
214
  */
@@ -219,6 +219,12 @@ export class BaseChart {
219
219
  writable: true,
220
220
  value: null
221
221
  });
222
+ Object.defineProperty(this, "plotAreaOverride", {
223
+ enumerable: true,
224
+ configurable: true,
225
+ writable: true,
226
+ value: null
227
+ });
222
228
  Object.defineProperty(this, "legendModeOverride", {
223
229
  enumerable: true,
224
230
  configurable: true,
@@ -361,7 +367,7 @@ export class BaseChart {
361
367
  // Calculate layout
362
368
  this.layoutManager = new LayoutManager(this.resolvedRenderTheme);
363
369
  const components = this.getLayoutComponents();
364
- const plotArea = this.layoutManager.calculateLayout(components);
370
+ const plotArea = this.applyPlotAreaOverride(this.layoutManager.calculateLayout(components));
365
371
  this.plotArea = plotArea;
366
372
  // Create plot group
367
373
  const plotGroup = this.svg.append('g').attr('class', 'chart-plot');
@@ -517,6 +523,59 @@ export class BaseChart {
517
523
  height: this.configuredHeight,
518
524
  };
519
525
  }
526
+ /** @internal */
527
+ measurePlotArea(width, height) {
528
+ const previousWidth = this.width;
529
+ const previousHeight = this.height;
530
+ const measurementSvg = create('svg')
531
+ .attr('width', width)
532
+ .attr('height', height)
533
+ .style('position', 'absolute')
534
+ .style('left', '-9999px')
535
+ .style('top', '-9999px')
536
+ .style('overflow', 'hidden')
537
+ .style('pointer-events', 'none');
538
+ const svgNode = measurementSvg.node();
539
+ if (!svgNode) {
540
+ throw new Error('Failed to initialize chart measurement SVG');
541
+ }
542
+ document.body.appendChild(svgNode);
543
+ this.width = width;
544
+ this.height = height;
545
+ const responsiveContext = this.resolveResponsiveContext({
546
+ width,
547
+ height,
548
+ });
549
+ const responsiveOverrides = this.collectResponsiveOverrides(responsiveContext);
550
+ const mergedComponentOverrides = this.mergeComponentOverrideMaps(responsiveOverrides.components);
551
+ const renderTheme = this.resolveRenderTheme(responsiveOverrides);
552
+ const overrideComponents = this.createOverrideComponents(mergedComponentOverrides);
553
+ const restoreComponents = this.applyComponentOverrides(overrideComponents);
554
+ const restoreTheme = this.applyRenderTheme(renderTheme);
555
+ try {
556
+ this.prepareLayout({
557
+ svg: measurementSvg,
558
+ svgNode,
559
+ });
560
+ const layoutManager = new LayoutManager(this.resolvedRenderTheme);
561
+ return this.applyPlotAreaOverride(layoutManager.calculateLayout(this.getLayoutComponents()));
562
+ }
563
+ finally {
564
+ restoreComponents();
565
+ restoreTheme();
566
+ this.width = previousWidth;
567
+ this.height = previousHeight;
568
+ svgNode.remove();
569
+ }
570
+ }
571
+ /** @internal */
572
+ setPlotAreaOverride(override, rerender = true) {
573
+ this.plotAreaOverride = override;
574
+ if (rerender) {
575
+ this.rerender('component');
576
+ }
577
+ return this;
578
+ }
520
579
  setLegendModeOverride(mode, rerender = true) {
521
580
  if (this.legendModeOverride === mode) {
522
581
  return this;
@@ -854,6 +913,23 @@ export class BaseChart {
854
913
  prepareLayout(context) {
855
914
  this.measureInlineLegend(context.svgNode);
856
915
  }
916
+ applyPlotAreaOverride(plotArea) {
917
+ if (!this.plotAreaOverride) {
918
+ return plotArea;
919
+ }
920
+ const left = this.plotAreaOverride.left ?? plotArea.left;
921
+ const right = this.plotAreaOverride.right ?? plotArea.right;
922
+ const top = this.plotAreaOverride.top ?? plotArea.top;
923
+ const bottom = this.plotAreaOverride.bottom ?? plotArea.bottom;
924
+ return {
925
+ left,
926
+ right,
927
+ top,
928
+ bottom,
929
+ width: Math.max(0, right - left),
930
+ height: Math.max(0, bottom - top),
931
+ };
932
+ }
857
933
  /**
858
934
  * Setup ResizeObserver for automatic resize handling
859
935
  */
@@ -96,6 +96,7 @@ export declare class ChartGroup {
96
96
  private resolveSharedYDomain;
97
97
  private warnIncompatibleYScaleTypes;
98
98
  private applyScaleSyncOverrides;
99
+ private applyPlotAreaSyncOverrides;
99
100
  private buildRows;
100
101
  private resolveDefaultChartHeight;
101
102
  private resolveDefaultChartHeightForWidth;
@@ -291,7 +291,9 @@ export class ChartGroup {
291
291
  const { width, renderedTopText, renderedBottomText, renderedLegend, layout, totalHeight, } = this.prepareRenderState(container);
292
292
  this.isRendering = true;
293
293
  try {
294
- this.applyScaleSyncOverrides(width);
294
+ const sharedYDomain = this.resolveSharedYDomain(width);
295
+ this.applyScaleSyncOverrides(width, sharedYDomain);
296
+ this.applyPlotAreaSyncOverrides(layout.items, sharedYDomain);
295
297
  container.innerHTML = '';
296
298
  const { root, chartLayer } = this.createRenderHosts(totalHeight, layout.chartHeight);
297
299
  this.appendRenderedTextSections(root, renderedTopText);
@@ -376,6 +378,7 @@ export class ChartGroup {
376
378
  chart.setLegendModeOverride(null, false);
377
379
  if (chart instanceof XYChart) {
378
380
  chart.setScaleConfigOverride(null, false);
381
+ chart.setPlotAreaOverride(null, false);
379
382
  }
380
383
  chart.destroy();
381
384
  });
@@ -508,9 +511,8 @@ export class ChartGroup {
508
511
  this.hasWarnedIncompatibleYScaleTypes = true;
509
512
  ChartValidator.warn('ChartGroup: syncY requires all synced XY child charts to use the same vertical numeric scale type');
510
513
  }
511
- applyScaleSyncOverrides(width) {
514
+ applyScaleSyncOverrides(width, sharedDomain) {
512
515
  const syncedCharts = new Set(this.getVerticalXYCharts(width));
513
- const sharedDomain = this.resolveSharedYDomain(width);
514
516
  this.getAllVerticalXYCharts().forEach((chart) => {
515
517
  if (!sharedDomain || !syncedCharts.has(chart)) {
516
518
  chart.setScaleConfigOverride(null, false);
@@ -524,6 +526,43 @@ export class ChartGroup {
524
526
  }, false);
525
527
  });
526
528
  }
529
+ applyPlotAreaSyncOverrides(items, sharedDomain) {
530
+ this.getAllVerticalXYCharts().forEach((chart) => {
531
+ chart.setPlotAreaOverride(null, false);
532
+ });
533
+ if (!sharedDomain) {
534
+ return;
535
+ }
536
+ const rowItems = new Map();
537
+ items.forEach((item) => {
538
+ if (!(item.chart instanceof XYChart) ||
539
+ item.chart.getOrientation() !== 'vertical') {
540
+ return;
541
+ }
542
+ const row = rowItems.get(item.y) ?? [];
543
+ row.push(item);
544
+ rowItems.set(item.y, row);
545
+ });
546
+ rowItems.forEach((row) => {
547
+ if (row.length < 2) {
548
+ return;
549
+ }
550
+ const measuredPlotAreas = row.map((item) => {
551
+ return {
552
+ chart: item.chart,
553
+ plotArea: item.chart.measurePlotArea(item.width, item.height),
554
+ };
555
+ });
556
+ const top = Math.max(...measuredPlotAreas.map(({ plotArea }) => plotArea.top));
557
+ const bottom = Math.min(...measuredPlotAreas.map(({ plotArea }) => plotArea.bottom));
558
+ if (bottom <= top) {
559
+ return;
560
+ }
561
+ measuredPlotAreas.forEach(({ chart }) => {
562
+ chart.setPlotAreaOverride({ top, bottom }, false);
563
+ });
564
+ });
565
+ }
527
566
  buildRows(entries, width, cols, gap) {
528
567
  const availableWidth = Math.max(1, width - gap * Math.max(0, cols - 1));
529
568
  const columnWidth = availableWidth / cols;
@@ -802,10 +841,12 @@ export class ChartGroup {
802
841
  this.resizeObserver.observe(this.container);
803
842
  }
804
843
  async exportSVG(format, width, options) {
805
- this.applyScaleSyncOverrides(width);
844
+ const sharedYDomain = this.resolveSharedYDomain(width);
845
+ this.applyScaleSyncOverrides(width, sharedYDomain);
806
846
  const baseContext = this.createExportRenderContext(format, width, options);
807
847
  const exportLayoutState = this.resolveExportLayoutState(width, baseContext);
808
848
  const layout = this.calculateLayout(width, exportLayoutState.chartAreaHeight, exportLayoutState.defaultChartHeightOverride);
849
+ this.applyPlotAreaSyncOverrides(layout.items, sharedYDomain);
809
850
  const childSvgs = await this.exportLayoutItems(layout.items, options);
810
851
  const totalHeight = exportLayoutState.topTextHeight +
811
852
  layout.chartHeight +
@@ -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
  }
@@ -338,6 +338,7 @@ const data = [
338
338
 
339
339
  const chart = new WordCloudChart({
340
340
  data,
341
+ animate: true,
341
342
  wordCloud: {
342
343
  minWordLength: 3,
343
344
  minValue: 5,
@@ -357,6 +358,8 @@ chart.render('#word-cloud-container');
357
358
  dimension and define the relative size range passed into `d3-cloud`. Word
358
359
  clouds accept flat `{ word, count }` rows and use theme typography/colors
359
360
  directly when laying out and rendering the cloud.
361
+ Set `animate: true` or pass an animation config to fade and scale words from
362
+ their own centers on initial render and `chart.update(...)`.
360
363
 
361
364
  ## Next Steps
362
365
 
@@ -17,14 +17,15 @@ new WordCloudChart(config: WordCloudChartConfig)
17
17
 
18
18
  ## Config Options
19
19
 
20
- | Option | Type | Default | Description |
21
- | ------------ | ------------------------- | -------- | ------------------------------------------------ |
22
- | `data` | `DataItem[]` | required | Flat `{ word, count }` rows |
23
- | `width` | `number` | - | Explicit chart width in pixels |
24
- | `height` | `number` | - | Explicit chart height in pixels |
25
- | `theme` | `DeepPartial<ChartTheme>` | - | Theme customization |
26
- | `responsive` | `ResponsiveConfig` | - | Declarative container-query responsive overrides |
27
- | `wordCloud` | `WordCloudConfig` | - | Layout and filtering options |
20
+ | Option | Type | Default | Description |
21
+ | ------------ | -------------------------------------- | -------- | ------------------------------------------------------- |
22
+ | `data` | `DataItem[]` | required | Flat `{ word, count }` rows |
23
+ | `width` | `number` | - | Explicit chart width in pixels |
24
+ | `height` | `number` | - | Explicit chart height in pixels |
25
+ | `theme` | `DeepPartial<ChartTheme>` | - | Theme customization |
26
+ | `responsive` | `ResponsiveConfig` | - | Declarative container-query responsive overrides |
27
+ | `wordCloud` | `WordCloudConfig` | - | Layout and filtering options |
28
+ | `animate` | `boolean \| WordCloudAnimationConfig` | `false` | Opt-in word animation for initial render and `update()` |
28
29
 
29
30
  ### `wordCloud`
30
31
 
@@ -41,6 +42,36 @@ wordCloud: {
41
42
  }
42
43
  ```
43
44
 
45
+ ### Animation
46
+
47
+ ```typescript
48
+ animate: true
49
+ ```
50
+
51
+ or:
52
+
53
+ ```typescript
54
+ animate: {
55
+ duration?: number; // default: 700
56
+ easing?:
57
+ | 'linear'
58
+ | 'ease-in'
59
+ | 'ease-out'
60
+ | 'ease-in-out'
61
+ | 'bounce-out'
62
+ | 'elastic-out'
63
+ | 'spring-out'
64
+ | `linear(${string})`
65
+ | ((progress: number) => number);
66
+ }
67
+ ```
68
+
69
+ Animation is off by default. When enabled, words fade and scale from their own
70
+ centers on the first render. On `chart.update(...)`, words that existed in the
71
+ previous layout animate from their previous positions and scale from their own
72
+ centers when their size changes. Use `chart.whenReady()` when surrounding UI or
73
+ tests need to wait until layout and animation have finished.
74
+
44
75
  ## Example
45
76
 
46
77
  ```typescript
@@ -56,6 +87,10 @@ const data = [
56
87
 
57
88
  const chart = new WordCloudChart({
58
89
  data,
90
+ animate: {
91
+ duration: 700,
92
+ easing: 'ease-in-out',
93
+ },
59
94
  wordCloud: {
60
95
  minWordLength: 3,
61
96
  minValue: 10,
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.14.0",
2
+ "version": "0.14.1",
3
3
  "name": "@internetstiftelsen/charts",
4
4
  "type": "module",
5
5
  "sideEffects": false,