@internetstiftelsen/charts 0.4.0 → 0.4.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 +14 -0
- package/bar.js +19 -7
- package/base-chart.d.ts +14 -5
- package/base-chart.js +111 -14
- 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/legend.d.ts +0 -1
- package/legend.js +2 -31
- package/package.json +9 -5
- package/types.d.ts +8 -1
- package/utils.d.ts +1 -0
- package/utils.js +38 -0
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/bar.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { sanitizeForCSS, mergeDeep } from './utils.js';
|
|
1
|
+
import { getContrastTextColor, sanitizeForCSS, mergeDeep } from './utils.js';
|
|
2
2
|
export class Bar {
|
|
3
3
|
constructor(config) {
|
|
4
4
|
Object.defineProperty(this, "type", {
|
|
@@ -362,7 +362,7 @@ export class Bar {
|
|
|
362
362
|
const fontSize = config.fontSize ?? theme.valueLabel.fontSize;
|
|
363
363
|
const fontFamily = config.fontFamily ?? theme.valueLabel.fontFamily;
|
|
364
364
|
const fontWeight = config.fontWeight ?? theme.valueLabel.fontWeight;
|
|
365
|
-
const
|
|
365
|
+
const defaultLabelColor = config.color ?? theme.valueLabel.color;
|
|
366
366
|
const background = config.background ?? theme.valueLabel.background;
|
|
367
367
|
const border = config.border ?? theme.valueLabel.border;
|
|
368
368
|
const borderRadius = config.borderRadius ?? theme.valueLabel.borderRadius;
|
|
@@ -370,11 +370,14 @@ export class Bar {
|
|
|
370
370
|
const labelGroup = plotGroup
|
|
371
371
|
.append('g')
|
|
372
372
|
.attr('class', `bar-value-labels-${sanitizeForCSS(this.dataKey)}`);
|
|
373
|
-
data.forEach((d) => {
|
|
373
|
+
data.forEach((d, i) => {
|
|
374
374
|
const categoryKey = String(d[xKey]);
|
|
375
375
|
const value = parseValue(d[this.dataKey]);
|
|
376
376
|
const valueText = String(value);
|
|
377
377
|
const xPos = this.getScaledPosition(d, xKey, x, xScaleType);
|
|
378
|
+
const barColor = this.colorAdapter
|
|
379
|
+
? this.colorAdapter(d, i)
|
|
380
|
+
: this.fill;
|
|
378
381
|
// Calculate bar position based on stacking mode
|
|
379
382
|
let barTop;
|
|
380
383
|
let barBottom;
|
|
@@ -497,6 +500,9 @@ export class Bar {
|
|
|
497
500
|
}
|
|
498
501
|
tempText.remove();
|
|
499
502
|
if (shouldRender) {
|
|
503
|
+
const labelColor = position === 'inside' && config.color === undefined
|
|
504
|
+
? getContrastTextColor(barColor)
|
|
505
|
+
: defaultLabelColor;
|
|
500
506
|
const group = labelGroup.append('g');
|
|
501
507
|
if (position === 'outside') {
|
|
502
508
|
// Draw rounded rectangle background
|
|
@@ -522,7 +528,7 @@ export class Bar {
|
|
|
522
528
|
.style('font-size', `${fontSize}px`)
|
|
523
529
|
.style('font-family', fontFamily)
|
|
524
530
|
.style('font-weight', fontWeight)
|
|
525
|
-
.style('fill',
|
|
531
|
+
.style('fill', labelColor)
|
|
526
532
|
.style('pointer-events', 'none')
|
|
527
533
|
.text(valueText);
|
|
528
534
|
}
|
|
@@ -574,7 +580,7 @@ export class Bar {
|
|
|
574
580
|
const fontSize = config.fontSize ?? theme.valueLabel.fontSize;
|
|
575
581
|
const fontFamily = config.fontFamily ?? theme.valueLabel.fontFamily;
|
|
576
582
|
const fontWeight = config.fontWeight ?? theme.valueLabel.fontWeight;
|
|
577
|
-
const
|
|
583
|
+
const defaultLabelColor = config.color ?? theme.valueLabel.color;
|
|
578
584
|
const background = config.background ?? theme.valueLabel.background;
|
|
579
585
|
const border = config.border ?? theme.valueLabel.border;
|
|
580
586
|
const borderRadius = config.borderRadius ?? theme.valueLabel.borderRadius;
|
|
@@ -582,11 +588,14 @@ export class Bar {
|
|
|
582
588
|
const labelGroup = plotGroup
|
|
583
589
|
.append('g')
|
|
584
590
|
.attr('class', `bar-value-labels-${sanitizeForCSS(this.dataKey)}`);
|
|
585
|
-
data.forEach((d) => {
|
|
591
|
+
data.forEach((d, i) => {
|
|
586
592
|
const categoryKey = String(d[xKey]);
|
|
587
593
|
const value = parseValue(d[this.dataKey]);
|
|
588
594
|
const valueText = String(value);
|
|
589
595
|
const yPos = this.getScaledPosition(d, xKey, y, yScaleType);
|
|
596
|
+
const barColor = this.colorAdapter
|
|
597
|
+
? this.colorAdapter(d, i)
|
|
598
|
+
: this.fill;
|
|
590
599
|
// Calculate bar position based on stacking mode
|
|
591
600
|
let barLeft;
|
|
592
601
|
let barRight;
|
|
@@ -711,6 +720,9 @@ export class Bar {
|
|
|
711
720
|
}
|
|
712
721
|
tempText.remove();
|
|
713
722
|
if (shouldRender) {
|
|
723
|
+
const labelColor = position === 'inside' && config.color === undefined
|
|
724
|
+
? getContrastTextColor(barColor)
|
|
725
|
+
: defaultLabelColor;
|
|
714
726
|
const group = labelGroup.append('g');
|
|
715
727
|
if (position === 'outside') {
|
|
716
728
|
// Draw rounded rectangle background
|
|
@@ -736,7 +748,7 @@ export class Bar {
|
|
|
736
748
|
.style('font-size', `${fontSize}px`)
|
|
737
749
|
.style('font-family', fontFamily)
|
|
738
750
|
.style('font-weight', fontWeight)
|
|
739
|
-
.style('fill',
|
|
751
|
+
.style('fill', labelColor)
|
|
740
752
|
.style('pointer-events', 'none')
|
|
741
753
|
.text(valueText);
|
|
742
754
|
}
|
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,
|
|
@@ -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/legend.d.ts
CHANGED
|
@@ -16,7 +16,6 @@ export declare class Legend implements LayoutAwareComponent<LegendConfigBase> {
|
|
|
16
16
|
setToggleCallback(callback: () => void): void;
|
|
17
17
|
isSeriesVisible(dataKey: string): boolean;
|
|
18
18
|
private getCheckmarkPath;
|
|
19
|
-
private parseColor;
|
|
20
19
|
/**
|
|
21
20
|
* Returns the space required by the legend
|
|
22
21
|
*/
|
package/legend.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { getSeriesColor } from './types.js';
|
|
2
|
-
import { mergeDeep } from './utils.js';
|
|
2
|
+
import { getContrastTextColor, mergeDeep } from './utils.js';
|
|
3
3
|
export class Legend {
|
|
4
4
|
constructor(config) {
|
|
5
5
|
Object.defineProperty(this, "type", {
|
|
@@ -83,29 +83,6 @@ export class Legend {
|
|
|
83
83
|
const offsetY = size * 0.15;
|
|
84
84
|
return `M ${4 * scale + offsetX} ${12 * scale + offsetY} L ${9 * scale + offsetX} ${17 * scale + offsetY} L ${20 * scale + offsetX} ${6 * scale + offsetY}`;
|
|
85
85
|
}
|
|
86
|
-
parseColor(color) {
|
|
87
|
-
// Handle hex colors
|
|
88
|
-
if (color.startsWith('#')) {
|
|
89
|
-
const hex = color.slice(1);
|
|
90
|
-
const r = parseInt(hex.slice(0, 2), 16);
|
|
91
|
-
const g = parseInt(hex.slice(2, 4), 16);
|
|
92
|
-
const b = parseInt(hex.slice(4, 6), 16);
|
|
93
|
-
return { r, g, b };
|
|
94
|
-
}
|
|
95
|
-
// Handle rgb/rgba colors
|
|
96
|
-
if (color.startsWith('rgb')) {
|
|
97
|
-
const match = color.match(/\d+/g);
|
|
98
|
-
if (match) {
|
|
99
|
-
return {
|
|
100
|
-
r: parseInt(match[0]),
|
|
101
|
-
g: parseInt(match[1]),
|
|
102
|
-
b: parseInt(match[2]),
|
|
103
|
-
};
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
// Default to black if we can't parse
|
|
107
|
-
return { r: 0, g: 0, b: 0 };
|
|
108
|
-
}
|
|
109
86
|
/**
|
|
110
87
|
* Returns the space required by the legend
|
|
111
88
|
*/
|
|
@@ -185,13 +162,7 @@ export class Legend {
|
|
|
185
162
|
.append('path')
|
|
186
163
|
.attr('d', this.getCheckmarkPath(boxSize))
|
|
187
164
|
.attr('fill', 'none')
|
|
188
|
-
.attr('stroke', (d) =>
|
|
189
|
-
// Calculate luminance to determine if we need black or white checkmark
|
|
190
|
-
const color = d.color;
|
|
191
|
-
const rgb = this.parseColor(color);
|
|
192
|
-
const luminance = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255;
|
|
193
|
-
return luminance > 0.5 ? '#000' : '#fff';
|
|
194
|
-
})
|
|
165
|
+
.attr('stroke', (d) => getContrastTextColor(d.color))
|
|
195
166
|
.attr('stroke-width', 2)
|
|
196
167
|
.attr('stroke-linecap', 'round')
|
|
197
168
|
.attr('stroke-linejoin', 'round')
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.4.
|
|
2
|
+
"version": "0.4.2",
|
|
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;
|
package/utils.d.ts
CHANGED
|
@@ -60,4 +60,5 @@ export declare function breakWord(word: string, maxWidth: number, fontSize: stri
|
|
|
60
60
|
* @returns Array of lines
|
|
61
61
|
*/
|
|
62
62
|
export declare function wrapText(text: string, maxWidth: number, fontSize: string | number, fontFamily: string, fontWeight: string, svg: SVGSVGElement): string[];
|
|
63
|
+
export declare function getContrastTextColor(backgroundColor: string, lightTextColor?: string, darkTextColor?: string): string;
|
|
63
64
|
export declare function mergeDeep<T extends Record<string, unknown>>(base: T, override?: Partial<T>): T;
|
package/utils.js
CHANGED
|
@@ -168,6 +168,44 @@ export function wrapText(text, maxWidth, fontSize, fontFamily, fontWeight, svg)
|
|
|
168
168
|
}
|
|
169
169
|
return lines.length > 0 ? lines : [text];
|
|
170
170
|
}
|
|
171
|
+
function parseColorToRGB(color) {
|
|
172
|
+
const normalizedColor = color.trim();
|
|
173
|
+
if (normalizedColor.startsWith('#')) {
|
|
174
|
+
const hex = normalizedColor.slice(1);
|
|
175
|
+
if (hex.length === 3) {
|
|
176
|
+
const r = parseInt(hex[0] + hex[0], 16);
|
|
177
|
+
const g = parseInt(hex[1] + hex[1], 16);
|
|
178
|
+
const b = parseInt(hex[2] + hex[2], 16);
|
|
179
|
+
return { r, g, b };
|
|
180
|
+
}
|
|
181
|
+
if (hex.length === 6) {
|
|
182
|
+
const r = parseInt(hex.slice(0, 2), 16);
|
|
183
|
+
const g = parseInt(hex.slice(2, 4), 16);
|
|
184
|
+
const b = parseInt(hex.slice(4, 6), 16);
|
|
185
|
+
return { r, g, b };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (normalizedColor.startsWith('rgb')) {
|
|
189
|
+
const matches = normalizedColor.match(/\d+/g);
|
|
190
|
+
if (matches && matches.length >= 3) {
|
|
191
|
+
return {
|
|
192
|
+
r: parseInt(matches[0], 10),
|
|
193
|
+
g: parseInt(matches[1], 10),
|
|
194
|
+
b: parseInt(matches[2], 10),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// Fallback matches legend behavior: unknown colors are treated as dark.
|
|
199
|
+
return { r: 0, g: 0, b: 0 };
|
|
200
|
+
}
|
|
201
|
+
export function getContrastTextColor(backgroundColor, lightTextColor = '#fff', darkTextColor = '#000') {
|
|
202
|
+
const { r, g, b } = parseColorToRGB(backgroundColor);
|
|
203
|
+
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
|
204
|
+
if (luminance > 0.5) {
|
|
205
|
+
return darkTextColor;
|
|
206
|
+
}
|
|
207
|
+
return lightTextColor;
|
|
208
|
+
}
|
|
171
209
|
export function mergeDeep(base, override) {
|
|
172
210
|
if (!override) {
|
|
173
211
|
return { ...base };
|