@internetstiftelsen/charts 0.14.0 → 0.14.2
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 +5 -2
- package/dist/base-chart.d.ts +7 -0
- package/dist/base-chart.js +77 -1
- package/dist/chart-group.d.ts +1 -0
- package/dist/chart-group.js +45 -4
- package/dist/export-xlsx.d.ts +2 -2
- package/dist/export-xlsx.js +22 -26
- package/dist/word-cloud-chart.d.ts +30 -0
- package/dist/word-cloud-chart.js +207 -7
- package/docs/components.md +1 -1
- package/docs/getting-started.md +3 -0
- package/docs/word-cloud-chart.md +43 -8
- package/package.json +10 -10
package/README.md
CHANGED
|
@@ -362,6 +362,7 @@ const data = [
|
|
|
362
362
|
|
|
363
363
|
const chart = new WordCloudChart({
|
|
364
364
|
data,
|
|
365
|
+
animate: true,
|
|
365
366
|
wordCloud: {
|
|
366
367
|
minValue: 5,
|
|
367
368
|
minWordLength: 3,
|
|
@@ -379,6 +380,8 @@ chart.render('#word-cloud');
|
|
|
379
380
|
dimension and define the relative size range passed into `d3-cloud`. The chart
|
|
380
381
|
expects flat `{ word, count }` rows, aggregates duplicate words after trimming,
|
|
381
382
|
and maps theme typography and colors directly into the layout and rendered SVG.
|
|
383
|
+
Set `animate: true` or pass an animation config to fade and scale words from
|
|
384
|
+
their own centers on initial render and `chart.update(...)`.
|
|
382
385
|
|
|
383
386
|
## Export
|
|
384
387
|
|
|
@@ -391,8 +394,8 @@ await chart.export('xlsx', { download: true, sheetName: 'Data' });
|
|
|
391
394
|
await chart.export('pdf', { download: true, pdfMargin: 16 });
|
|
392
395
|
```
|
|
393
396
|
|
|
394
|
-
`xlsx` and `pdf` are lazy-loaded and require optional dependencies
|
|
395
|
-
`jspdf`) only when those formats are used.
|
|
397
|
+
`xlsx` and `pdf` are lazy-loaded and require optional dependencies
|
|
398
|
+
(`write-excel-file` and `jspdf`) only when those formats are used.
|
|
396
399
|
|
|
397
400
|
## Import
|
|
398
401
|
|
package/dist/base-chart.d.ts
CHANGED
|
@@ -16,6 +16,7 @@ type RenderDimensions = {
|
|
|
16
16
|
svgWidthAttr: number | string;
|
|
17
17
|
svgHeightAttr: number | string;
|
|
18
18
|
};
|
|
19
|
+
type PlotAreaOverride = Partial<Pick<PlotAreaBounds, 'top' | 'right' | 'bottom' | 'left'>>;
|
|
19
20
|
type ResponsiveOverrides = {
|
|
20
21
|
theme?: DeepPartial<ChartTheme>;
|
|
21
22
|
components: Map<ChartComponentBase, Record<string, unknown>>;
|
|
@@ -123,6 +124,7 @@ export declare abstract class BaseChart {
|
|
|
123
124
|
private disconnectedLegendContainer;
|
|
124
125
|
private renderThemeOverride;
|
|
125
126
|
private renderSizeOverride;
|
|
127
|
+
private plotAreaOverride;
|
|
126
128
|
private legendModeOverride;
|
|
127
129
|
private readonly eventListeners;
|
|
128
130
|
private renderId;
|
|
@@ -165,6 +167,10 @@ export declare abstract class BaseChart {
|
|
|
165
167
|
width?: number;
|
|
166
168
|
height?: number;
|
|
167
169
|
};
|
|
170
|
+
/** @internal */
|
|
171
|
+
measurePlotArea(width: number, height: number): PlotAreaBounds;
|
|
172
|
+
/** @internal */
|
|
173
|
+
setPlotAreaOverride(override: PlotAreaOverride | null, rerender?: boolean): this;
|
|
168
174
|
setLegendModeOverride(mode: LegendMode | null, rerender?: boolean): this;
|
|
169
175
|
on<TEventName extends ChartEventName>(eventName: TEventName, listener: ChartEventListener<TEventName>): this;
|
|
170
176
|
off<TEventName extends ChartEventName>(eventName: TEventName, listener: ChartEventListener<TEventName>): this;
|
|
@@ -202,6 +208,7 @@ export declare abstract class BaseChart {
|
|
|
202
208
|
protected prepareForLegendChange(): void;
|
|
203
209
|
protected initializeDataState(): void;
|
|
204
210
|
protected prepareLayout(context: BaseLayoutContext): void;
|
|
211
|
+
private applyPlotAreaOverride;
|
|
205
212
|
/**
|
|
206
213
|
* Setup ResizeObserver for automatic resize handling
|
|
207
214
|
*/
|
package/dist/base-chart.js
CHANGED
|
@@ -219,6 +219,12 @@ export class BaseChart {
|
|
|
219
219
|
writable: true,
|
|
220
220
|
value: null
|
|
221
221
|
});
|
|
222
|
+
Object.defineProperty(this, "plotAreaOverride", {
|
|
223
|
+
enumerable: true,
|
|
224
|
+
configurable: true,
|
|
225
|
+
writable: true,
|
|
226
|
+
value: null
|
|
227
|
+
});
|
|
222
228
|
Object.defineProperty(this, "legendModeOverride", {
|
|
223
229
|
enumerable: true,
|
|
224
230
|
configurable: true,
|
|
@@ -361,7 +367,7 @@ export class BaseChart {
|
|
|
361
367
|
// Calculate layout
|
|
362
368
|
this.layoutManager = new LayoutManager(this.resolvedRenderTheme);
|
|
363
369
|
const components = this.getLayoutComponents();
|
|
364
|
-
const plotArea = this.layoutManager.calculateLayout(components);
|
|
370
|
+
const plotArea = this.applyPlotAreaOverride(this.layoutManager.calculateLayout(components));
|
|
365
371
|
this.plotArea = plotArea;
|
|
366
372
|
// Create plot group
|
|
367
373
|
const plotGroup = this.svg.append('g').attr('class', 'chart-plot');
|
|
@@ -517,6 +523,59 @@ export class BaseChart {
|
|
|
517
523
|
height: this.configuredHeight,
|
|
518
524
|
};
|
|
519
525
|
}
|
|
526
|
+
/** @internal */
|
|
527
|
+
measurePlotArea(width, height) {
|
|
528
|
+
const previousWidth = this.width;
|
|
529
|
+
const previousHeight = this.height;
|
|
530
|
+
const measurementSvg = create('svg')
|
|
531
|
+
.attr('width', width)
|
|
532
|
+
.attr('height', height)
|
|
533
|
+
.style('position', 'absolute')
|
|
534
|
+
.style('left', '-9999px')
|
|
535
|
+
.style('top', '-9999px')
|
|
536
|
+
.style('overflow', 'hidden')
|
|
537
|
+
.style('pointer-events', 'none');
|
|
538
|
+
const svgNode = measurementSvg.node();
|
|
539
|
+
if (!svgNode) {
|
|
540
|
+
throw new Error('Failed to initialize chart measurement SVG');
|
|
541
|
+
}
|
|
542
|
+
document.body.appendChild(svgNode);
|
|
543
|
+
this.width = width;
|
|
544
|
+
this.height = height;
|
|
545
|
+
const responsiveContext = this.resolveResponsiveContext({
|
|
546
|
+
width,
|
|
547
|
+
height,
|
|
548
|
+
});
|
|
549
|
+
const responsiveOverrides = this.collectResponsiveOverrides(responsiveContext);
|
|
550
|
+
const mergedComponentOverrides = this.mergeComponentOverrideMaps(responsiveOverrides.components);
|
|
551
|
+
const renderTheme = this.resolveRenderTheme(responsiveOverrides);
|
|
552
|
+
const overrideComponents = this.createOverrideComponents(mergedComponentOverrides);
|
|
553
|
+
const restoreComponents = this.applyComponentOverrides(overrideComponents);
|
|
554
|
+
const restoreTheme = this.applyRenderTheme(renderTheme);
|
|
555
|
+
try {
|
|
556
|
+
this.prepareLayout({
|
|
557
|
+
svg: measurementSvg,
|
|
558
|
+
svgNode,
|
|
559
|
+
});
|
|
560
|
+
const layoutManager = new LayoutManager(this.resolvedRenderTheme);
|
|
561
|
+
return this.applyPlotAreaOverride(layoutManager.calculateLayout(this.getLayoutComponents()));
|
|
562
|
+
}
|
|
563
|
+
finally {
|
|
564
|
+
restoreComponents();
|
|
565
|
+
restoreTheme();
|
|
566
|
+
this.width = previousWidth;
|
|
567
|
+
this.height = previousHeight;
|
|
568
|
+
svgNode.remove();
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
/** @internal */
|
|
572
|
+
setPlotAreaOverride(override, rerender = true) {
|
|
573
|
+
this.plotAreaOverride = override;
|
|
574
|
+
if (rerender) {
|
|
575
|
+
this.rerender('component');
|
|
576
|
+
}
|
|
577
|
+
return this;
|
|
578
|
+
}
|
|
520
579
|
setLegendModeOverride(mode, rerender = true) {
|
|
521
580
|
if (this.legendModeOverride === mode) {
|
|
522
581
|
return this;
|
|
@@ -854,6 +913,23 @@ export class BaseChart {
|
|
|
854
913
|
prepareLayout(context) {
|
|
855
914
|
this.measureInlineLegend(context.svgNode);
|
|
856
915
|
}
|
|
916
|
+
applyPlotAreaOverride(plotArea) {
|
|
917
|
+
if (!this.plotAreaOverride) {
|
|
918
|
+
return plotArea;
|
|
919
|
+
}
|
|
920
|
+
const left = this.plotAreaOverride.left ?? plotArea.left;
|
|
921
|
+
const right = this.plotAreaOverride.right ?? plotArea.right;
|
|
922
|
+
const top = this.plotAreaOverride.top ?? plotArea.top;
|
|
923
|
+
const bottom = this.plotAreaOverride.bottom ?? plotArea.bottom;
|
|
924
|
+
return {
|
|
925
|
+
left,
|
|
926
|
+
right,
|
|
927
|
+
top,
|
|
928
|
+
bottom,
|
|
929
|
+
width: Math.max(0, right - left),
|
|
930
|
+
height: Math.max(0, bottom - top),
|
|
931
|
+
};
|
|
932
|
+
}
|
|
857
933
|
/**
|
|
858
934
|
* Setup ResizeObserver for automatic resize handling
|
|
859
935
|
*/
|
package/dist/chart-group.d.ts
CHANGED
|
@@ -96,6 +96,7 @@ export declare class ChartGroup {
|
|
|
96
96
|
private resolveSharedYDomain;
|
|
97
97
|
private warnIncompatibleYScaleTypes;
|
|
98
98
|
private applyScaleSyncOverrides;
|
|
99
|
+
private applyPlotAreaSyncOverrides;
|
|
99
100
|
private buildRows;
|
|
100
101
|
private resolveDefaultChartHeight;
|
|
101
102
|
private resolveDefaultChartHeightForWidth;
|
package/dist/chart-group.js
CHANGED
|
@@ -291,7 +291,9 @@ export class ChartGroup {
|
|
|
291
291
|
const { width, renderedTopText, renderedBottomText, renderedLegend, layout, totalHeight, } = this.prepareRenderState(container);
|
|
292
292
|
this.isRendering = true;
|
|
293
293
|
try {
|
|
294
|
-
this.
|
|
294
|
+
const sharedYDomain = this.resolveSharedYDomain(width);
|
|
295
|
+
this.applyScaleSyncOverrides(width, sharedYDomain);
|
|
296
|
+
this.applyPlotAreaSyncOverrides(layout.items, sharedYDomain);
|
|
295
297
|
container.innerHTML = '';
|
|
296
298
|
const { root, chartLayer } = this.createRenderHosts(totalHeight, layout.chartHeight);
|
|
297
299
|
this.appendRenderedTextSections(root, renderedTopText);
|
|
@@ -376,6 +378,7 @@ export class ChartGroup {
|
|
|
376
378
|
chart.setLegendModeOverride(null, false);
|
|
377
379
|
if (chart instanceof XYChart) {
|
|
378
380
|
chart.setScaleConfigOverride(null, false);
|
|
381
|
+
chart.setPlotAreaOverride(null, false);
|
|
379
382
|
}
|
|
380
383
|
chart.destroy();
|
|
381
384
|
});
|
|
@@ -508,9 +511,8 @@ export class ChartGroup {
|
|
|
508
511
|
this.hasWarnedIncompatibleYScaleTypes = true;
|
|
509
512
|
ChartValidator.warn('ChartGroup: syncY requires all synced XY child charts to use the same vertical numeric scale type');
|
|
510
513
|
}
|
|
511
|
-
applyScaleSyncOverrides(width) {
|
|
514
|
+
applyScaleSyncOverrides(width, sharedDomain) {
|
|
512
515
|
const syncedCharts = new Set(this.getVerticalXYCharts(width));
|
|
513
|
-
const sharedDomain = this.resolveSharedYDomain(width);
|
|
514
516
|
this.getAllVerticalXYCharts().forEach((chart) => {
|
|
515
517
|
if (!sharedDomain || !syncedCharts.has(chart)) {
|
|
516
518
|
chart.setScaleConfigOverride(null, false);
|
|
@@ -524,6 +526,43 @@ export class ChartGroup {
|
|
|
524
526
|
}, false);
|
|
525
527
|
});
|
|
526
528
|
}
|
|
529
|
+
applyPlotAreaSyncOverrides(items, sharedDomain) {
|
|
530
|
+
this.getAllVerticalXYCharts().forEach((chart) => {
|
|
531
|
+
chart.setPlotAreaOverride(null, false);
|
|
532
|
+
});
|
|
533
|
+
if (!sharedDomain) {
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
const rowItems = new Map();
|
|
537
|
+
items.forEach((item) => {
|
|
538
|
+
if (!(item.chart instanceof XYChart) ||
|
|
539
|
+
item.chart.getOrientation() !== 'vertical') {
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
const row = rowItems.get(item.y) ?? [];
|
|
543
|
+
row.push(item);
|
|
544
|
+
rowItems.set(item.y, row);
|
|
545
|
+
});
|
|
546
|
+
rowItems.forEach((row) => {
|
|
547
|
+
if (row.length < 2) {
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
const measuredPlotAreas = row.map((item) => {
|
|
551
|
+
return {
|
|
552
|
+
chart: item.chart,
|
|
553
|
+
plotArea: item.chart.measurePlotArea(item.width, item.height),
|
|
554
|
+
};
|
|
555
|
+
});
|
|
556
|
+
const top = Math.max(...measuredPlotAreas.map(({ plotArea }) => plotArea.top));
|
|
557
|
+
const bottom = Math.min(...measuredPlotAreas.map(({ plotArea }) => plotArea.bottom));
|
|
558
|
+
if (bottom <= top) {
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
measuredPlotAreas.forEach(({ chart }) => {
|
|
562
|
+
chart.setPlotAreaOverride({ top, bottom }, false);
|
|
563
|
+
});
|
|
564
|
+
});
|
|
565
|
+
}
|
|
527
566
|
buildRows(entries, width, cols, gap) {
|
|
528
567
|
const availableWidth = Math.max(1, width - gap * Math.max(0, cols - 1));
|
|
529
568
|
const columnWidth = availableWidth / cols;
|
|
@@ -802,10 +841,12 @@ export class ChartGroup {
|
|
|
802
841
|
this.resizeObserver.observe(this.container);
|
|
803
842
|
}
|
|
804
843
|
async exportSVG(format, width, options) {
|
|
805
|
-
this.
|
|
844
|
+
const sharedYDomain = this.resolveSharedYDomain(width);
|
|
845
|
+
this.applyScaleSyncOverrides(width, sharedYDomain);
|
|
806
846
|
const baseContext = this.createExportRenderContext(format, width, options);
|
|
807
847
|
const exportLayoutState = this.resolveExportLayoutState(width, baseContext);
|
|
808
848
|
const layout = this.calculateLayout(width, exportLayoutState.chartAreaHeight, exportLayoutState.defaultChartHeightOverride);
|
|
849
|
+
this.applyPlotAreaSyncOverrides(layout.items, sharedYDomain);
|
|
809
850
|
const childSvgs = await this.exportLayoutItems(layout.items, options);
|
|
810
851
|
const totalHeight = exportLayoutState.topTextHeight +
|
|
811
852
|
layout.chartHeight +
|
package/dist/export-xlsx.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import type { ChartData, ExportOptions } from './types.js';
|
|
2
|
-
type
|
|
3
|
-
export declare function exportXLSXBlob(data: ChartData, options?: ExportOptions, loader?:
|
|
2
|
+
type XlsxWriterLoader = () => Promise<unknown>;
|
|
3
|
+
export declare function exportXLSXBlob(data: ChartData, options?: ExportOptions, loader?: XlsxWriterLoader): Promise<Blob>;
|
|
4
4
|
export {};
|
package/dist/export-xlsx.js
CHANGED
|
@@ -3,42 +3,38 @@ const XLSX_MIME_TYPE = 'application/vnd.openxmlformats-officedocument.spreadshee
|
|
|
3
3
|
function isObject(value) {
|
|
4
4
|
return typeof value === 'object' && value !== null;
|
|
5
5
|
}
|
|
6
|
-
function
|
|
6
|
+
function resolveXlsxWriter(moduleValue) {
|
|
7
|
+
if (typeof moduleValue === 'function') {
|
|
8
|
+
return moduleValue;
|
|
9
|
+
}
|
|
7
10
|
if (!isObject(moduleValue)) {
|
|
8
|
-
throw new Error('Invalid "
|
|
11
|
+
throw new Error('Invalid "write-excel-file" module export');
|
|
9
12
|
}
|
|
10
13
|
const moduleCandidate = moduleValue;
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
: moduleCandidate;
|
|
14
|
-
if (!xlsxCandidate.utils || !xlsxCandidate.write) {
|
|
15
|
-
throw new Error('Invalid "xlsx" module export');
|
|
14
|
+
if (typeof moduleCandidate.default === 'function') {
|
|
15
|
+
return moduleCandidate.default;
|
|
16
16
|
}
|
|
17
|
-
|
|
18
|
-
utils: xlsxCandidate.utils,
|
|
19
|
-
write: xlsxCandidate.write,
|
|
20
|
-
};
|
|
17
|
+
throw new Error('Invalid "write-excel-file" module export');
|
|
21
18
|
}
|
|
22
|
-
async function
|
|
23
|
-
return import('
|
|
19
|
+
async function defaultXlsxWriterLoader() {
|
|
20
|
+
return import('write-excel-file/universal');
|
|
24
21
|
}
|
|
25
|
-
export async function exportXLSXBlob(data, options, loader =
|
|
26
|
-
let
|
|
22
|
+
export async function exportXLSXBlob(data, options, loader = defaultXlsxWriterLoader) {
|
|
23
|
+
let xlsxWriterModule;
|
|
27
24
|
try {
|
|
28
|
-
|
|
25
|
+
xlsxWriterModule = await loader();
|
|
29
26
|
}
|
|
30
27
|
catch {
|
|
31
|
-
throw new Error('XLSX export requires optional dependency "
|
|
28
|
+
throw new Error('XLSX export requires optional dependency "write-excel-file". Install optional dependency "write-excel-file".');
|
|
32
29
|
}
|
|
33
|
-
const
|
|
30
|
+
const writeXlsxFile = resolveXlsxWriter(xlsxWriterModule);
|
|
34
31
|
const { columns, rows } = toTabularData(data, options?.columns);
|
|
35
|
-
const worksheet = xlsx.utils.aoa_to_sheet([columns, ...rows]);
|
|
36
|
-
const workbook = xlsx.utils.book_new();
|
|
37
32
|
const sheetName = options?.sheetName?.trim() || 'Sheet1';
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
33
|
+
const blob = await writeXlsxFile([columns, ...rows], {
|
|
34
|
+
sheet: sheetName,
|
|
35
|
+
}).toBlob();
|
|
36
|
+
if (blob.type === XLSX_MIME_TYPE) {
|
|
37
|
+
return blob;
|
|
38
|
+
}
|
|
39
|
+
return new Blob([await blob.arrayBuffer()], { type: XLSX_MIME_TYPE });
|
|
44
40
|
}
|
|
@@ -2,6 +2,12 @@ import { BaseChart, type BaseChartConfig, type BaseRenderContext } from './base-
|
|
|
2
2
|
import type { ChartData } from './types.js';
|
|
3
3
|
export type WordCloudRotationMode = 'none' | 'right-angle';
|
|
4
4
|
export type WordCloudSpiral = 'archimedean' | 'rectangular';
|
|
5
|
+
export type WordCloudAnimationEasingPreset = 'linear' | 'ease-in' | 'ease-out' | 'ease-in-out' | 'bounce-out' | 'elastic-out' | 'spring-out';
|
|
6
|
+
export type WordCloudAnimationConfig = {
|
|
7
|
+
show?: boolean;
|
|
8
|
+
duration?: number;
|
|
9
|
+
easing?: WordCloudAnimationEasingPreset | `linear(${string})` | ((progress: number) => number);
|
|
10
|
+
};
|
|
5
11
|
export type WordCloudConfig = {
|
|
6
12
|
maxWords?: number;
|
|
7
13
|
minWordLength?: number;
|
|
@@ -14,19 +20,43 @@ export type WordCloudConfig = {
|
|
|
14
20
|
};
|
|
15
21
|
export type WordCloudChartConfig = Pick<BaseChartConfig, 'data' | 'width' | 'height' | 'theme' | 'responsive'> & {
|
|
16
22
|
wordCloud?: WordCloudConfig;
|
|
23
|
+
animate?: boolean | WordCloudAnimationConfig;
|
|
17
24
|
};
|
|
18
25
|
export declare class WordCloudChart extends BaseChart {
|
|
19
26
|
private readonly options;
|
|
27
|
+
private readonly animation;
|
|
20
28
|
private layout;
|
|
21
29
|
private layoutRunId;
|
|
22
30
|
private resolvePendingReady;
|
|
31
|
+
private hasRenderedLive;
|
|
32
|
+
private nextRenderShouldAnimate;
|
|
33
|
+
private previousWordSnapshot;
|
|
23
34
|
constructor(config: WordCloudChartConfig);
|
|
35
|
+
update(data: ChartData): void;
|
|
24
36
|
destroy(): void;
|
|
25
37
|
protected validateSourceData(data: ChartData): void;
|
|
26
38
|
protected renderChart({ svg, plotArea }: BaseRenderContext): void;
|
|
27
39
|
protected createExportChart(): BaseChart;
|
|
40
|
+
private prepareAnimationForUpdate;
|
|
28
41
|
private startLayout;
|
|
29
42
|
private renderWords;
|
|
43
|
+
private shouldAnimateWords;
|
|
44
|
+
private createInitialWordTransform;
|
|
45
|
+
private createInitialWordTransformState;
|
|
46
|
+
private createWordTransform;
|
|
47
|
+
private createWordTransformState;
|
|
48
|
+
private createWordPositionTransform;
|
|
49
|
+
private createInitialScaleTransform;
|
|
50
|
+
private createScaleTransform;
|
|
51
|
+
private createInitialWordOpacity;
|
|
52
|
+
private animateWords;
|
|
53
|
+
private createWordGroupAnimationEntries;
|
|
54
|
+
private createWordTextAnimationEntries;
|
|
55
|
+
private applyWordAnimationFrame;
|
|
56
|
+
private requestAnimationFrame;
|
|
57
|
+
private now;
|
|
58
|
+
private completeRender;
|
|
59
|
+
private createWordSnapshot;
|
|
30
60
|
private stopLayout;
|
|
31
61
|
private finishReady;
|
|
32
62
|
}
|
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 { normalizeRadialAnimationConfig, } from './radial-animation.js';
|
|
5
6
|
const DEFAULT_OPTIONS = {
|
|
6
7
|
maxWords: 75,
|
|
7
8
|
minWordLength: 1,
|
|
@@ -12,6 +13,7 @@ const DEFAULT_OPTIONS = {
|
|
|
12
13
|
rotation: undefined,
|
|
13
14
|
spiral: 'archimedean',
|
|
14
15
|
};
|
|
16
|
+
const INITIAL_WORD_SCALE = 0.2;
|
|
15
17
|
const GROUPED_DATA_ERROR = 'WordCloudChart: grouped datasets are not supported; provide a flat array of rows instead';
|
|
16
18
|
function createPreparedWords(data, plotArea, options, colors) {
|
|
17
19
|
const counts = new Map();
|
|
@@ -61,6 +63,12 @@ export class WordCloudChart extends BaseChart {
|
|
|
61
63
|
writable: true,
|
|
62
64
|
value: void 0
|
|
63
65
|
});
|
|
66
|
+
Object.defineProperty(this, "animation", {
|
|
67
|
+
enumerable: true,
|
|
68
|
+
configurable: true,
|
|
69
|
+
writable: true,
|
|
70
|
+
value: void 0
|
|
71
|
+
});
|
|
64
72
|
Object.defineProperty(this, "layout", {
|
|
65
73
|
enumerable: true,
|
|
66
74
|
configurable: true,
|
|
@@ -79,6 +87,24 @@ export class WordCloudChart extends BaseChart {
|
|
|
79
87
|
writable: true,
|
|
80
88
|
value: null
|
|
81
89
|
});
|
|
90
|
+
Object.defineProperty(this, "hasRenderedLive", {
|
|
91
|
+
enumerable: true,
|
|
92
|
+
configurable: true,
|
|
93
|
+
writable: true,
|
|
94
|
+
value: false
|
|
95
|
+
});
|
|
96
|
+
Object.defineProperty(this, "nextRenderShouldAnimate", {
|
|
97
|
+
enumerable: true,
|
|
98
|
+
configurable: true,
|
|
99
|
+
writable: true,
|
|
100
|
+
value: false
|
|
101
|
+
});
|
|
102
|
+
Object.defineProperty(this, "previousWordSnapshot", {
|
|
103
|
+
enumerable: true,
|
|
104
|
+
configurable: true,
|
|
105
|
+
writable: true,
|
|
106
|
+
value: new Map()
|
|
107
|
+
});
|
|
82
108
|
const wordCloud = config.wordCloud ?? {};
|
|
83
109
|
this.options = {
|
|
84
110
|
maxWords: wordCloud.maxWords ?? DEFAULT_OPTIONS.maxWords,
|
|
@@ -90,8 +116,16 @@ export class WordCloudChart extends BaseChart {
|
|
|
90
116
|
rotation: wordCloud.rotation,
|
|
91
117
|
spiral: wordCloud.spiral ?? DEFAULT_OPTIONS.spiral,
|
|
92
118
|
};
|
|
119
|
+
this.animation = normalizeRadialAnimationConfig(config.animate, 'WordCloudChart');
|
|
120
|
+
this.nextRenderShouldAnimate = this.animation.show;
|
|
93
121
|
this.initializeDataState();
|
|
94
122
|
}
|
|
123
|
+
update(data) {
|
|
124
|
+
if (this.container) {
|
|
125
|
+
this.prepareAnimationForUpdate();
|
|
126
|
+
}
|
|
127
|
+
super.update(data);
|
|
128
|
+
}
|
|
95
129
|
destroy() {
|
|
96
130
|
this.layoutRunId += 1;
|
|
97
131
|
this.stopLayout();
|
|
@@ -120,8 +154,15 @@ export class WordCloudChart extends BaseChart {
|
|
|
120
154
|
theme: this.theme,
|
|
121
155
|
responsive: this.responsiveConfig,
|
|
122
156
|
wordCloud: this.options,
|
|
157
|
+
animate: false,
|
|
123
158
|
});
|
|
124
159
|
}
|
|
160
|
+
prepareAnimationForUpdate() {
|
|
161
|
+
this.nextRenderShouldAnimate =
|
|
162
|
+
this.animation.show &&
|
|
163
|
+
this.animation.duration > 0 &&
|
|
164
|
+
this.hasRenderedLive;
|
|
165
|
+
}
|
|
125
166
|
startLayout(words, plotArea, runId, resolve) {
|
|
126
167
|
const layout = cloud()
|
|
127
168
|
.words(words.map((word) => ({ ...word })))
|
|
@@ -146,8 +187,10 @@ export class WordCloudChart extends BaseChart {
|
|
|
146
187
|
if (placedWords.length < words.length) {
|
|
147
188
|
console.warn(`[Chart Warning] WordCloudChart: rendered ${placedWords.length} of ${words.length} words within the available area; reduce maxWords or font sizes to fit more words`);
|
|
148
189
|
}
|
|
149
|
-
this.renderWords(this.plotGroup, this.plotArea, placedWords);
|
|
150
|
-
this.
|
|
190
|
+
const transitions = this.renderWords(this.plotGroup, this.plotArea, placedWords);
|
|
191
|
+
this.completeRender(placedWords, transitions).then(() => {
|
|
192
|
+
this.finishReady(resolve);
|
|
193
|
+
});
|
|
151
194
|
});
|
|
152
195
|
if (this.options.rotation === 'none') {
|
|
153
196
|
layout.rotate(0);
|
|
@@ -170,27 +213,184 @@ export class WordCloudChart extends BaseChart {
|
|
|
170
213
|
.attr('fill', 'transparent')
|
|
171
214
|
.attr('stroke', 'none')
|
|
172
215
|
.attr('pointer-events', 'none');
|
|
173
|
-
plotGroup
|
|
216
|
+
const cloudGroup = plotGroup
|
|
174
217
|
.append('g')
|
|
175
218
|
.attr('class', 'word-cloud')
|
|
176
|
-
.attr('transform', `translate(${plotArea.width / 2}, ${plotArea.height / 2})`)
|
|
177
|
-
|
|
219
|
+
.attr('transform', `translate(${plotArea.width / 2}, ${plotArea.height / 2})`);
|
|
220
|
+
const wordGroupSelection = cloudGroup
|
|
221
|
+
.selectAll('g.word-cloud-word-wrapper')
|
|
178
222
|
.data(words)
|
|
179
|
-
.join('
|
|
223
|
+
.join('g')
|
|
224
|
+
.attr('class', 'word-cloud-word-wrapper')
|
|
225
|
+
.attr('transform', (word) => this.createWordTransform(word));
|
|
226
|
+
const wordSelection = wordGroupSelection
|
|
227
|
+
.append('text')
|
|
180
228
|
.attr('class', 'word-cloud-word')
|
|
181
229
|
.attr('text-anchor', 'middle')
|
|
182
230
|
.style('font-family', this.renderTheme.fontFamily)
|
|
183
231
|
.style('font-weight', String(this.renderTheme.valueLabel.fontWeight))
|
|
184
232
|
.style('font-size', (word) => `${word.size}px`)
|
|
185
233
|
.style('fill', (word) => word.color)
|
|
186
|
-
.attr('transform', (word) => `translate(${word.x ?? 0}, ${word.y ?? 0}) rotate(${word.rotate ?? 0})`)
|
|
187
234
|
.text((word) => word.text);
|
|
235
|
+
if (!this.shouldAnimateWords()) {
|
|
236
|
+
return [];
|
|
237
|
+
}
|
|
238
|
+
wordGroupSelection.attr('transform', (word) => this.createInitialWordTransform(word));
|
|
239
|
+
wordSelection.attr('transform', (word) => this.createInitialScaleTransform(word));
|
|
240
|
+
wordSelection.attr('opacity', (word) => String(this.createInitialWordOpacity(word)));
|
|
241
|
+
return [this.animateWords(wordGroupSelection, wordSelection)];
|
|
242
|
+
}
|
|
243
|
+
shouldAnimateWords() {
|
|
244
|
+
return (this.nextRenderShouldAnimate &&
|
|
245
|
+
this.animation.show &&
|
|
246
|
+
this.animation.duration > 0);
|
|
247
|
+
}
|
|
248
|
+
createInitialWordTransform(word) {
|
|
249
|
+
return this.createWordPositionTransform(this.createInitialWordTransformState(word));
|
|
250
|
+
}
|
|
251
|
+
createInitialWordTransformState(word) {
|
|
252
|
+
const previous = this.previousWordSnapshot.get(word.text);
|
|
253
|
+
if (previous) {
|
|
254
|
+
return {
|
|
255
|
+
x: previous.x,
|
|
256
|
+
y: previous.y,
|
|
257
|
+
rotate: previous.rotate,
|
|
258
|
+
scale: word.size > 0 ? previous.size / word.size : 1,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
return {
|
|
262
|
+
x: word.x ?? 0,
|
|
263
|
+
y: word.y ?? 0,
|
|
264
|
+
rotate: word.rotate ?? 0,
|
|
265
|
+
scale: INITIAL_WORD_SCALE,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
createWordTransform(word) {
|
|
269
|
+
return this.createWordPositionTransform({
|
|
270
|
+
x: word.x ?? 0,
|
|
271
|
+
y: word.y ?? 0,
|
|
272
|
+
rotate: word.rotate ?? 0,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
createWordTransformState(word) {
|
|
276
|
+
return {
|
|
277
|
+
x: word.x ?? 0,
|
|
278
|
+
y: word.y ?? 0,
|
|
279
|
+
rotate: word.rotate ?? 0,
|
|
280
|
+
scale: 1,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
createWordPositionTransform(state) {
|
|
284
|
+
return `translate(${state.x}, ${state.y}) rotate(${state.rotate})`;
|
|
285
|
+
}
|
|
286
|
+
createInitialScaleTransform(word) {
|
|
287
|
+
return this.createScaleTransform(this.createInitialWordTransformState(word).scale);
|
|
288
|
+
}
|
|
289
|
+
createScaleTransform(scale) {
|
|
290
|
+
return `scale(${scale})`;
|
|
291
|
+
}
|
|
292
|
+
createInitialWordOpacity(word) {
|
|
293
|
+
return this.previousWordSnapshot.has(word.text) ? 1 : 0;
|
|
294
|
+
}
|
|
295
|
+
animateWords(wordGroupSelection, wordSelection) {
|
|
296
|
+
const groupEntries = this.createWordGroupAnimationEntries(wordGroupSelection);
|
|
297
|
+
const textEntries = this.createWordTextAnimationEntries(wordSelection);
|
|
298
|
+
const startTime = this.now();
|
|
299
|
+
return new Promise((resolve) => {
|
|
300
|
+
const tick = (currentTime) => {
|
|
301
|
+
const progress = Math.min(1, (currentTime - startTime) / this.animation.duration);
|
|
302
|
+
const easedProgress = this.animation.easing(progress);
|
|
303
|
+
this.applyWordAnimationFrame(groupEntries, textEntries, easedProgress);
|
|
304
|
+
if (progress >= 1) {
|
|
305
|
+
resolve();
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
this.requestAnimationFrame(tick);
|
|
309
|
+
};
|
|
310
|
+
this.requestAnimationFrame(tick);
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
createWordGroupAnimationEntries(wordGroupSelection) {
|
|
314
|
+
const entries = [];
|
|
315
|
+
wordGroupSelection.each((word, _index, nodes) => {
|
|
316
|
+
const node = nodes[_index];
|
|
317
|
+
entries.push({
|
|
318
|
+
node,
|
|
319
|
+
start: this.createInitialWordTransformState(word),
|
|
320
|
+
end: this.createWordTransformState(word),
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
return entries;
|
|
324
|
+
}
|
|
325
|
+
createWordTextAnimationEntries(wordSelection) {
|
|
326
|
+
const entries = [];
|
|
327
|
+
wordSelection.each((word, _index, nodes) => {
|
|
328
|
+
entries.push({
|
|
329
|
+
node: nodes[_index],
|
|
330
|
+
startScale: this.createInitialWordTransformState(word).scale,
|
|
331
|
+
startOpacity: this.createInitialWordOpacity(word),
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
return entries;
|
|
335
|
+
}
|
|
336
|
+
applyWordAnimationFrame(groupEntries, textEntries, progress) {
|
|
337
|
+
groupEntries.forEach((entry) => {
|
|
338
|
+
entry.node.setAttribute('transform', this.createWordPositionTransform({
|
|
339
|
+
x: entry.start.x + (entry.end.x - entry.start.x) * progress,
|
|
340
|
+
y: entry.start.y + (entry.end.y - entry.start.y) * progress,
|
|
341
|
+
rotate: entry.start.rotate +
|
|
342
|
+
(entry.end.rotate - entry.start.rotate) * progress,
|
|
343
|
+
}));
|
|
344
|
+
});
|
|
345
|
+
textEntries.forEach((entry) => {
|
|
346
|
+
entry.node.setAttribute('opacity', String(entry.startOpacity + (1 - entry.startOpacity) * progress));
|
|
347
|
+
entry.node.setAttribute('transform', this.createScaleTransform(entry.startScale + (1 - entry.startScale) * progress));
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
requestAnimationFrame(callback) {
|
|
351
|
+
if (typeof requestAnimationFrame === 'function') {
|
|
352
|
+
requestAnimationFrame(callback);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
setTimeout(() => {
|
|
356
|
+
callback(this.now());
|
|
357
|
+
}, 16);
|
|
358
|
+
}
|
|
359
|
+
now() {
|
|
360
|
+
return typeof performance === 'undefined'
|
|
361
|
+
? Date.now()
|
|
362
|
+
: performance.now();
|
|
363
|
+
}
|
|
364
|
+
completeRender(words, transitions) {
|
|
365
|
+
this.previousWordSnapshot = this.createWordSnapshot(words);
|
|
366
|
+
this.hasRenderedLive = true;
|
|
367
|
+
this.nextRenderShouldAnimate = false;
|
|
368
|
+
if (transitions.length === 0) {
|
|
369
|
+
return Promise.resolve();
|
|
370
|
+
}
|
|
371
|
+
return Promise.allSettled(transitions).then(() => undefined);
|
|
372
|
+
}
|
|
373
|
+
createWordSnapshot(words) {
|
|
374
|
+
return new Map(words.map((word) => {
|
|
375
|
+
return [
|
|
376
|
+
word.text,
|
|
377
|
+
{
|
|
378
|
+
x: word.x ?? 0,
|
|
379
|
+
y: word.y ?? 0,
|
|
380
|
+
rotate: word.rotate ?? 0,
|
|
381
|
+
size: word.size,
|
|
382
|
+
},
|
|
383
|
+
];
|
|
384
|
+
}));
|
|
188
385
|
}
|
|
189
386
|
stopLayout() {
|
|
190
387
|
if (this.layout) {
|
|
191
388
|
this.layout.stop();
|
|
192
389
|
this.layout = null;
|
|
193
390
|
}
|
|
391
|
+
this.plotGroup
|
|
392
|
+
?.selectAll('.word-cloud-word-wrapper, .word-cloud-word')
|
|
393
|
+
.interrupt();
|
|
194
394
|
this.resolvePendingReady?.();
|
|
195
395
|
this.resolvePendingReady = null;
|
|
196
396
|
}
|
package/docs/components.md
CHANGED
package/docs/getting-started.md
CHANGED
|
@@ -338,6 +338,7 @@ const data = [
|
|
|
338
338
|
|
|
339
339
|
const chart = new WordCloudChart({
|
|
340
340
|
data,
|
|
341
|
+
animate: true,
|
|
341
342
|
wordCloud: {
|
|
342
343
|
minWordLength: 3,
|
|
343
344
|
minValue: 5,
|
|
@@ -357,6 +358,8 @@ chart.render('#word-cloud-container');
|
|
|
357
358
|
dimension and define the relative size range passed into `d3-cloud`. Word
|
|
358
359
|
clouds accept flat `{ word, count }` rows and use theme typography/colors
|
|
359
360
|
directly when laying out and rendering the cloud.
|
|
361
|
+
Set `animate: true` or pass an animation config to fade and scale words from
|
|
362
|
+
their own centers on initial render and `chart.update(...)`.
|
|
360
363
|
|
|
361
364
|
## Next Steps
|
|
362
365
|
|
package/docs/word-cloud-chart.md
CHANGED
|
@@ -17,14 +17,15 @@ new WordCloudChart(config: WordCloudChartConfig)
|
|
|
17
17
|
|
|
18
18
|
## Config Options
|
|
19
19
|
|
|
20
|
-
| Option | Type
|
|
21
|
-
| ------------ |
|
|
22
|
-
| `data` | `DataItem[]`
|
|
23
|
-
| `width` | `number`
|
|
24
|
-
| `height` | `number`
|
|
25
|
-
| `theme` | `DeepPartial<ChartTheme>`
|
|
26
|
-
| `responsive` | `ResponsiveConfig`
|
|
27
|
-
| `wordCloud` | `WordCloudConfig`
|
|
20
|
+
| Option | Type | Default | Description |
|
|
21
|
+
| ------------ | -------------------------------------- | -------- | ------------------------------------------------------- |
|
|
22
|
+
| `data` | `DataItem[]` | required | Flat `{ word, count }` rows |
|
|
23
|
+
| `width` | `number` | - | Explicit chart width in pixels |
|
|
24
|
+
| `height` | `number` | - | Explicit chart height in pixels |
|
|
25
|
+
| `theme` | `DeepPartial<ChartTheme>` | - | Theme customization |
|
|
26
|
+
| `responsive` | `ResponsiveConfig` | - | Declarative container-query responsive overrides |
|
|
27
|
+
| `wordCloud` | `WordCloudConfig` | - | Layout and filtering options |
|
|
28
|
+
| `animate` | `boolean \| WordCloudAnimationConfig` | `false` | Opt-in word animation for initial render and `update()` |
|
|
28
29
|
|
|
29
30
|
### `wordCloud`
|
|
30
31
|
|
|
@@ -41,6 +42,36 @@ wordCloud: {
|
|
|
41
42
|
}
|
|
42
43
|
```
|
|
43
44
|
|
|
45
|
+
### Animation
|
|
46
|
+
|
|
47
|
+
```typescript
|
|
48
|
+
animate: true
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
or:
|
|
52
|
+
|
|
53
|
+
```typescript
|
|
54
|
+
animate: {
|
|
55
|
+
duration?: number; // default: 700
|
|
56
|
+
easing?:
|
|
57
|
+
| 'linear'
|
|
58
|
+
| 'ease-in'
|
|
59
|
+
| 'ease-out'
|
|
60
|
+
| 'ease-in-out'
|
|
61
|
+
| 'bounce-out'
|
|
62
|
+
| 'elastic-out'
|
|
63
|
+
| 'spring-out'
|
|
64
|
+
| `linear(${string})`
|
|
65
|
+
| ((progress: number) => number);
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Animation is off by default. When enabled, words fade and scale from their own
|
|
70
|
+
centers on the first render. On `chart.update(...)`, words that existed in the
|
|
71
|
+
previous layout animate from their previous positions and scale from their own
|
|
72
|
+
centers when their size changes. Use `chart.whenReady()` when surrounding UI or
|
|
73
|
+
tests need to wait until layout and animation have finished.
|
|
74
|
+
|
|
44
75
|
## Example
|
|
45
76
|
|
|
46
77
|
```typescript
|
|
@@ -56,6 +87,10 @@ const data = [
|
|
|
56
87
|
|
|
57
88
|
const chart = new WordCloudChart({
|
|
58
89
|
data,
|
|
90
|
+
animate: {
|
|
91
|
+
duration: 700,
|
|
92
|
+
easing: 'ease-in-out',
|
|
93
|
+
},
|
|
59
94
|
wordCloud: {
|
|
60
95
|
minWordLength: 3,
|
|
61
96
|
minValue: 10,
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.14.
|
|
2
|
+
"version": "0.14.2",
|
|
3
3
|
"name": "@internetstiftelsen/charts",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
},
|
|
40
40
|
"optionalDependencies": {
|
|
41
41
|
"jspdf": "^4.2.1",
|
|
42
|
-
"
|
|
42
|
+
"write-excel-file": "^4.0.7"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
45
|
"@eslint/js": "^10.0.1",
|
|
@@ -57,30 +57,30 @@
|
|
|
57
57
|
"@types/d3": "^7.4.3",
|
|
58
58
|
"@types/d3-cloud": "^1.2.9",
|
|
59
59
|
"@types/node": "^25.9.1",
|
|
60
|
-
"@types/react": "^19.2.
|
|
60
|
+
"@types/react": "^19.2.16",
|
|
61
61
|
"@types/react-dom": "^19.2.3",
|
|
62
62
|
"@vitejs/plugin-react-swc": "^4.3.1",
|
|
63
63
|
"class-variance-authority": "^0.7.1",
|
|
64
64
|
"clsx": "^2.1.1",
|
|
65
|
-
"eslint": "^10.4.
|
|
65
|
+
"eslint": "^10.4.1",
|
|
66
66
|
"eslint-plugin-react-hooks": "^7.1.1",
|
|
67
67
|
"eslint-plugin-react-refresh": "^0.5.2",
|
|
68
68
|
"globals": "^17.6.0",
|
|
69
69
|
"handsontable": "^17.1.0",
|
|
70
70
|
"jsdom": "^29.1.1",
|
|
71
|
-
"lucide-react": "^1.
|
|
71
|
+
"lucide-react": "^1.17.0",
|
|
72
72
|
"prettier": "3.8.3",
|
|
73
73
|
"radix-ui": "^1.4.3",
|
|
74
|
-
"react": "^19.2.
|
|
75
|
-
"react-dom": "^19.2.
|
|
74
|
+
"react": "^19.2.7",
|
|
75
|
+
"react-dom": "^19.2.7",
|
|
76
76
|
"sass": "^1.100.0",
|
|
77
77
|
"tailwind-merge": "^3.6.0",
|
|
78
78
|
"tailwindcss": "^4.3.0",
|
|
79
79
|
"tsc-alias": "^1.8.17",
|
|
80
80
|
"tw-animate-css": "^1.4.0",
|
|
81
81
|
"typescript": "~6.0.3",
|
|
82
|
-
"typescript-eslint": "^8.
|
|
83
|
-
"vite": "^8.0.
|
|
84
|
-
"vitest": "^4.1.
|
|
82
|
+
"typescript-eslint": "^8.60.1",
|
|
83
|
+
"vite": "^8.0.16",
|
|
84
|
+
"vitest": "^4.1.8"
|
|
85
85
|
}
|
|
86
86
|
}
|