Skip to content

Commit

Permalink
fix: SJIP-818 implement svg download for charts (Ferlab-Ste-Justine#436)
Browse files Browse the repository at this point in the history
  • Loading branch information
aperron-ferlab authored Apr 19, 2024
1 parent 773a26f commit 5b3a357
Show file tree
Hide file tree
Showing 6 changed files with 163 additions and 88 deletions.
3 changes: 3 additions & 0 deletions packages/ui/Release.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
### 9.14.2 2024-04-19
- fix: SJIP-818 implement svg download for charts

### 9.14.1 2024-04-17
- fix: SJIP-758 keep analyse modal open when copying file

Expand Down
4 changes: 2 additions & 2 deletions packages/ui/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/ui/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ferlab/ui",
"version": "9.14.1",
"version": "9.14.2",
"description": "Core components for scientific research data portals",
"publishConfig": {
"access": "public"
Expand Down
118 changes: 34 additions & 84 deletions packages/ui/src/layout/ResizableGridLayout/ResizableGridCard/index.tsx
Original file line number Diff line number Diff line change
@@ -1,35 +1,25 @@
import React, { useContext, useEffect, useState } from 'react';
import React, { ReactElement, useContext, useEffect, useState } from 'react';
import { CloseOutlined, DownloadOutlined } from '@ant-design/icons';
import { Button, Dropdown, Modal, Tooltip } from 'antd';
import d3ToPng from 'd3-svg-to-png';
import { format } from 'date-fns';
import { v4 } from 'uuid';

import { toKebabCase } from '../../../utils/stringUtils';
import { GridCardHeader } from '../../../view/v2/GridCard';
import GridCard, { TGridCard } from '../../../view/v2/GridCard/GridCard';
import { ResizableGridLayoutContext } from '..';

import styles from './index.module.scss';

type TDictionary = {
download?: {
fileNameTemplate?: string;
fileNameDateFormat?: string;
preview?: string;
download?: string;
data?: string;
svg?: string;
png?: string;
removeChart?: string;
};
};
import {
DownloadKey,
downloadToSvg,
DownloadType,
fileNameFormatter,
populateMenuItems,
TDictionary,
TDownloadSettings,
} from './utils';

type TDownloadSettings = {
tsv: boolean;
svg: boolean;
png: boolean;
};
import styles from './index.module.scss';

type TResizableGridCard = Omit<TGridCard, 'title' | 'resizable'> & {
gridUID: string;
Expand All @@ -55,17 +45,6 @@ type TResizableGridCard = Omit<TGridCard, 'title' | 'resizable'> & {
withHandle?: boolean;
};

enum DownloadKey {
png = 'png',
svg = 'svg',
tsv = 'tsv',
}

enum DownloadType {
data = 'data',
chart = 'chart',
}

const EXPORT_SETTINGS = {
background: 'white',
quality: 0.92,
Expand All @@ -79,48 +58,6 @@ const DEFAULT_TSV_CONTENT_MAP = ['label', 'value', 'frequency'];
const DEFAULT_FILENAME_TEMPLATE = '%name-$type-%date%extension';
const DEFAULT_FILENAME_DATE_FORMAT = 'yyyy-MM-dd';

const fileNameFormatter = (
fileNameTemplate: string,
type: DownloadType,
dateFormat: string,
name: string,
extension: string,
): string => {
const formattedDate = format(new Date(), dateFormat);
return fileNameTemplate
.replace('%name', name.toLowerCase().replace(/ /g, ''))
.replace('%type', type)
.replace('%date', formattedDate)
.replace('%extension', extension);
};

const populateMenuItems = (settings: TDownloadSettings, dictionary?: TDictionary) => {
const items = [];

if (settings.tsv) {
items.push({
key: DownloadKey.tsv,
label: dictionary?.download?.data ?? 'Download data',
});
}

if (settings.svg) {
items.push({
key: DownloadKey.svg,
label: dictionary?.download?.svg ?? 'Download SVG',
});
}

if (settings.png) {
items.push({
key: DownloadKey.png,
label: dictionary?.download?.png ?? 'Download PNG',
});
}

return items;
};

const ResizableGridCard = ({
closeHandle = true,
dictionary,
Expand All @@ -134,7 +71,7 @@ const ResizableGridCard = ({
tsvSettings,
withHandle = true,
...rest
}: TResizableGridCard): JSX.Element => {
}: TResizableGridCard): ReactElement => {
const context = useContext(ResizableGridLayoutContext);
const graphId = `graph-${v4()}`;
const [isModalVisible, setIsModalVisible] = useState(false);
Expand Down Expand Up @@ -187,15 +124,28 @@ const ResizableGridCard = ({

// d3ToPng block react's re-rendering. A timeout is need to force the download state to be set
setTimeout(() => {
document.querySelectorAll(`#${graphId} svg`).forEach((_, index) => {
// d3ToPng only works with string query, not element
d3ToPng(
`#${graphId} div:nth-child(${index + 1}) svg`,
fileNameFormatter(fileNameTemplate, DownloadType.chart, fileNameDateFormat, headerTitle, ''),
{ ...EXPORT_SETTINGS, format: action },
).then(() => {
setHasStartedDownload(false);
});
document.querySelectorAll(`#${graphId} svg`).forEach((svg, index) => {
const fileName = fileNameFormatter(
fileNameTemplate,
DownloadType.chart,
fileNameDateFormat,
headerTitle,
'',
);

if (action === DownloadKey.svg) {
downloadToSvg(fileName, svg).then(() => {
setHasStartedDownload(false);
});
} else {
// d3ToPng only works with string query, not element
d3ToPng(`#${graphId} div:nth-child(${index + 1}) svg`, fileName, {
...EXPORT_SETTINGS,
format: action,
}).then(() => {
setHasStartedDownload(false);
});
}
});
}, DOWNLOAD_DELAY);
}, [hasStartedDownload]);
Expand Down
122 changes: 122 additions & 0 deletions packages/ui/src/layout/ResizableGridLayout/ResizableGridCard/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { format } from 'date-fns';

export type TDictionary = {
download?: {
fileNameTemplate?: string;
fileNameDateFormat?: string;
preview?: string;
download?: string;
data?: string;
svg?: string;
png?: string;
removeChart?: string;
};
};

export type TDownloadSettings = {
tsv: boolean;
svg: boolean;
png: boolean;
};

export enum DownloadKey {
png = 'png',
svg = 'svg',
tsv = 'tsv',
}

export enum DownloadType {
data = 'data',
chart = 'chart',
}

export const fileNameFormatter = (
fileNameTemplate: string,
type: DownloadType,
dateFormat: string,
name: string,
extension: string,
): string => {
const formattedDate = format(new Date(), dateFormat);
return fileNameTemplate
.replace('%name', name.toLowerCase().replace(/ /g, ''))
.replace('%type', type)
.replace('%date', formattedDate)
.replace('%extension', extension);
};

/**
* Downloads an SVG element as an SVG file with the specified file name.
*
* @param {string} fileName - The name of the file to be downloaded.
* @param {Element} svg - The SVG element to be downloaded.
* @returns {Promise<void>} A promise that resolves once the download is complete.
*/
export const downloadToSvg = async (fileName: string, svg: Element) => {
// Serialize the SVG element to a string
const serializer = new XMLSerializer();
let source = serializer.serializeToString(svg);

// Ensure SVG element has necessary XML namespaces
if (!source.includes('xmlns="http://www.w3.org/2000/svg"')) {
source = source.replace('<svg', '<svg xmlns="http://www.w3.org/2000/svg"');
}
if (!source.includes('xmlns:xlink="http://www.w3.org/1999/xlink"')) {
source = source.replace('<svg', '<svg xmlns:xlink="http://www.w3.org/1999/xlink"');
}

// Add XML declaration and prepare SVG data URL
const svgWithXmlDeclaration = `<?xml version="1.0" standalone="no"?>\r\n${source}`;
const url = 'data:image/svg+xml;base64,' + btoa(svgWithXmlDeclaration);

// Create an anchor element for download
const a = document.createElement('a');
a.download = `${fileName}.svg`;
a.href = url;

// Create a promise that resolves when the download is complete
const downloadPromise = new Promise<void>((resolve) => {
a.addEventListener(
'click',
() => {
// Clean up after download
document.body.removeChild(a);
resolve();
},
{ once: true },
);
});

// Add the anchor element to the document body and trigger download
document.body.appendChild(a);
a.click();

return downloadPromise;
};

export const populateMenuItems = (settings: TDownloadSettings, dictionary?: TDictionary) => {
const items = [];

if (settings.tsv) {
items.push({
key: DownloadKey.tsv,
label: dictionary?.download?.data ?? 'Download data',
});
}

if (settings.svg) {
items.push({
key: DownloadKey.svg,
label: dictionary?.download?.svg ?? 'Download SVG',
});
}

if (settings.png) {
items.push({
key: DownloadKey.png,
label: dictionary?.download?.png ?? 'Download PNG',
});
}

return items;
};
2 changes: 1 addition & 1 deletion packages/ui/src/layout/ResizableGridLayout/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Check for futur fix for the mouse input lag
// TODO: https://github.com/react-grid-layout/react-grid-layout/issues/2003
import React, { createContext, memo, useState } from 'react';
import React, { createContext, useState } from 'react';
import { Layout, Layouts, Responsive as ResponsiveGridLayout, ResponsiveProps } from 'react-grid-layout';
import { Space, Spin } from 'antd';
import { Breakpoint } from 'antd/lib/_util/responsiveObserve';
Expand Down

0 comments on commit 5b3a357

Please sign in to comment.