@internetstiftelsen/charts 0.19.2 → 0.20.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.
package/README.md CHANGED
@@ -396,9 +396,14 @@ chart.render('#word-cloud');
396
396
  ```
397
397
 
398
398
  `minFontSize` and `maxFontSize` are percentages of the smaller plot-area
399
- dimension and define the relative size range passed into `d3-cloud`. The chart
399
+ dimension and define the initial size range passed into `d3-cloud`. The chart
400
400
  expects flat `{ word, count }` rows, aggregates duplicate words after trimming,
401
401
  and maps theme typography and colors directly into the layout and rendered SVG.
402
+ Long phrases are fitted to the available area, with at most six placement
403
+ attempts. Container resizing is debounced by 150 ms; initial rendering and data
404
+ updates remain immediate. If space remains insufficient, the chart retains the highest-frequency
405
+ words without shrinking below the configured minimum. See the
406
+ [WordCloudChart API](./docs/word-cloud-chart.md#notes) for fitting behavior.
402
407
  Set `animate: true` or pass an animation config to fade and scale words from
403
408
  their own centers on initial render and `chart.update(...)`.
404
409
 
package/dist/area.d.ts CHANGED
@@ -3,7 +3,7 @@ import type { AreaConfig, AreaCurveType, AreaConfigBase, AreaStackingContext, Ch
3
3
  import type { ChartComponent } from './chart-interface.js';
4
4
  import type { XYAreaAnimationContext, XYAreaPointSnapshot, XYSeriesRenderResult } from './xy-motion/types.js';
5
5
  export declare class Area implements ChartComponent<AreaConfigBase> {
6
- readonly type: "area";
6
+ readonly type: 'area';
7
7
  readonly dataKey: string;
8
8
  readonly fill: string;
9
9
  readonly stroke: string;
package/dist/bar.d.ts CHANGED
@@ -3,7 +3,7 @@ import type { BarConfig, BarStackingContext, BarSide, BarValueLabelConfig, Chart
3
3
  import type { ChartComponent } from './chart-interface.js';
4
4
  import type { XYBarAnimationContext, XYBarSnapshot, XYSeriesRenderResult } from './xy-motion/types.js';
5
5
  export declare class Bar implements ChartComponent<BarConfigBase> {
6
- readonly type: "bar";
6
+ readonly type: 'bar';
7
7
  readonly dataKey: string;
8
8
  readonly fill: string;
9
9
  readonly colorAdapter?: (data: DataItem, index: number) => string;
@@ -213,6 +213,8 @@ export declare abstract class BaseChart {
213
213
  * Setup ResizeObserver for automatic resize handling
214
214
  */
215
215
  private setupResizeObserver;
216
+ protected hasContainerSizeChanged(): boolean;
217
+ protected handleResize(): void;
216
218
  private notifyLegendChanged;
217
219
  /**
218
220
  * Subclasses must implement this method to define their rendering logic
@@ -939,19 +939,22 @@ export class BaseChart {
939
939
  if (this.resizeObserver) {
940
940
  this.resizeObserver.disconnect();
941
941
  }
942
- this.resizeObserver = new ResizeObserver(() => {
943
- if (!this.container) {
944
- return;
945
- }
946
- const nextDimensions = this.resolveRenderDimensions(this.container.getBoundingClientRect());
947
- if (nextDimensions.width === this.width &&
948
- nextDimensions.height === this.height) {
949
- return;
950
- }
951
- this.rerender('resize');
952
- });
942
+ this.resizeObserver = new ResizeObserver(() => this.handleResize());
953
943
  this.resizeObserver.observe(this.container);
954
944
  }
945
+ hasContainerSizeChanged() {
946
+ if (!this.container) {
947
+ return false;
948
+ }
949
+ const nextDimensions = this.resolveRenderDimensions(this.container.getBoundingClientRect());
950
+ return (nextDimensions.width !== this.width ||
951
+ nextDimensions.height !== this.height);
952
+ }
953
+ handleResize() {
954
+ if (this.hasContainerSizeChanged()) {
955
+ this.rerender('resize');
956
+ }
957
+ }
955
958
  notifyLegendChanged() {
956
959
  this.emit('legend:change');
957
960
  }
@@ -1310,6 +1313,7 @@ export class BaseChart {
1310
1313
  return exportXLSXBlob(this.sourceData, options);
1311
1314
  }
1312
1315
  async exportImage(format, options) {
1316
+ await this.whenReady();
1313
1317
  const { width, height } = this.exportSize(options);
1314
1318
  const svg = await this.exportSVG(options, format);
1315
1319
  const backgroundColor = options?.backgroundColor ??
@@ -1325,8 +1329,8 @@ export class BaseChart {
1325
1329
  });
1326
1330
  }
1327
1331
  async exportSVG(options, formatForHooks = 'svg') {
1328
- const liveSvg = this.requireRenderedSvg();
1329
1332
  await this.whenReady();
1333
+ const liveSvg = this.requireRenderedSvg();
1330
1334
  const { exportWidth, exportHeight, requiresExportRender, baseContext } = this.resolveExportContext(options, formatForHooks);
1331
1335
  const clone = this.createExportSvgClone(liveSvg, exportWidth, exportHeight);
1332
1336
  const overrides = this.collectExportOverrides(baseContext);
@@ -19,7 +19,7 @@ export type DonutCenterContentConfig = DonutCenterContentConfigBase & {
19
19
  exportHooks?: ExportHooks<DonutCenterContentConfigBase>;
20
20
  };
21
21
  export declare class DonutCenterContent implements ChartComponent<DonutCenterContentConfigBase> {
22
- readonly type: "donutCenterContent";
22
+ readonly type: 'donutCenterContent';
23
23
  readonly mainValue?: string;
24
24
  readonly title?: string;
25
25
  readonly subtitle?: string;
package/dist/grid.d.ts CHANGED
@@ -2,7 +2,7 @@ import { type Selection } from 'd3';
2
2
  import type { GridConfig, ChartTheme, D3Scale, ExportHooks, GridConfigBase } from './types.js';
3
3
  import type { ChartComponent } from './chart-interface.js';
4
4
  export declare class Grid implements ChartComponent<GridConfigBase> {
5
- readonly type: "grid";
5
+ readonly type: 'grid';
6
6
  readonly value: boolean;
7
7
  readonly category: boolean;
8
8
  readonly exportHooks?: ExportHooks<GridConfigBase>;
package/dist/legend.d.ts CHANGED
@@ -3,7 +3,7 @@ import type { LegendConfig, ChartTheme, HorizontalAlignment, LegendSeries, Expor
3
3
  import type { LayoutAwareComponent, ComponentSpace } from './chart-interface.js';
4
4
  import { LegendStateController } from './legend-state.js';
5
5
  export declare class Legend implements LayoutAwareComponent<LegendConfigBase> {
6
- readonly type: "legend";
6
+ readonly type: 'legend';
7
7
  mode: LegendMode;
8
8
  readonly position: LegendConfig['position'];
9
9
  readonly disconnectedTarget?: string | HTMLElement;
package/dist/line.d.ts CHANGED
@@ -3,7 +3,7 @@ import type { LineConfig, DataItem, D3Scale, ScaleType, ChartTheme, LineValueLab
3
3
  import type { ChartComponent } from './chart-interface.js';
4
4
  import type { XYPointAnimationContext, XYPointSnapshot, XYSeriesRenderResult } from './xy-motion/types.js';
5
5
  export declare class Line implements ChartComponent<LineConfigBase> {
6
- readonly type: "line";
6
+ readonly type: 'line';
7
7
  readonly dataKey: string;
8
8
  readonly stroke: string;
9
9
  readonly strokeWidth?: number;
package/dist/scatter.d.ts CHANGED
@@ -3,7 +3,7 @@ import type { ChartComponent } from './chart-interface.js';
3
3
  import type { Selection } from 'd3';
4
4
  import type { XYPointAnimationContext, XYPointSnapshot, XYSeriesRenderResult } from './xy-motion/types.js';
5
5
  export declare class Scatter implements ChartComponent<ScatterConfigBase> {
6
- readonly type: "scatter";
6
+ readonly type: 'scatter';
7
7
  readonly dataKey: string;
8
8
  readonly stroke: string;
9
9
  readonly pointSize?: number;
package/dist/title.d.ts CHANGED
@@ -2,10 +2,10 @@ import { type Selection } from 'd3';
2
2
  import type { ChartTheme, ExportHooks, TitleConfig, TitleConfigBase } from './types.js';
3
3
  import type { ComponentSpace, LayoutAwareComponent } from './chart-interface.js';
4
4
  export declare class Title implements LayoutAwareComponent<TitleConfigBase> {
5
- readonly type: "title";
5
+ readonly type: 'title';
6
6
  readonly display: boolean;
7
7
  readonly text: string;
8
- readonly position: "top";
8
+ readonly position: 'top';
9
9
  readonly variant = "title";
10
10
  readonly exportHooks?: ExportHooks<TitleConfigBase>;
11
11
  private readonly textComponent;
@@ -725,7 +725,7 @@ function getHorizontalAboveBelowPlacementCost(candidate, layout, preferredEdge,
725
725
  return (connectorPenalty +
726
726
  edgePenalty +
727
727
  xDistance +
728
- yDistance * 0.5 +
728
+ yDistance * 4 +
729
729
  overflowPenalty);
730
730
  }
731
731
  function getPreferredAndOppositeEdges(preferredEdge) {
@@ -757,8 +757,9 @@ function getTooltipPlacementBounds(svgNode) {
757
757
  let bounds = getSplitTooltipViewportBounds();
758
758
  let currentElement = svgNode.parentElement;
759
759
  while (currentElement) {
760
- if (isScrollableElement(currentElement)) {
761
- bounds = intersectTooltipBounds(bounds, getElementTooltipBounds(currentElement));
760
+ const scrollAxes = getScrollableElementAxes(currentElement);
761
+ if (scrollAxes.horizontal || scrollAxes.vertical) {
762
+ bounds = intersectTooltipBounds(bounds, getElementTooltipBounds(currentElement), scrollAxes);
762
763
  }
763
764
  currentElement = currentElement.parentElement;
764
765
  }
@@ -775,15 +776,26 @@ function getElementTooltipBounds(element) {
775
776
  maxBottom: top + rect.height,
776
777
  };
777
778
  }
778
- function intersectTooltipBounds(bounds, nextBounds) {
779
+ function intersectTooltipBounds(bounds, nextBounds, axes = {
780
+ horizontal: true,
781
+ vertical: true,
782
+ }) {
779
783
  if (nextBounds.maxRight <= nextBounds.minLeft ||
780
784
  nextBounds.maxBottom <= nextBounds.minTop) {
781
785
  return bounds;
782
786
  }
783
- const minLeft = Math.max(bounds.minLeft, nextBounds.minLeft);
784
- const maxRight = Math.min(bounds.maxRight, nextBounds.maxRight);
785
- const minTop = Math.max(bounds.minTop, nextBounds.minTop);
786
- const maxBottom = Math.min(bounds.maxBottom, nextBounds.maxBottom);
787
+ const minLeft = axes.horizontal
788
+ ? Math.max(bounds.minLeft, nextBounds.minLeft)
789
+ : bounds.minLeft;
790
+ const maxRight = axes.horizontal
791
+ ? Math.min(bounds.maxRight, nextBounds.maxRight)
792
+ : bounds.maxRight;
793
+ const minTop = axes.vertical
794
+ ? Math.max(bounds.minTop, nextBounds.minTop)
795
+ : bounds.minTop;
796
+ const maxBottom = axes.vertical
797
+ ? Math.min(bounds.maxBottom, nextBounds.maxBottom)
798
+ : bounds.maxBottom;
787
799
  if (maxRight <= minLeft || maxBottom <= minTop) {
788
800
  return bounds;
789
801
  }
@@ -820,6 +832,26 @@ function getTooltipScrollTargets(svgNode) {
820
832
  return [...scrollTargets];
821
833
  }
822
834
  function isScrollableElement(element) {
835
+ const scrollAxes = getScrollableElementAxes(element);
836
+ return scrollAxes.horizontal || scrollAxes.vertical;
837
+ }
838
+ function getScrollableElementAxes(element) {
823
839
  const style = window.getComputedStyle(element);
824
- return [style.overflow, style.overflowX, style.overflowY].some((value) => value === 'auto' || value === 'scroll' || value === 'overlay');
840
+ return {
841
+ horizontal: isScrollableOverflowValue(style.overflowX) &&
842
+ hasScrollableOverflow(element, 'horizontal'),
843
+ vertical: isScrollableOverflowValue(style.overflowY) &&
844
+ hasScrollableOverflow(element, 'vertical'),
845
+ };
846
+ }
847
+ function isScrollableOverflowValue(value) {
848
+ return value === 'auto' || value === 'scroll' || value === 'overlay';
849
+ }
850
+ function hasScrollableOverflow(element, axis) {
851
+ const scrollSize = axis === 'horizontal' ? element.scrollWidth : element.scrollHeight;
852
+ const clientSize = axis === 'horizontal' ? element.clientWidth : element.clientHeight;
853
+ if (scrollSize === 0 && clientSize === 0) {
854
+ return true;
855
+ }
856
+ return scrollSize > clientSize;
825
857
  }
package/dist/tooltip.d.ts CHANGED
@@ -6,7 +6,7 @@ import { type XYTooltipSeries } from './tooltip/types.js';
6
6
  export declare class Tooltip implements ChartComponent<TooltipConfigBase> {
7
7
  private static nextTooltipId;
8
8
  readonly id: string;
9
- readonly type: "tooltip";
9
+ readonly type: 'tooltip';
10
10
  readonly mode: TooltipMode;
11
11
  readonly position: TooltipPosition;
12
12
  readonly barAnchorPosition: TooltipBarAnchorPosition;
@@ -31,13 +31,21 @@ export declare class WordCloudChart extends BaseChart {
31
31
  private hasRenderedLive;
32
32
  private nextRenderShouldAnimate;
33
33
  private previousWordSnapshot;
34
+ private resizeTimeout;
35
+ private pendingResize;
36
+ private resolvePendingResize;
34
37
  constructor(config: WordCloudChartConfig);
35
38
  update(data: ChartData): void;
36
39
  destroy(): void;
40
+ whenReady(): Promise<void>;
41
+ protected handleResize(): void;
42
+ private cancelPendingResize;
37
43
  protected validateSourceData(data: ChartData): void;
38
44
  protected renderChart({ svg, plotArea }: BaseRenderContext): void;
39
45
  protected createExportChart(): BaseChart;
40
46
  private prepareAnimationForUpdate;
47
+ private minimumFontSize;
48
+ private fitWordSizes;
41
49
  private startLayout;
42
50
  private renderWords;
43
51
  private shouldAnimateWords;
@@ -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 { measureTextWidth } from './utils.js';
5
6
  import { normalizeRadialAnimationConfig, } from './radial-animation.js';
6
7
  const DEFAULT_OPTIONS = {
7
8
  maxWords: 75,
@@ -14,6 +15,10 @@ const DEFAULT_OPTIONS = {
14
15
  spiral: 'archimedean',
15
16
  };
16
17
  const INITIAL_WORD_SCALE = 0.2;
18
+ const MAX_LAYOUT_ATTEMPTS = 6;
19
+ const LAYOUT_TIME_SLICE_MS = 8;
20
+ const FONT_SIZE_REDUCTION = 0.75;
21
+ const RESIZE_DEBOUNCE_MS = 150;
17
22
  const GROUPED_DATA_ERROR = 'WordCloudChart: grouped datasets are not supported; provide a flat array of rows instead';
18
23
  function createPreparedWords(data, plotArea, options, colors) {
19
24
  const counts = new Map();
@@ -54,6 +59,18 @@ function createPreparedWords(data, plotArea, options, colors) {
54
59
  };
55
60
  });
56
61
  }
62
+ function getPlacedPrefix(words, placedWords) {
63
+ const placedByText = new Map(placedWords.map((word) => [word.text, word]));
64
+ const prefix = [];
65
+ // A missing common word must never be replaced by rarer words.
66
+ for (const word of words) {
67
+ const placed = placedByText.get(word.text);
68
+ if (!placed)
69
+ break;
70
+ prefix.push(placed);
71
+ }
72
+ return prefix;
73
+ }
57
74
  export class WordCloudChart extends BaseChart {
58
75
  constructor(config) {
59
76
  super(config);
@@ -105,6 +122,24 @@ export class WordCloudChart extends BaseChart {
105
122
  writable: true,
106
123
  value: new Map()
107
124
  });
125
+ Object.defineProperty(this, "resizeTimeout", {
126
+ enumerable: true,
127
+ configurable: true,
128
+ writable: true,
129
+ value: null
130
+ });
131
+ Object.defineProperty(this, "pendingResize", {
132
+ enumerable: true,
133
+ configurable: true,
134
+ writable: true,
135
+ value: null
136
+ });
137
+ Object.defineProperty(this, "resolvePendingResize", {
138
+ enumerable: true,
139
+ configurable: true,
140
+ writable: true,
141
+ value: null
142
+ });
108
143
  const wordCloud = config.wordCloud ?? {};
109
144
  this.options = {
110
145
  maxWords: wordCloud.maxWords ?? DEFAULT_OPTIONS.maxWords,
@@ -127,20 +162,62 @@ export class WordCloudChart extends BaseChart {
127
162
  super.update(data);
128
163
  }
129
164
  destroy() {
165
+ this.cancelPendingResize();
130
166
  this.layoutRunId += 1;
131
167
  this.stopLayout();
132
168
  this.setReadyPromise(Promise.resolve());
133
169
  super.destroy();
134
170
  }
171
+ whenReady() {
172
+ if (this.pendingResize) {
173
+ return this.pendingResize.then(() => super.whenReady());
174
+ }
175
+ return super.whenReady();
176
+ }
177
+ handleResize() {
178
+ if (!this.hasContainerSizeChanged()) {
179
+ this.cancelPendingResize();
180
+ return;
181
+ }
182
+ if (this.resizeTimeout !== null) {
183
+ clearTimeout(this.resizeTimeout);
184
+ }
185
+ // Keep the same promise throughout a burst of resize notifications.
186
+ if (!this.pendingResize) {
187
+ this.pendingResize = new Promise((resolve) => {
188
+ this.resolvePendingResize = resolve;
189
+ });
190
+ }
191
+ this.resizeTimeout = setTimeout(() => {
192
+ this.resizeTimeout = null;
193
+ try {
194
+ super.handleResize();
195
+ }
196
+ finally {
197
+ this.cancelPendingResize();
198
+ }
199
+ }, RESIZE_DEBOUNCE_MS);
200
+ }
201
+ cancelPendingResize() {
202
+ if (this.resizeTimeout !== null) {
203
+ clearTimeout(this.resizeTimeout);
204
+ this.resizeTimeout = null;
205
+ }
206
+ this.resolvePendingResize?.();
207
+ this.resolvePendingResize = null;
208
+ this.pendingResize = null;
209
+ }
135
210
  validateSourceData(data) {
136
211
  if (isGroupedData(data)) {
137
212
  throw new Error(GROUPED_DATA_ERROR);
138
213
  }
139
214
  }
140
215
  renderChart({ svg, plotArea }) {
216
+ this.cancelPendingResize();
141
217
  this.stopLayout();
142
218
  this.renderTitle(svg);
143
- const words = createPreparedWords(this.data, plotArea, this.options, this.renderTheme.colorPalette);
219
+ const preparedWords = createPreparedWords(this.data, plotArea, this.options, this.renderTheme.colorPalette);
220
+ const words = this.fitWordSizes(preparedWords, plotArea, svg.node());
144
221
  this.setReadyPromise(new Promise((resolve) => {
145
222
  this.resolvePendingReady = resolve;
146
223
  this.startLayout(words, plotArea, ++this.layoutRunId, resolve);
@@ -163,9 +240,46 @@ export class WordCloudChart extends BaseChart {
163
240
  this.animation.duration > 0 &&
164
241
  this.hasRenderedLive;
165
242
  }
166
- startLayout(words, plotArea, runId, resolve) {
243
+ minimumFontSize(plotArea) {
244
+ return Math.max(1, Math.ceil((Math.min(plotArea.width, plotArea.height) *
245
+ this.options.minFontSize) /
246
+ 100));
247
+ }
248
+ fitWordSizes(words, plotArea, svg) {
249
+ const minimumSize = this.minimumFontSize(plotArea);
250
+ let scale = 1;
251
+ words.forEach((word, index) => {
252
+ // d3-cloud measures at size + 1 and rounds sprite widths to 32px.
253
+ const width = measureTextWidth(word.text, word.size + 1, this.renderTheme.fontFamily, String(this.renderTheme.valueLabel.fontWeight), svg) + 1;
254
+ const height = (word.size + 1) * 2;
255
+ let rotatedWidth = width;
256
+ let rotatedHeight = height;
257
+ if (this.options.rotation === 'right-angle' && index % 2 === 1) {
258
+ rotatedWidth = height;
259
+ rotatedHeight = width;
260
+ }
261
+ else if (this.options.rotation === undefined) {
262
+ // Leave room for any angle selected by d3-cloud.
263
+ rotatedWidth = rotatedHeight = Math.hypot(width, height);
264
+ }
265
+ const padding = this.options.padding * 2;
266
+ const fit = Math.min(1, Math.max(1, plotArea.width - 32 - padding) / rotatedWidth, Math.max(1, plotArea.height - padding) / rotatedHeight);
267
+ const fittedSize = (word.size + 1) * fit - 1;
268
+ if (word.size > minimumSize) {
269
+ scale = Math.min(scale, Math.max(0, (fittedSize - minimumSize) / (word.size - minimumSize)));
270
+ }
271
+ });
272
+ // Compress the range uniformly to preserve frequency order and the
273
+ // configured minimum size, including for long, high-frequency phrases.
274
+ return words.map((word) => ({
275
+ ...word,
276
+ size: Math.max(minimumSize, minimumSize + (word.size - minimumSize) * scale),
277
+ }));
278
+ }
279
+ startLayout(words, plotArea, runId, resolve, attempt = 1, bestWords = []) {
167
280
  const layout = cloud()
168
281
  .words(words.map((word) => ({ ...word })))
282
+ .timeInterval(LAYOUT_TIME_SLICE_MS)
169
283
  .size([
170
284
  Math.max(1, Math.floor(plotArea.width)),
171
285
  Math.max(1, Math.floor(plotArea.height)),
@@ -177,18 +291,39 @@ export class WordCloudChart extends BaseChart {
177
291
  .fontSize((word) => word.size)
178
292
  .text((word) => word.text)
179
293
  .on('end', (placedWords) => {
180
- this.layout = null;
181
294
  if (runId !== this.layoutRunId ||
182
295
  !this.plotGroup ||
183
296
  !this.plotArea) {
184
297
  this.finishReady(resolve);
185
298
  return;
186
299
  }
187
- if (placedWords.length < words.length) {
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`);
300
+ this.layout = null;
301
+ const prioritizedWords = getPlacedPrefix(words, placedWords);
302
+ if (prioritizedWords.length > bestWords.length) {
303
+ bestWords = prioritizedWords;
189
304
  }
