@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 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 color = config.color ?? theme.valueLabel.color;
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', color)
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 color = config.color ?? theme.valueLabel.color;
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', color)
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 ('svg' or 'json')
84
+ * @param format - The export format
83
85
  * @param options - Optional export options (download, filename)
84
- * @returns The exported content as a string if download is false/undefined, void if download is true
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
- protected exportSVG(options?: ExportOptions): string;
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 = document.querySelector(target);
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 ('svg' or 'json')
337
+ * @param format - The export format
325
338
  * @param options - Optional export options (download, filename)
326
- * @returns The exported content as a string if download is false/undefined, void if download is true
339
+ * @returns The exported content when download is false/undefined, void if download is true
327
340
  */
328
- export(format, options) {
329
- const content = format === 'svg' ? this.exportSVG(options) : this.exportJSON();
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 === 'svg' ? 'image/svg+xml' : 'application/json';
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 = options.filename || `chart.${format}`;
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
- exportSVG(options) {
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: 'svg',
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 {};
@@ -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
+ }
@@ -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
+ }
@@ -0,0 +1,4 @@
1
+ import type { DataItem, ExportOptions } from './types.js';
2
+ type XlsxLoader = () => Promise<unknown>;
3
+ export declare function exportXLSXBlob(data: DataItem[], options?: ExportOptions, loader?: XlsxLoader): Promise<Blob>;
4
+ export {};
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.0",
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.9",
52
- "@types/react": "^19.2.10",
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.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.54.0",
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 };