@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 +6 -1
- package/dist/area.d.ts +1 -1
- package/dist/bar.d.ts +1 -1
- package/dist/base-chart.d.ts +2 -0
- package/dist/base-chart.js +16 -12
- package/dist/donut-center-content.d.ts +1 -1
- package/dist/grid.d.ts +1 -1
- package/dist/legend.d.ts +1 -1
- package/dist/line.d.ts +1 -1
- package/dist/scatter.d.ts +1 -1
- package/dist/title.d.ts +2 -2
- package/dist/tooltip/geometry.js +1 -1
- package/dist/tooltip/xy-interaction.js +40 -8
- package/dist/tooltip.d.ts +1 -1
- package/dist/word-cloud-chart.d.ts +8 -0
- package/dist/word-cloud-chart.js +145 -8
- package/dist/x-axis.d.ts +1 -1
- package/dist/y-axis.d.ts +1 -1
- package/docs/word-cloud-chart.md +17 -1
- package/package.json +42 -41
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
|
|
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:
|
|
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:
|
|
6
|
+
readonly type: 'bar';
|
|
7
7
|
readonly dataKey: string;
|
|
8
8
|
readonly fill: string;
|
|
9
9
|
readonly colorAdapter?: (data: DataItem, index: number) => string;
|
package/dist/base-chart.d.ts
CHANGED
|
@@ -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
|
package/dist/base-chart.js
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
5
|
+
readonly type: 'title';
|
|
6
6
|
readonly display: boolean;
|
|
7
7
|
readonly text: string;
|
|
8
|
-
readonly position:
|
|
8
|
+
readonly position: 'top';
|
|
9
9
|
readonly variant = "title";
|
|
10
10
|
readonly exportHooks?: ExportHooks<TitleConfigBase>;
|
|
11
11
|
private readonly textComponent;
|
package/dist/tooltip/geometry.js
CHANGED
|
@@ -725,7 +725,7 @@ function getHorizontalAboveBelowPlacementCost(candidate, layout, preferredEdge,
|
|
|
725
725
|
return (connectorPenalty +
|
|
726
726
|
edgePenalty +
|
|
727
727
|
xDistance +
|
|
728
|
-
yDistance *
|
|
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
|
-
|
|
761
|
-
|
|
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 =
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
const
|
|
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
|
|
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:
|
|
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;
|
package/dist/word-cloud-chart.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
188
|
-
|
|
300
|
+
this.layout = null;
|
|
301
|
+
const prioritizedWords = getPlacedPrefix(words, placedWords);
|
|
302
|
+
if (prioritizedWords.length > bestWords.length) {
|
|
303
|
+
bestWords = prioritizedWords;
|
|
189
304
|
}
|
|
190
|
-
|
|
191
|
-
|
|
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
|
|
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:
|
|
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:
|
|
5
|
+
readonly type: 'yAxis';
|
|
6
6
|
readonly display: boolean;
|
|
7
7
|
private readonly tickPadding;
|
|
8
8
|
private fontSize;
|
package/docs/word-cloud-chart.md
CHANGED
|
@@ -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
|
|
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.
|
|
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.
|
|
53
|
+
"@chromatic-com/storybook": "^5.3.0",
|
|
54
54
|
"@eslint/js": "^10.0.1",
|
|
55
|
-
"@handsontable/react-wrapper": "^
|
|
55
|
+
"@handsontable/react-wrapper": "^18.0.0",
|
|
56
56
|
"@internetstiftelsen/styleguide": "^5.1.27",
|
|
57
|
-
"@radix-ui/react-label": "^2.1.
|
|
58
|
-
"@radix-ui/react-select": "^2.3.
|
|
59
|
-
"@radix-ui/react-switch": "^1.3.
|
|
60
|
-
"@radix-ui/react-tabs": "^1.1.
|
|
61
|
-
"@speed-highlight/core": "^1.2.
|
|
62
|
-
"@storybook/addon-a11y": "^10.
|
|
63
|
-
"@storybook/addon-docs": "^10.
|
|
64
|
-
"@storybook/addon-mcp": "^0.
|
|
65
|
-
"@storybook/addon-vitest": "^10.
|
|
66
|
-
"@storybook/react-vite": "^10.
|
|
67
|
-
"@tailwindcss/vite": "^4.3.
|
|
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": "^
|
|
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": "^
|
|
74
|
-
"@types/react": "^19.2.
|
|
75
|
-
"@types/react-dom": "^19.2.
|
|
76
|
-
"@
|
|
77
|
-
"@
|
|
78
|
-
"@
|
|
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.
|
|
82
|
+
"eslint": "^10.8.1",
|
|
82
83
|
"eslint-plugin-react-hooks": "^7.1.1",
|
|
83
|
-
"eslint-plugin-react-refresh": "^0.5.
|
|
84
|
-
"eslint-plugin-storybook": "^10.
|
|
85
|
-
"globals": "^17.
|
|
86
|
-
"handsontable": "^
|
|
87
|
-
"jsdom": "^
|
|
88
|
-
"lucide-react": "^1.
|
|
89
|
-
"playwright": "^1.
|
|
90
|
-
"prettier": "3.
|
|
91
|
-
"radix-ui": "^1.
|
|
92
|
-
"react": "^19.2.
|
|
93
|
-
"react-dom": "^19.2.
|
|
94
|
-
"sass": "^1.
|
|
95
|
-
"storybook": "^10.
|
|
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.
|
|
98
|
-
"tsc-alias": "^1.
|
|
98
|
+
"tailwindcss": "^4.3.3",
|
|
99
|
+
"tsc-alias": "^1.9.1",
|
|
99
100
|
"tw-animate-css": "^1.4.0",
|
|
100
|
-
"typescript": "
|
|
101
|
-
"typescript-eslint": "^8.
|
|
102
|
-
"vite": "^8.
|
|
103
|
-
"vitest": "^4.1.
|
|
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
|
}
|