@internetstiftelsen/charts 0.4.1 → 0.4.3
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 +14 -0
- package/base-chart.d.ts +14 -5
- package/base-chart.js +111 -14
- package/donut-chart.js +3 -3
- package/export-image.d.ts +12 -0
- package/export-image.js +48 -0
- package/export-pdf.d.ts +8 -0
- package/export-pdf.js +67 -0
- package/export-tabular.d.ts +9 -0
- package/export-tabular.js +61 -0
- package/export-xlsx.d.ts +4 -0
- package/export-xlsx.js +44 -0
- package/package.json +9 -5
- package/types.d.ts +8 -1
package/README.md
CHANGED
|
@@ -43,6 +43,20 @@ chart
|
|
|
43
43
|
chart.render('#chart-container');
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
+
## Export
|
|
47
|
+
|
|
48
|
+
`chart.export()` supports `svg`, `json`, `csv`, `xlsx`, `png`, `jpg`, and `pdf`.
|
|
49
|
+
|
|
50
|
+
```javascript
|
|
51
|
+
await chart.export('png', { download: true });
|
|
52
|
+
await chart.export('csv', { download: true, delimiter: ';' });
|
|
53
|
+
await chart.export('xlsx', { download: true, sheetName: 'Data' });
|
|
54
|
+
await chart.export('pdf', { download: true, pdfMargin: 16 });
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`xlsx` and `pdf` are lazy-loaded and require optional dependencies (`xlsx` and
|
|
58
|
+
`jspdf`) only when those formats are used.
|
|
59
|
+
|
|
46
60
|
## Documentation
|
|
47
61
|
|
|
48
62
|
- [Getting Started](./docs/getting-started.md) - Installation, Vanilla JS, React integration
|
package/base-chart.d.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type { Tooltip } from './tooltip.js';
|
|
|
8
8
|
import type { Legend } from './legend.js';
|
|
9
9
|
import type { Title } from './title.js';
|
|
10
10
|
import { LayoutManager, type PlotAreaBounds } from './layout-manager.js';
|
|
11
|
+
type VisualExportFormat = 'svg' | 'png' | 'jpg' | 'pdf';
|
|
11
12
|
export type BaseChartConfig = {
|
|
12
13
|
data: DataItem[];
|
|
13
14
|
theme?: Partial<ChartTheme>;
|
|
@@ -44,7 +45,8 @@ export declare abstract class BaseChart {
|
|
|
44
45
|
/**
|
|
45
46
|
* Renders the chart to the specified target element
|
|
46
47
|
*/
|
|
47
|
-
render(target: string): HTMLElement | null;
|
|
48
|
+
render(target: string | HTMLElement): HTMLElement | null;
|
|
49
|
+
private resolveContainer;
|
|
48
50
|
/**
|
|
49
51
|
* Performs the actual rendering logic
|
|
50
52
|
*/
|
|
@@ -79,15 +81,22 @@ export declare abstract class BaseChart {
|
|
|
79
81
|
protected parseValue(value: unknown): number;
|
|
80
82
|
/**
|
|
81
83
|
* Exports the chart in the specified format
|
|
82
|
-
* @param format - The export format
|
|
84
|
+
* @param format - The export format
|
|
83
85
|
* @param options - Optional export options (download, filename)
|
|
84
|
-
* @returns The exported content
|
|
86
|
+
* @returns The exported content when download is false/undefined, void if download is true
|
|
85
87
|
*/
|
|
86
|
-
export(format: ExportFormat, options?: ExportOptions): string | void
|
|
88
|
+
export(format: ExportFormat, options?: ExportOptions): Promise<string | Blob | void>;
|
|
87
89
|
/**
|
|
88
90
|
* Downloads the exported content as a file
|
|
89
91
|
*/
|
|
90
92
|
private downloadContent;
|
|
91
|
-
|
|
93
|
+
private getMimeType;
|
|
94
|
+
private exportCSV;
|
|
95
|
+
private exportSize;
|
|
96
|
+
private exportXLSX;
|
|
97
|
+
private exportImage;
|
|
98
|
+
private exportPDF;
|
|
99
|
+
protected exportSVG(options?: ExportOptions, formatForHooks?: VisualExportFormat): string;
|
|
92
100
|
protected exportJSON(): string;
|
|
93
101
|
}
|
|
102
|
+
export {};
|
package/base-chart.js
CHANGED
|
@@ -2,6 +2,10 @@ import { create } from 'd3';
|
|
|
2
2
|
import { defaultTheme } from './theme.js';
|
|
3
3
|
import { ChartValidator } from './validation.js';
|
|
4
4
|
import { LayoutManager } from './layout-manager.js';
|
|
5
|
+
import { serializeCSV } from './export-tabular.js';
|
|
6
|
+
import { exportRasterBlob } from './export-image.js';
|
|
7
|
+
import { exportXLSXBlob } from './export-xlsx.js';
|
|
8
|
+
import { exportPDFBlob } from './export-pdf.js';
|
|
5
9
|
/**
|
|
6
10
|
* Base chart class that provides common functionality for all chart types
|
|
7
11
|
*/
|
|
@@ -134,10 +138,7 @@ export class BaseChart {
|
|
|
134
138
|
* Renders the chart to the specified target element
|
|
135
139
|
*/
|
|
136
140
|
render(target) {
|
|
137
|
-
const container =
|
|
138
|
-
if (!container) {
|
|
139
|
-
throw new Error(`Container "${target}" not found`);
|
|
140
|
-
}
|
|
141
|
+
const container = this.resolveContainer(target);
|
|
141
142
|
this.container = container;
|
|
142
143
|
container.innerHTML = '';
|
|
143
144
|
// Perform initial render
|
|
@@ -146,6 +147,19 @@ export class BaseChart {
|
|
|
146
147
|
this.setupResizeObserver();
|
|
147
148
|
return container;
|
|
148
149
|
}
|
|
150
|
+
resolveContainer(target) {
|
|
151
|
+
if (target instanceof HTMLElement) {
|
|
152
|
+
return target;
|
|
153
|
+
}
|
|
154
|
+
const container = document.querySelector(target);
|
|
155
|
+
if (!container) {
|
|
156
|
+
throw new Error(`Container "${target}" not found`);
|
|
157
|
+
}
|
|
158
|
+
if (!(container instanceof HTMLElement)) {
|
|
159
|
+
throw new Error(`Container "${target}" is not an HTMLElement`);
|
|
160
|
+
}
|
|
161
|
+
return container;
|
|
162
|
+
}
|
|
149
163
|
/**
|
|
150
164
|
* Performs the actual rendering logic
|
|
151
165
|
*/
|
|
@@ -267,7 +281,6 @@ export class BaseChart {
|
|
|
267
281
|
return svg;
|
|
268
282
|
}
|
|
269
283
|
// Hook for subclasses to update component layout estimates before layout calc
|
|
270
|
-
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
271
284
|
prepareLayout() { }
|
|
272
285
|
/**
|
|
273
286
|
* Setup ResizeObserver for automatic resize handling
|
|
@@ -321,12 +334,30 @@ export class BaseChart {
|
|
|
321
334
|
}
|
|
322
335
|
/**
|
|
323
336
|
* Exports the chart in the specified format
|
|
324
|
-
* @param format - The export format
|
|
337
|
+
* @param format - The export format
|
|
325
338
|
* @param options - Optional export options (download, filename)
|
|
326
|
-
* @returns The exported content
|
|
339
|
+
* @returns The exported content when download is false/undefined, void if download is true
|
|
327
340
|
*/
|
|
328
|
-
export(format, options) {
|
|
329
|
-
|
|
341
|
+
async export(format, options) {
|
|
342
|
+
let content;
|
|
343
|
+
if (format === 'svg') {
|
|
344
|
+
content = this.exportSVG(options, 'svg');
|
|
345
|
+
}
|
|
346
|
+
else if (format === 'json') {
|
|
347
|
+
content = this.exportJSON();
|
|
348
|
+
}
|
|
349
|
+
else if (format === 'csv') {
|
|
350
|
+
content = this.exportCSV(options);
|
|
351
|
+
}
|
|
352
|
+
else if (format === 'xlsx') {
|
|
353
|
+
content = await this.exportXLSX(options);
|
|
354
|
+
}
|
|
355
|
+
else if (format === 'png' || format === 'jpg') {
|
|
356
|
+
content = await this.exportImage(format, options);
|
|
357
|
+
}
|
|
358
|
+
else {
|
|
359
|
+
content = await this.exportPDF(options);
|
|
360
|
+
}
|
|
330
361
|
if (options?.download) {
|
|
331
362
|
this.downloadContent(content, format, options);
|
|
332
363
|
return;
|
|
@@ -337,18 +368,84 @@ export class BaseChart {
|
|
|
337
368
|
* Downloads the exported content as a file
|
|
338
369
|
*/
|
|
339
370
|
downloadContent(content, format, options) {
|
|
340
|
-
const mimeType = format
|
|
341
|
-
const blob = new Blob([content], { type: mimeType });
|
|
371
|
+
const mimeType = this.getMimeType(format);
|
|
372
|
+
const blob = content instanceof Blob ? content : new Blob([content], { type: mimeType });
|
|
373
|
+
const filename = options.filename || `chart.${format}`;
|
|
342
374
|
const url = URL.createObjectURL(blob);
|
|
343
375
|
const link = document.createElement('a');
|
|
344
376
|
link.href = url;
|
|
345
|
-
link.download =
|
|
377
|
+
link.download = filename;
|
|
346
378
|
document.body.appendChild(link);
|
|
347
379
|
link.click();
|
|
348
380
|
document.body.removeChild(link);
|
|
349
381
|
URL.revokeObjectURL(url);
|
|
350
382
|
}
|
|
351
|
-
|
|
383
|
+
getMimeType(format) {
|
|
384
|
+
if (format === 'svg') {
|
|
385
|
+
return 'image/svg+xml';
|
|
386
|
+
}
|
|
387
|
+
if (format === 'json') {
|
|
388
|
+
return 'application/json';
|
|
389
|
+
}
|
|
390
|
+
if (format === 'csv') {
|
|
391
|
+
return 'text/csv;charset=utf-8';
|
|
392
|
+
}
|
|
393
|
+
if (format === 'xlsx') {
|
|
394
|
+
return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
|
395
|
+
}
|
|
396
|
+
if (format === 'png') {
|
|
397
|
+
return 'image/png';
|
|
398
|
+
}
|
|
399
|
+
if (format === 'jpg') {
|
|
400
|
+
return 'image/jpeg';
|
|
401
|
+
}
|
|
402
|
+
return 'application/pdf';
|
|
403
|
+
}
|
|
404
|
+
exportCSV(options) {
|
|
405
|
+
return serializeCSV(this.data, options);
|
|
406
|
+
}
|
|
407
|
+
exportSize(options) {
|
|
408
|
+
return {
|
|
409
|
+
width: options?.width ?? this.width,
|
|
410
|
+
height: options?.height ?? this.height,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
async exportXLSX(options) {
|
|
414
|
+
return exportXLSXBlob(this.data, options);
|
|
415
|
+
}
|
|
416
|
+
async exportImage(format, options) {
|
|
417
|
+
const { width, height } = this.exportSize(options);
|
|
418
|
+
const svg = this.exportSVG(options, format);
|
|
419
|
+
const backgroundColor = options?.backgroundColor ?? (format === 'jpg' ? '#ffffff' : undefined);
|
|
420
|
+
return exportRasterBlob({
|
|
421
|
+
format,
|
|
422
|
+
svg,
|
|
423
|
+
width,
|
|
424
|
+
height,
|
|
425
|
+
pixelRatio: options?.pixelRatio ?? 1,
|
|
426
|
+
backgroundColor,
|
|
427
|
+
jpegQuality: options?.jpegQuality ?? 0.92,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
async exportPDF(options) {
|
|
431
|
+
const { width, height } = this.exportSize(options);
|
|
432
|
+
const svg = this.exportSVG(options, 'pdf');
|
|
433
|
+
const pngBlob = await exportRasterBlob({
|
|
434
|
+
format: 'png',
|
|
435
|
+
svg,
|
|
436
|
+
width,
|
|
437
|
+
height,
|
|
438
|
+
pixelRatio: options?.pixelRatio ?? 1,
|
|
439
|
+
backgroundColor: options?.backgroundColor ?? '#ffffff',
|
|
440
|
+
jpegQuality: options?.jpegQuality ?? 0.92,
|
|
441
|
+
});
|
|
442
|
+
return exportPDFBlob(pngBlob, {
|
|
443
|
+
width,
|
|
444
|
+
height,
|
|
445
|
+
margin: options?.pdfMargin ?? 0,
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
exportSVG(options, formatForHooks = 'svg') {
|
|
352
449
|
if (!this.svg) {
|
|
353
450
|
throw new Error('Chart must be rendered before export');
|
|
354
451
|
}
|
|
@@ -360,7 +457,7 @@ export class BaseChart {
|
|
|
360
457
|
clone.setAttribute('width', String(exportWidth));
|
|
361
458
|
clone.setAttribute('height', String(exportHeight));
|
|
362
459
|
const baseContext = {
|
|
363
|
-
format:
|
|
460
|
+
format: formatForHooks,
|
|
364
461
|
options,
|
|
365
462
|
width: exportWidth,
|
|
366
463
|
height: exportHeight,
|
package/donut-chart.js
CHANGED
|
@@ -157,6 +157,9 @@ export class DonutChart extends BaseChart {
|
|
|
157
157
|
const cy = this.plotArea.top + this.plotArea.height / 2;
|
|
158
158
|
const outerRadius = Math.min(this.plotArea.width, this.plotArea.height) / 2;
|
|
159
159
|
const innerRadius = outerRadius * this.innerRadiusRatio;
|
|
160
|
+
if (this.tooltip) {
|
|
161
|
+
this.tooltip.initialize(this.theme);
|
|
162
|
+
}
|
|
160
163
|
this.renderSegments(visibleSegments, cx, cy, innerRadius, outerRadius);
|
|
161
164
|
if (this.centerContent) {
|
|
162
165
|
this.centerContent.render(this.svg, cx, cy, this.theme);
|
|
@@ -169,9 +172,6 @@ export class DonutChart extends BaseChart {
|
|
|
169
172
|
}));
|
|
170
173
|
this.legend.render(this.svg, legendSeries, this.theme, this.width, pos.x, pos.y);
|
|
171
174
|
}
|
|
172
|
-
if (this.tooltip) {
|
|
173
|
-
this.tooltip.initialize(this.theme);
|
|
174
|
-
}
|
|
175
175
|
}
|
|
176
176
|
positionTooltip(event, tooltipDiv) {
|
|
177
177
|
const node = tooltipDiv.node();
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
type RasterFormat = 'png' | 'jpg';
|
|
2
|
+
export type RasterExportOptions = {
|
|
3
|
+
format: RasterFormat;
|
|
4
|
+
svg: string;
|
|
5
|
+
width: number;
|
|
6
|
+
height: number;
|
|
7
|
+
pixelRatio: number;
|
|
8
|
+
backgroundColor?: string;
|
|
9
|
+
jpegQuality: number;
|
|
10
|
+
};
|
|
11
|
+
export declare function exportRasterBlob(options: RasterExportOptions): Promise<Blob>;
|
|
12
|
+
export {};
|
package/export-image.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
function loadImage(source) {
|
|
2
|
+
return new Promise((resolve, reject) => {
|
|
3
|
+
const image = new Image();
|
|
4
|
+
image.onload = () => resolve(image);
|
|
5
|
+
image.onerror = () => reject(new Error('Failed to load SVG for raster export'));
|
|
6
|
+
image.src = source;
|
|
7
|
+
});
|
|
8
|
+
}
|
|
9
|
+
function canvasToBlob(canvas, mimeType, quality) {
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
canvas.toBlob((blob) => {
|
|
12
|
+
if (!blob) {
|
|
13
|
+
reject(new Error('Failed to generate image export'));
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
resolve(blob);
|
|
17
|
+
}, mimeType, quality);
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
export async function exportRasterBlob(options) {
|
|
21
|
+
const { svg, width, height, format, jpegQuality } = options;
|
|
22
|
+
const pixelRatio = Math.max(1, options.pixelRatio);
|
|
23
|
+
const objectUrl = URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml;charset=utf-8' }));
|
|
24
|
+
try {
|
|
25
|
+
const image = await loadImage(objectUrl);
|
|
26
|
+
const canvas = document.createElement('canvas');
|
|
27
|
+
const scaledWidth = Math.max(1, Math.round(width * pixelRatio));
|
|
28
|
+
const scaledHeight = Math.max(1, Math.round(height * pixelRatio));
|
|
29
|
+
canvas.width = scaledWidth;
|
|
30
|
+
canvas.height = scaledHeight;
|
|
31
|
+
const context = canvas.getContext('2d');
|
|
32
|
+
if (!context) {
|
|
33
|
+
throw new Error('Failed to get 2D context for image export');
|
|
34
|
+
}
|
|
35
|
+
if (options.backgroundColor) {
|
|
36
|
+
context.fillStyle = options.backgroundColor;
|
|
37
|
+
context.fillRect(0, 0, scaledWidth, scaledHeight);
|
|
38
|
+
}
|
|
39
|
+
context.drawImage(image, 0, 0, scaledWidth, scaledHeight);
|
|
40
|
+
if (format === 'png') {
|
|
41
|
+
return canvasToBlob(canvas, 'image/png');
|
|
42
|
+
}
|
|
43
|
+
return canvasToBlob(canvas, 'image/jpeg', jpegQuality);
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
URL.revokeObjectURL(objectUrl);
|
|
47
|
+
}
|
|
48
|
+
}
|
package/export-pdf.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
type JsPdfLoader = () => Promise<unknown>;
|
|
2
|
+
export type PDFExportOptions = {
|
|
3
|
+
width: number;
|
|
4
|
+
height: number;
|
|
5
|
+
margin: number;
|
|
6
|
+
};
|
|
7
|
+
export declare function exportPDFBlob(imageBlob: Blob, options: PDFExportOptions, loader?: JsPdfLoader): Promise<Blob>;
|
|
8
|
+
export {};
|
package/export-pdf.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
function isObject(value) {
|
|
2
|
+
return typeof value === 'object' && value !== null;
|
|
3
|
+
}
|
|
4
|
+
function resolveJsPdfConstructor(moduleValue) {
|
|
5
|
+
if (!isObject(moduleValue)) {
|
|
6
|
+
throw new Error('Invalid "jspdf" module export');
|
|
7
|
+
}
|
|
8
|
+
const moduleCandidate = moduleValue;
|
|
9
|
+
const defaultCandidate = moduleCandidate.default;
|
|
10
|
+
let ctor = moduleCandidate.jsPDF;
|
|
11
|
+
if (!ctor && isObject(defaultCandidate)) {
|
|
12
|
+
ctor = defaultCandidate.jsPDF;
|
|
13
|
+
}
|
|
14
|
+
if (!ctor && typeof defaultCandidate === 'function') {
|
|
15
|
+
ctor = defaultCandidate;
|
|
16
|
+
}
|
|
17
|
+
if (typeof ctor !== 'function') {
|
|
18
|
+
throw new Error('Invalid "jspdf" module export');
|
|
19
|
+
}
|
|
20
|
+
return ctor;
|
|
21
|
+
}
|
|
22
|
+
async function defaultJsPdfLoader() {
|
|
23
|
+
return import('jspdf');
|
|
24
|
+
}
|
|
25
|
+
function blobToDataUrl(blob) {
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
const reader = new FileReader();
|
|
28
|
+
reader.onload = () => resolve(String(reader.result));
|
|
29
|
+
reader.onerror = () => reject(new Error('Failed to create PDF image data'));
|
|
30
|
+
reader.readAsDataURL(blob);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
export async function exportPDFBlob(imageBlob, options, loader = defaultJsPdfLoader) {
|
|
34
|
+
let jsPdfModule;
|
|
35
|
+
try {
|
|
36
|
+
jsPdfModule = await loader();
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
throw new Error('PDF export requires optional dependency "jspdf". Install optional dependency "jspdf".');
|
|
40
|
+
}
|
|
41
|
+
const JsPdfCtor = resolveJsPdfConstructor(jsPdfModule);
|
|
42
|
+
const margin = Math.max(0, options.margin);
|
|
43
|
+
const pageWidth = Math.max(1, options.width + margin * 2);
|
|
44
|
+
const pageHeight = Math.max(1, options.height + margin * 2);
|
|
45
|
+
const orientation = pageWidth >= pageHeight ? 'landscape' : 'portrait';
|
|
46
|
+
const pdf = new JsPdfCtor({
|
|
47
|
+
unit: 'px',
|
|
48
|
+
format: [pageWidth, pageHeight],
|
|
49
|
+
orientation,
|
|
50
|
+
hotfixes: ['px_scaling'],
|
|
51
|
+
});
|
|
52
|
+
const actualPageWidth = pdf.internal.pageSize.getWidth();
|
|
53
|
+
const actualPageHeight = pdf.internal.pageSize.getHeight();
|
|
54
|
+
const availableWidth = actualPageWidth - margin * 2;
|
|
55
|
+
const availableHeight = actualPageHeight - margin * 2;
|
|
56
|
+
if (availableWidth <= 0 || availableHeight <= 0) {
|
|
57
|
+
throw new Error('Invalid PDF margin: no drawable area remains');
|
|
58
|
+
}
|
|
59
|
+
const imageDataUrl = await blobToDataUrl(imageBlob);
|
|
60
|
+
const scale = Math.min(availableWidth / options.width, availableHeight / options.height);
|
|
61
|
+
const drawWidth = options.width * scale;
|
|
62
|
+
const drawHeight = options.height * scale;
|
|
63
|
+
const x = (actualPageWidth - drawWidth) / 2;
|
|
64
|
+
const y = (actualPageHeight - drawHeight) / 2;
|
|
65
|
+
pdf.addImage(imageDataUrl, 'PNG', x, y, drawWidth, drawHeight);
|
|
66
|
+
return pdf.output('blob');
|
|
67
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { DataItem, ExportOptions } from './types.js';
|
|
2
|
+
type TabularData = {
|
|
3
|
+
columns: string[];
|
|
4
|
+
rows: string[][];
|
|
5
|
+
};
|
|
6
|
+
export declare function resolveTableColumns(data: DataItem[], requestedColumns?: string[]): string[];
|
|
7
|
+
export declare function toTabularData(data: DataItem[], requestedColumns?: string[]): TabularData;
|
|
8
|
+
export declare function serializeCSV(data: DataItem[], options?: ExportOptions): string;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
function normalizeTableValue(value) {
|
|
2
|
+
if (value === null || value === undefined) {
|
|
3
|
+
return '';
|
|
4
|
+
}
|
|
5
|
+
if (value instanceof Date) {
|
|
6
|
+
return value.toISOString();
|
|
7
|
+
}
|
|
8
|
+
if (typeof value === 'object') {
|
|
9
|
+
try {
|
|
10
|
+
return JSON.stringify(value);
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return String(value);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return String(value);
|
|
17
|
+
}
|
|
18
|
+
function escapeCSVValue(value, delimiter) {
|
|
19
|
+
const escaped = value.replace(/"/g, '""');
|
|
20
|
+
const needsQuotes = escaped.includes('"') ||
|
|
21
|
+
escaped.includes(delimiter) ||
|
|
22
|
+
escaped.includes('\n') ||
|
|
23
|
+
escaped.includes('\r');
|
|
24
|
+
if (!needsQuotes) {
|
|
25
|
+
return escaped;
|
|
26
|
+
}
|
|
27
|
+
return `"${escaped}"`;
|
|
28
|
+
}
|
|
29
|
+
export function resolveTableColumns(data, requestedColumns) {
|
|
30
|
+
if (requestedColumns && requestedColumns.length > 0) {
|
|
31
|
+
return requestedColumns;
|
|
32
|
+
}
|
|
33
|
+
const columns = [];
|
|
34
|
+
const seen = new Set();
|
|
35
|
+
data.forEach((item) => {
|
|
36
|
+
Object.keys(item).forEach((key) => {
|
|
37
|
+
if (!seen.has(key)) {
|
|
38
|
+
seen.add(key);
|
|
39
|
+
columns.push(key);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
return columns;
|
|
44
|
+
}
|
|
45
|
+
export function toTabularData(data, requestedColumns) {
|
|
46
|
+
const columns = resolveTableColumns(data, requestedColumns);
|
|
47
|
+
const rows = data.map((item) => columns.map((column) => normalizeTableValue(item[column])));
|
|
48
|
+
return {
|
|
49
|
+
columns,
|
|
50
|
+
rows,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export function serializeCSV(data, options) {
|
|
54
|
+
const delimiter = options?.delimiter ?? ',';
|
|
55
|
+
const { columns, rows } = toTabularData(data, options?.columns);
|
|
56
|
+
const serializedRows = [
|
|
57
|
+
columns.map((column) => escapeCSVValue(column, delimiter)).join(delimiter),
|
|
58
|
+
...rows.map((row) => row.map((value) => escapeCSVValue(value, delimiter)).join(delimiter)),
|
|
59
|
+
];
|
|
60
|
+
return serializedRows.join('\n');
|
|
61
|
+
}
|
package/export-xlsx.d.ts
ADDED
package/export-xlsx.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { toTabularData } from './export-tabular.js';
|
|
2
|
+
const XLSX_MIME_TYPE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
|
3
|
+
function isObject(value) {
|
|
4
|
+
return typeof value === 'object' && value !== null;
|
|
5
|
+
}
|
|
6
|
+
function resolveXlsxApi(moduleValue) {
|
|
7
|
+
if (!isObject(moduleValue)) {
|
|
8
|
+
throw new Error('Invalid "xlsx" module export');
|
|
9
|
+
}
|
|
10
|
+
const moduleCandidate = moduleValue;
|
|
11
|
+
const xlsxCandidate = moduleCandidate.default && isObject(moduleCandidate.default)
|
|
12
|
+
? moduleCandidate.default
|
|
13
|
+
: moduleCandidate;
|
|
14
|
+
if (!xlsxCandidate.utils || !xlsxCandidate.write) {
|
|
15
|
+
throw new Error('Invalid "xlsx" module export');
|
|
16
|
+
}
|
|
17
|
+
return {
|
|
18
|
+
utils: xlsxCandidate.utils,
|
|
19
|
+
write: xlsxCandidate.write,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
async function defaultXlsxLoader() {
|
|
23
|
+
return import('xlsx');
|
|
24
|
+
}
|
|
25
|
+
export async function exportXLSXBlob(data, options, loader = defaultXlsxLoader) {
|
|
26
|
+
let xlsxModule;
|
|
27
|
+
try {
|
|
28
|
+
xlsxModule = await loader();
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
throw new Error('XLSX export requires optional dependency "xlsx". Install optional dependency "xlsx".');
|
|
32
|
+
}
|
|
33
|
+
const xlsx = resolveXlsxApi(xlsxModule);
|
|
34
|
+
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
|
+
const sheetName = options?.sheetName?.trim() || 'Sheet1';
|
|
38
|
+
xlsx.utils.book_append_sheet(workbook, worksheet, sheetName);
|
|
39
|
+
const buffer = xlsx.write(workbook, {
|
|
40
|
+
type: 'array',
|
|
41
|
+
bookType: 'xlsx',
|
|
42
|
+
});
|
|
43
|
+
return new Blob([buffer], { type: XLSX_MIME_TYPE });
|
|
44
|
+
}
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.4.
|
|
2
|
+
"version": "0.4.3",
|
|
3
3
|
"name": "@internetstiftelsen/charts",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
@@ -42,16 +42,20 @@
|
|
|
42
42
|
"tailwind-merge": "^3.4.0",
|
|
43
43
|
"tailwindcss": "^4.1.18"
|
|
44
44
|
},
|
|
45
|
+
"optionalDependencies": {
|
|
46
|
+
"jspdf": "^4.1.0",
|
|
47
|
+
"xlsx": "^0.18.5"
|
|
48
|
+
},
|
|
45
49
|
"devDependencies": {
|
|
46
50
|
"@eslint/js": "^9.39.2",
|
|
47
51
|
"@tailwindcss/vite": "^4.1.18",
|
|
48
52
|
"@testing-library/dom": "^10.4.1",
|
|
49
53
|
"@testing-library/jest-dom": "^6.9.1",
|
|
50
54
|
"@testing-library/react": "^16.3.2",
|
|
51
|
-
"@types/node": "^24.10.
|
|
52
|
-
"@types/react": "^19.2.
|
|
55
|
+
"@types/node": "^24.10.13",
|
|
56
|
+
"@types/react": "^19.2.14",
|
|
53
57
|
"@types/react-dom": "^19.2.3",
|
|
54
|
-
"@vitejs/plugin-react-swc": "^4.2.
|
|
58
|
+
"@vitejs/plugin-react-swc": "^4.2.3",
|
|
55
59
|
"eslint": "^9.39.2",
|
|
56
60
|
"eslint-plugin-react-hooks": "^7.0.1",
|
|
57
61
|
"eslint-plugin-react-refresh": "^0.4.26",
|
|
@@ -61,7 +65,7 @@
|
|
|
61
65
|
"tsc-alias": "^1.8.16",
|
|
62
66
|
"tw-animate-css": "^1.4.0",
|
|
63
67
|
"typescript": "~5.9.3",
|
|
64
|
-
"typescript-eslint": "^8.
|
|
68
|
+
"typescript-eslint": "^8.55.0",
|
|
65
69
|
"vite": "^7.3.1",
|
|
66
70
|
"vitest": "^4.0.18"
|
|
67
71
|
}
|
package/types.d.ts
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
export type DataValue = string | number | boolean | Date | null | undefined;
|
|
2
2
|
export type DataItem = Record<string, any>;
|
|
3
|
-
export type ExportFormat = 'svg' | 'json';
|
|
3
|
+
export type ExportFormat = 'svg' | 'json' | 'csv' | 'xlsx' | 'png' | 'jpg' | 'pdf';
|
|
4
4
|
export type ExportOptions = {
|
|
5
5
|
download?: boolean;
|
|
6
6
|
filename?: string;
|
|
7
7
|
width?: number;
|
|
8
8
|
height?: number;
|
|
9
|
+
columns?: string[];
|
|
10
|
+
delimiter?: string;
|
|
11
|
+
jpegQuality?: number;
|
|
12
|
+
pixelRatio?: number;
|
|
13
|
+
backgroundColor?: string;
|
|
14
|
+
sheetName?: string;
|
|
15
|
+
pdfMargin?: number;
|
|
9
16
|
};
|
|
10
17
|
export type ExportRenderContext = {
|
|
11
18
|
format: ExportFormat;
|