@internetstiftelsen/charts 0.19.3 → 0.20.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
@@ -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;
@@ -27,6 +27,8 @@ export declare class TooltipDom {
27
27
  setContent(content: string): void;
28
28
  getBounds(): DOMRect | null;
29
29
  showAt(left: number, top: number): void;
30
+ hasVisibleTooltips(): boolean;
31
+ moveVisibleTooltips(deltaX: number, deltaY: number): void;
30
32
  hide(): void;
31
33
  cleanup(): void;
32
34
  measureTooltip(tooltip: TooltipDivSelection, content: string): {
@@ -50,6 +52,7 @@ export declare class TooltipDom {
50
52
  private scheduleTooltipPositionReset;
51
53
  private cancelTooltipPositionReset;
52
54
  private getTooltipTransitionStyle;
55
+ private getTooltipNodes;
53
56
  private isTooltipVisible;
54
57
  private getTooltipPosition;
55
58
  private hasVisibleSlideOffset;
@@ -108,6 +108,22 @@ export class TooltipDom {
108
108
  }
109
109
  this.showTooltipAt(this.tooltipDiv, left, top);
110
110
  }
111
+ hasVisibleTooltips() {
112
+ return this.getTooltipNodes().some((node) => this.isTooltipVisible(node));
113
+ }
114
+ moveVisibleTooltips(deltaX, deltaY) {
115
+ this.getTooltipNodes().forEach((node) => {
116
+ if (!this.isTooltipVisible(node)) {
117
+ return;
118
+ }
119
+ const position = this.getTooltipPosition(node);
120
+ if (!position) {
121
+ return;
122
+ }
123
+ node.style.left = `${position.left + deltaX}px`;
124
+ node.style.top = `${position.top + deltaY}px`;
125
+ });
126
+ }
111
127
  hide() {
112
128
  const tooltip = this.tooltipDiv ?? select(`#${this.id}`);
113
129
  if (!tooltip.empty()) {
@@ -350,6 +366,9 @@ export class TooltipDom {
350
366
  getTooltipTransitionStyle() {
351
367
  return `opacity ${this.transition.duration}ms ${this.transition.easing}, transform ${this.transition.duration}ms ${this.transition.easing}`;
352
368
  }
369
+ getTooltipNodes() {
370
+ return Array.from(document.querySelectorAll(`#${this.id}, [data-chart-tooltip-owner="${this.splitTooltipOwner}"]`));
371
+ }
353
372
  isTooltipVisible(node) {
354
373
  return (node.style.visibility === 'visible' && node.style.opacity !== '0');
355
374
  }
@@ -301,11 +301,11 @@ export function attachXYTooltipArea(config) {
301
301
  const svgNode = svg.node();
302
302
  if (!svgNode) {
303
303
  dom.hideTooltipSelection(tooltip);
304
- return null;
304
+ return;
305
305
  }
306
306
  const placementBounds = getTooltipPlacementBounds(svgNode);
307
307
  const candidates = [];
308
- series.forEach((currentSeries, seriesIndex) => {
308
+ series.forEach((currentSeries) => {
309
309
  const rawValue = dataPoint[currentSeries.dataKey];
310
310
  if (rawValue === null || rawValue === undefined) {
311
311
  return;
@@ -319,57 +319,90 @@ export function attachXYTooltipArea(config) {
319
319
  const target = resolveSplitTooltipTarget(currentSeries, visibleAnchor, resolvedBarAnchorPosition);
320
320
  candidates.push({
321
321
  series: currentSeries,
322
- seriesIndex,
323
322
  anchor: visibleAnchor,
324
323
  target,
325
324
  });
326
325
  });
327
- const selectedCandidate = candidates.find((candidate) => candidate.seriesIndex === request.seriesIndex) ?? getClosestSingleTooltipCandidate(candidates, request);
326
+ const selectedCandidate = getClosestSingleTooltipCandidate(candidates, request);
328
327
  updateVisualStateAtIndex(request.index);
329
328
  dom.hideSplitTooltips();
330
329
  if (!selectedCandidate) {
331
330
  dom.hideTooltipSelection(tooltip);
332
- return null;
331
+ return;
333
332
  }
334
333
  dom.applyRootTooltipStyles(theme, getSeriesTooltipStyle(selectedCandidate.series, dataPoint, request.index));
335
334
  const content = buildSplitTooltipContent(dataPoint, selectedCandidate.series);
336
335
  const measuredTooltip = dom.measureTooltip(tooltip, content);
337
336
  if (!measuredTooltip) {
338
337
  dom.hideTooltipSelection(tooltip);
339
- return null;
338
+ return;
340
339
  }
341
340
  const arrowEdge = resolveTooltipArrowEdge(resolvedPosition, selectedCandidate.anchor, selectedCandidate.target, measuredTooltip.width, measuredTooltip.height, placementBounds);
342
341
  const tooltipPosition = getAnchoredTooltipPosition(selectedCandidate.anchor, selectedCandidate.target, measuredTooltip.width, measuredTooltip.height, arrowEdge, placementBounds);
343
342
  if (!tooltipPosition) {
344
343
  dom.hideTooltipSelection(tooltip);
345
- return null;
344
+ return;
346
345
  }
347
346
  dom.renderTooltipWithConnector(tooltip, arrowEdge, tooltipPosition.left, tooltipPosition.top, measuredTooltip.width, measuredTooltip.height, selectedCandidate.target.x, selectedCandidate.target.y, selectedCandidate.anchor);
348
- return {
349
- index: request.index,
350
- seriesIndex: selectedCandidate.seriesIndex,
351
- };
352
347
  };
353
- let activeTooltipRequest = null;
348
+ const tooltipSvgNode = svg.node();
349
+ let activeSvgBounds = null;
350
+ let positionTrackingFrame = null;
351
+ const stopPositionTracking = () => {
352
+ activeSvgBounds = null;
353
+ if (positionTrackingFrame !== null) {
354
+ cancelFrame(positionTrackingFrame);
355
+ positionTrackingFrame = null;
356
+ }
357
+ };
358
+ const updateTooltipPosition = () => {
359
+ if (!activeSvgBounds || !tooltipSvgNode) {
360
+ return;
361
+ }
362
+ const nextSvgBounds = getElementTooltipBounds(tooltipSvgNode);
363
+ const deltaX = nextSvgBounds.minLeft - activeSvgBounds.minLeft;
364
+ const deltaY = nextSvgBounds.minTop - activeSvgBounds.minTop;
365
+ if (deltaX === 0 && deltaY === 0) {
366
+ return;
367
+ }
368
+ dom.moveVisibleTooltips(deltaX, deltaY);
369
+ activeSvgBounds = nextSvgBounds;
370
+ };
371
+ const trackTooltipPosition = () => {
372
+ positionTrackingFrame = null;
373
+ if (!tooltipSvgNode?.isConnected || !dom.hasVisibleTooltips()) {
374
+ stopPositionTracking();
375
+ return;
376
+ }
377
+ // Scroll events can arrive late during touch or momentum scrolling.
378
+ updateTooltipPosition();
379
+ positionTrackingFrame = requestFrame(trackTooltipPosition);
380
+ };
354
381
  const hideTooltip = () => {
355
- activeTooltipRequest = null;
382
+ stopPositionTracking();
356
383
  dom.hideTooltipSelection(tooltip);
357
384
  dom.hideSplitTooltips();
358
385
  clearVisualState();
359
386
  };
360
- const tooltipSvgNode = svg.node();
361
- const queuedRender = createQueuedTooltipRender(tooltipSvgNode, (request) => {
387
+ const renderTooltip = (request) => {
388
+ if (!tooltipSvgNode) {
389
+ return;
390
+ }
391
+ activeSvgBounds = getElementTooltipBounds(tooltipSvgNode);
392
+ if (positionTrackingFrame === null) {
393
+ positionTrackingFrame = requestFrame(trackTooltipPosition);
394
+ }
362
395
  if (mode === 'single') {
363
- activeTooltipRequest = showSingleTooltip(request);
396
+ showSingleTooltip(request);
364
397
  return;
365
398
  }
366
- activeTooltipRequest = { index: request.index };
367
399
  if (mode === 'split') {
368
400
  showSplitTooltipAtIndex(request.index);
369
401
  return;
370
402
  }
371
403
  showSharedTooltipAtIndex(request.index);
372
- });
404
+ };
405
+ const queuedRender = createQueuedTooltipRender(tooltipSvgNode, renderTooltip);
373
406
  overlay
374
407
  .on('mousemove', (event) => {
375
408
  const [mouseX, mouseY] = pointer(event, svg.node());
@@ -415,18 +448,7 @@ export function attachXYTooltipArea(config) {
415
448
  }
416
449
  queuedRender.cancel();
417
450
  select(this).attr('stroke', '#111827').attr('stroke-width', 2);
418
- if (mode === 'single') {
419
- activeTooltipRequest = showSingleTooltip({
420
- index: currentIndex,
421
- });
422
- return;
423
- }
424
- activeTooltipRequest = { index: currentIndex };
425
- if (mode === 'split') {
426
- showSplitTooltipAtIndex(currentIndex);
427
- return;
428
- }
429
- showSharedTooltipAtIndex(currentIndex);
451
+ renderTooltip({ index: currentIndex });
430
452
  })
431
453
  .on('blur', function (event) {
432
454
  select(this).attr('stroke', 'none').attr('stroke-width', 0);
@@ -448,12 +470,12 @@ export function attachXYTooltipArea(config) {
448
470
  event.preventDefault();
449
471
  focusTargetNodes[nextIndex].focus();
450
472
  });
451
- return attachTooltipScrollListeners(tooltipSvgNode, () => {
452
- if (activeTooltipRequest === null) {
453
- return;
454
- }
455
- queuedRender.request(activeTooltipRequest);
456
- });
473
+ const detachScrollListeners = attachTooltipScrollListeners(tooltipSvgNode, updateTooltipPosition);
474
+ return () => {
475
+ detachScrollListeners?.();
476
+ queuedRender.cancel();
477
+ stopPositionTracking();
478
+ };
457
479
  }
458
480
  function normalizeFormatterValue(value) {
459
481
  if (value === null ||
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;
@@ -167,6 +167,11 @@ paths are omitted when the arrow is already close to its target. For line,
167
167
  area, and scatter points, side split tooltips spread nearby point targets
168
168
  within the connectorless arrow range before drawing connector paths.
169
169
 
170
+ Visible XY tooltips track their targets on every animation frame, including
171
+ during touch and momentum scrolling. Their initial placement is preserved,
172
+ allowing them to pass beyond the container edges instead of sticking there
173
+ until blur. Tracking stops when the tooltips are hidden or the chart is destroyed.
174
+
170
175
  For horizontal bar charts, prefer `position: 'auto'` or `position: 'vertical'`.
171
176
  `position: 'side'` and `barAnchorPosition: 'top' | 'middle'` are kept for
172
177
  legacy configs, but horizontal bars resolve bar anchoring automatically.
@@ -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.3",
2
+ "version": "0.20.1",
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
  }