190
- const transitions = this.renderWords(this.plotGroup, this.plotArea, placedWords);
191
- this.completeRender(placedWords, transitions).then(() => {
305
+ if (bestWords.length < words.length &&
306
+ attempt < MAX_LAYOUT_ATTEMPTS) {
307
+ const minimumSize = this.minimumFontSize(plotArea);
308
+ const smallerWords = words.map((word) => ({
309
+ ...word,
310
+ // Always try the minimum on the final attempt.
311
+ size: attempt === MAX_LAYOUT_ATTEMPTS - 1
312
+ ? minimumSize
313
+ : Math.max(minimumSize, word.size * FONT_SIZE_REDUCTION),
314
+ }));
315
+ const canShrink = smallerWords.some((word, index) => Math.floor(word.size) <
316
+ Math.floor(words[index].size));
317
+ if (canShrink) {
318
+ this.startLayout(smallerWords, plotArea, runId, resolve, attempt + 1, bestWords);
319
+ return;
320
+ }
321
+ }
322
+ if (bestWords.length < words.length) {
323
+ console.warn(`[Chart Warning] WordCloudChart: rendered ${bestWords.length} of ${words.length} words within the available area after ${attempt} layout attempt(s); retained the highest-frequency words`);
324
+ }
325
+ const transitions = this.renderWords(this.plotGroup, this.plotArea, bestWords);
326
+ this.completeRender(bestWords, transitions).then(() => {
192
327
  this.finishReady(resolve);
193
328
  });
194
329
  });
@@ -395,7 +530,9 @@ export class WordCloudChart extends BaseChart {
395
530
  this.resolvePendingReady = null;
396
531
  }
397
532
  finishReady(resolve) {
398
- this.resolvePendingReady = null;
533
+ if (this.resolvePendingReady === resolve) {
534
+ this.resolvePendingReady = null;
535
+ }
399
536
  resolve();
400
537
  }
401
538
  }
package/dist/x-axis.d.ts CHANGED
@@ -2,7 +2,7 @@ import { type Selection } from 'd3';
2
2
  import type { XAxisConfig, ChartTheme, D3Scale, DataItem, ExportHooks, XAxisConfigBase } from './types.js';
3
3
  import type { LayoutAwareComponent, ComponentSpace } from './chart-interface.js';
4
4
  export declare class XAxis implements LayoutAwareComponent<XAxisConfigBase> {
5
- readonly type: "xAxis";
5
+ readonly type: 'xAxis';
6
6
  readonly display: boolean;
7
7
  readonly dataKey?: string;
8
8
  readonly labelKey?: string;
package/dist/y-axis.d.ts CHANGED
@@ -2,7 +2,7 @@ import { type Selection } from 'd3';
2
2
  import type { ChartTheme, YAxisConfig, D3Scale, ExportHooks, YAxisConfigBase } from './types.js';
3
3
  import type { LayoutAwareComponent, ComponentSpace } from './chart-interface.js';
4
4
  export declare class YAxis implements LayoutAwareComponent<YAxisConfigBase> {
5
- readonly type: "yAxis";
5
+ readonly type: 'yAxis';
6
6
  readonly display: boolean;
7
7
  private readonly tickPadding;
8
8
  private fontSize;
@@ -126,7 +126,23 @@ chart.render('#word-cloud');
126
126
  ## Notes
127
127
 
128
128
  - `minFontSize` and `maxFontSize` use percentages of the smaller plot-area
129
- dimension and define the relative size range passed into `d3-cloud`.
129
+ dimension and define the initial size range. The chart fits long phrases to
130
+ the available area and may reduce text sizes while preserving frequency order
131
+ and the configured minimum (rounded up to a whole pixel, at least one pixel).
132
+ - Placement makes at most six attempts, stopping earlier when all words fit or
133
+ text sizes cannot shrink further. If a sixth attempt is needed, it uses the minimum text size.
134
+ Each attempt yields between short batches of work. Updating, resizing, or
135
+ destroying the chart cancels pending placement.
136
+ - If words still do not fit, the chart retains the longest successfully placed
137
+ prefix of the frequency-sorted input across those attempts and logs a warning.
138
+ It never substitutes rarer words for a missing more frequent word. If even the
139
+ most frequent word cannot fit at the minimum size, the cloud may be empty.
140
+ - `whenReady()` resolves after the final placement and any animation, including
141
+ when only part of the input can be rendered.
142
+ - Container resizing is debounced by 150 ms. `whenReady()` also waits for a
143
+ pending resize and its layout. Initial renders and data updates remain
144
+ immediate. Updates, explicit renders, and destruction cancel queued resize
145
+ work; returning to the current rendered dimensions skips the rerender.
130
146
  - The chart uses theme typography and palette colors directly when configuring
131
147
  `d3-cloud` and rendering the final SVG.
132
148
  - If `rotation` is omitted, the chart uses the native `d3-cloud` rotate
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.19.2",
2
+ "version": "0.20.0",
3
3
  "name": "@internetstiftelsen/charts",
4
4
  "type": "module",
5
5
  "sideEffects": false,
@@ -50,56 +50,57 @@
50
50
  "write-excel-file": "^4.1.1"
51
51
  },
52
52
  "devDependencies": {
53
- "@chromatic-com/storybook": "^5.2.1",
53
+ "@chromatic-com/storybook": "^5.3.0",
54
54
  "@eslint/js": "^10.0.1",
55
- "@handsontable/react-wrapper": "^17.1.0",
55
+ "@handsontable/react-wrapper": "^18.0.0",
56
56
  "@internetstiftelsen/styleguide": "^5.1.27",
57
- "@radix-ui/react-label": "^2.1.9",
58
- "@radix-ui/react-select": "^2.3.0",
59
- "@radix-ui/react-switch": "^1.3.0",
60
- "@radix-ui/react-tabs": "^1.1.14",
61
- "@speed-highlight/core": "^1.2.17",
62
- "@storybook/addon-a11y": "^10.4.6",
63
- "@storybook/addon-docs": "^10.4.6",
64
- "@storybook/addon-mcp": "^0.6.0",
65
- "@storybook/addon-vitest": "^10.4.6",
66
- "@storybook/react-vite": "^10.4.6",
67
- "@tailwindcss/vite": "^4.3.1",
57
+ "@radix-ui/react-label": "^2.1.15",
58
+ "@radix-ui/react-select": "^2.3.7",
59
+ "@radix-ui/react-switch": "^1.3.7",
60
+ "@radix-ui/react-tabs": "^1.1.21",
61
+ "@speed-highlight/core": "^1.2.24",
62
+ "@storybook/addon-a11y": "^10.5.7",
63
+ "@storybook/addon-docs": "^10.5.7",
64
+ "@storybook/addon-mcp": "^0.7.0",
65
+ "@storybook/addon-vitest": "^10.5.7",
66
+ "@storybook/react-vite": "^10.5.7",
67
+ "@tailwindcss/vite": "^4.3.3",
68
68
  "@testing-library/dom": "^10.4.1",
69
- "@testing-library/jest-dom": "^6.9.1",
69
+ "@testing-library/jest-dom": "^7.0.0",
70
70
  "@testing-library/react": "^16.3.2",
71
71
  "@types/d3": "^7.4.3",
72
72
  "@types/d3-cloud": "^1.2.9",
73
- "@types/node": "^25.9.3",
74
- "@types/react": "^19.2.17",
75
- "@types/react-dom": "^19.2.3",
76
- "@vitest/browser-playwright": "4.1.8",
77
- "@vitest/coverage-v8": "4.1.8",
78
- "@vitejs/plugin-react-swc": "^4.3.1",
73
+ "@types/node": "^26.2.0",
74
+ "@types/react": "^19.2.18",
75
+ "@types/react-dom": "^19.2.4",
76
+ "@typescript/native": "npm:typescript@~7.0.2",
77
+ "@vitejs/plugin-react": "^6.0.5",
78
+ "@vitest/browser-playwright": "4.1.10",
79
+ "@vitest/coverage-v8": "4.1.10",
79
80
  "class-variance-authority": "^0.7.1",
80
81
  "clsx": "^2.1.1",
81
- "eslint": "^10.5.0",
82
+ "eslint": "^10.8.1",
82
83
  "eslint-plugin-react-hooks": "^7.1.1",
83
- "eslint-plugin-react-refresh": "^0.5.2",
84
- "eslint-plugin-storybook": "^10.4.6",
85
- "globals": "^17.6.0",
86
- "handsontable": "^17.1.0",
87
- "jsdom": "^29.1.1",
88
- "lucide-react": "^1.18.0",
89
- "playwright": "^1.61.1",
90
- "prettier": "3.8.4",
91
- "radix-ui": "^1.5.0",
92
- "react": "^19.2.7",
93
- "react-dom": "^19.2.7",
94
- "sass": "^1.101.0",
95
- "storybook": "^10.4.6",
84
+ "eslint-plugin-react-refresh": "^0.5.3",
85
+ "eslint-plugin-storybook": "^10.5.7",
86
+ "globals": "^17.9.0",
87
+ "handsontable": "^18.0.0",
88
+ "jsdom": "^30.0.1",
89
+ "lucide-react": "^1.30.0",
90
+ "playwright": "^1.62.1",
91
+ "prettier": "3.9.6",
92
+ "radix-ui": "^1.6.7",
93
+ "react": "^19.2.8",
94
+ "react-dom": "^19.2.8",
95
+ "sass": "^1.102.0",
96
+ "storybook": "^10.5.7",
96
97
  "tailwind-merge": "^3.6.0",
97
- "tailwindcss": "^4.3.1",
98
- "tsc-alias": "^1.8.17",
98
+ "tailwindcss": "^4.3.3",
99
+ "tsc-alias": "^1.9.1",
99
100
  "tw-animate-css": "^1.4.0",
100
- "typescript": "~6.0.3",
101
- "typescript-eslint": "^8.61.0",
102
- "vite": "^8.0.16",
103
- "vitest": "^4.1.8"
101
+ "typescript": "npm:@typescript/typescript6@~6.0.2",
102
+ "typescript-eslint": "^8.66.0",
103
+ "vite": "^8.2.1",
104
+ "vitest": "^4.1.10"
104
105
  }
105
106
  }