Skip to content

[charts-pro] Export charts as image #17353

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 16 commits into from
Apr 24, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions docs/data/charts/export/ExportChartAsImage.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import * as React from 'react';
import Button from '@mui/material/Button';
import FormControl from '@mui/material/FormControl';
import FormControlLabel from '@mui/material/FormControlLabel';
import FormLabel from '@mui/material/FormLabel';
import Radio from '@mui/material/Radio';
import RadioGroup from '@mui/material/RadioGroup';
import Stack from '@mui/material/Stack';
import TextField from '@mui/material/TextField';
import { LineChartPro } from '@mui/x-charts-pro/LineChartPro';

function ExportParamsSelector({ apiRef }) {
const [type, setType] = React.useState('image/png');
const [rawQuality, setRawQuality] = React.useState('0.9');
const quality = Math.max(0, Math.min(1, Number.parseFloat(rawQuality)));

return (
<Stack
direction="row"
justifyContent="space-between"
flexWrap="wrap"
gap={2}
sx={{ width: '100%' }}
>
<FormControl fullWidth>
<FormLabel id="image-format-radio-buttons-group-label">
Image Format
</FormLabel>
<RadioGroup
row
aria-labelledby="image-format-radio-buttons-group-label"
name="image-format"
value={type}
onChange={(event) => setType(event.target.value)}
>
<FormControlLabel
value="image/png"
control={<Radio />}
label="image/png"
/>
<FormControlLabel
value="image/jpeg"
control={<Radio />}
label="image/jpeg"
/>
<FormControlLabel
value="image/webp"
control={<Radio />}
label="image/webp"
/>
</RadioGroup>
</FormControl>
<FormControl>
<TextField
label="Quality"
value={rawQuality}
onChange={(event) => setRawQuality(event.target.value)}
disabled={type === 'image/png'}
helperText="Only applicable to lossy formats."
/>
</FormControl>
<Button
variant="outlined"
onClick={() => apiRef.current?.exportAsImage({ type, quality })}
>
Export Image
</Button>
</Stack>
);
}

export default function ExportChartAsImage() {
const apiRef = React.useRef(undefined);

return (
<Stack width="100%" gap={2}>
<LineChartPro
apiRef={apiRef}
xAxis={[{ data: [1, 2, 3, 5, 8, 10] }]}
series={[
{ data: [4, 9, 1, 4, 9, 6], label: 'Series A' },
{
data: [2, 5.5, 2, 8.5, 1.5, 5],
area: true,
label: 'Series B',
},
]}
height={300}
grid={{ vertical: true, horizontal: true }}
/>
<ExportParamsSelector apiRef={apiRef} />
</Stack>
);
}
101 changes: 101 additions & 0 deletions docs/data/charts/export/ExportChartAsImage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import * as React from 'react';
import Button from '@mui/material/Button';
import FormControl from '@mui/material/FormControl';
import FormControlLabel from '@mui/material/FormControlLabel';
import FormLabel from '@mui/material/FormLabel';
import Radio from '@mui/material/Radio';
import RadioGroup from '@mui/material/RadioGroup';
import Stack from '@mui/material/Stack';
import TextField from '@mui/material/TextField';
import { LineChartPro } from '@mui/x-charts-pro/LineChartPro';
import { ChartProApi } from '@mui/x-charts-pro/ChartContainerPro';

function ExportParamsSelector({
apiRef,
}: {
apiRef: React.RefObject<ChartProApi | undefined>;
}) {
const [type, setType] = React.useState('image/png');
const [rawQuality, setRawQuality] = React.useState('0.9');
const quality = Math.max(0, Math.min(1, Number.parseFloat(rawQuality)));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A slider woudl solve the need for such a validation :)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't a slider a bit harder to use when selecting a specific value? For floating point numbers we would have a step of 0.01, which wouldn't be great when selecting a number, right?

I suppose sliders work better for integers.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose sliders work better for integers.

If you consider this as a percentage, it becomes an integer equivalent to a step of 0.01 ;)
But the texfield is ok too 👍


return (
<Stack
direction="row"
justifyContent="space-between"
flexWrap="wrap"
gap={2}
sx={{ width: '100%' }}
>
<FormControl fullWidth>
<FormLabel id="image-format-radio-buttons-group-label">
Image Format
</FormLabel>
<RadioGroup
row
aria-labelledby="image-format-radio-buttons-group-label"
name="image-format"
value={type}
onChange={(event) =>
setType(event.target.value as 'image/png' | 'image/jpeg' | 'image/webp')
}
>
<FormControlLabel
value="image/png"
control={<Radio />}
label="image/png"
/>
<FormControlLabel
value="image/jpeg"
control={<Radio />}
label="image/jpeg"
/>
<FormControlLabel
value="image/webp"
control={<Radio />}
label="image/webp"
/>
</RadioGroup>
</FormControl>
<FormControl>
<TextField
label="Quality"
value={rawQuality}
onChange={(event) => setRawQuality(event.target.value)}
disabled={type === 'image/png'}
helperText="Only applicable to lossy formats."
/>
</FormControl>
<Button
variant="outlined"
onClick={() => apiRef.current?.exportAsImage({ type, quality })}
>
Export Image
</Button>
</Stack>
);
}

export default function ExportChartAsImage() {
const apiRef = React.useRef<ChartProApi>(undefined);

return (
<Stack width="100%" gap={2}>
<LineChartPro
apiRef={apiRef}
xAxis={[{ data: [1, 2, 3, 5, 8, 10] }]}
series={[
{ data: [4, 9, 1, 4, 9, 6], label: 'Series A' },
{
data: [2, 5.5, 2, 8.5, 1.5, 5],
area: true,
label: 'Series B',
},
]}
height={300}
grid={{ vertical: true, horizontal: true }}
/>
<ExportParamsSelector apiRef={apiRef} />
</Stack>
);
}
15 changes: 15 additions & 0 deletions docs/data/charts/export/ExportChartAsImage.tsx.preview
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<LineChartPro
apiRef={apiRef}
xAxis={[{ data: [1, 2, 3, 5, 8, 10] }]}
series={[
{ data: [4, 9, 1, 4, 9, 6], label: 'Series A' },
{
data: [2, 5.5, 2, 8.5, 1.5, 5],
area: true,
label: 'Series B',
},
]}
height={300}
grid={{ vertical: true, horizontal: true }}
/>
<ExportParamsSelector apiRef={apiRef} />
38 changes: 38 additions & 0 deletions docs/data/charts/export/export.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,44 @@ The print dialog allows you to print the chart or save it as a PDF, as well as c

{{"demo": "PrintChart.js"}}

## Export as image

The `apiRef` prop also exposes a `exportAsImage()` method to export the chart as an image.

### Dependency

For `exportAsImage()` to work, you need to add `rasterizehtml` as a dependency in your project.

<codeblock storageKey="package-manager">

```bash npm
npm install rasterizehtml
```

```bash pnpm
pnpm add rasterizehtml
```

```bash yarn
yarn add rasterizehtml
```

</codeblock>

### Usage

The function accepts an options object with the `type` property, which specifies the image format. The available formats are:

- `image/png` and `image/jpeg`, which are supported across all [supported platforms](/material-ui/getting-started/supported-platforms/);
- `image/webp` which is only supported in some browsers.

If the format is not supported by the browser, `exportAsImage()` falls back to `image/png`.

Additionally, for lossy formats such as `image/jpeg` and `image/webp`, the options object also accepts the `quality` property, which is a number between 0 and 1.
The default value is 0.9.

{{"demo": "ExportChartAsImage.js"}}

## Composition

As detailed in the [Composition](/x/react-charts/composition/) section, charts can alternatively be composed of more specific components to create custom visualizations.
Expand Down
4 changes: 4 additions & 0 deletions packages/x-charts-pro/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,15 @@
"optional": true
}
},
"optionalDependencies": {
"rasterizehtml": "^1.3.1"
},
"devDependencies": {
"@mui/material": "^7.0.2",
"@mui/system": "^7.0.2",
"@types/prop-types": "^15.7.14",
"csstype": "^3.1.3",
"rasterizehtml": "^1.3.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"rimraf": "^6.0.1"
Expand Down
1 change: 1 addition & 0 deletions packages/x-charts-pro/src/BarChartPro/BarChartPro.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ BarChartPro.propTypes = {
// ----------------------------------------------------------------------
apiRef: PropTypes.shape({
current: PropTypes.shape({
exportAsImage: PropTypes.func.isRequired,
exportAsPrint: PropTypes.func.isRequired,
setZoomData: PropTypes.func.isRequired,
}),
Expand Down
1 change: 1 addition & 0 deletions packages/x-charts-pro/src/FunnelChart/FunnelChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ FunnelChart.propTypes = {
// ----------------------------------------------------------------------
apiRef: PropTypes.shape({
current: PropTypes.shape({
exportAsImage: PropTypes.func.isRequired,
exportAsPrint: PropTypes.func.isRequired,
setZoomData: PropTypes.func.isRequired,
}),
Expand Down
1 change: 1 addition & 0 deletions packages/x-charts-pro/src/LineChartPro/LineChartPro.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ LineChartPro.propTypes = {
// ----------------------------------------------------------------------
apiRef: PropTypes.shape({
current: PropTypes.shape({
exportAsImage: PropTypes.func.isRequired,
exportAsPrint: PropTypes.func.isRequired,
setZoomData: PropTypes.func.isRequired,
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ ScatterChartPro.propTypes = {
// ----------------------------------------------------------------------
apiRef: PropTypes.shape({
current: PropTypes.shape({
exportAsImage: PropTypes.func.isRequired,
exportAsPrint: PropTypes.func.isRequired,
setZoomData: PropTypes.func.isRequired,
}),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import ownerDocument from '@mui/utils/ownerDocument';

export function createExportIframe(title?: string): HTMLIFrameElement {
const iframeEl = document.createElement('iframe');
iframeEl.style.position = 'absolute';
iframeEl.style.width = '0px';
iframeEl.style.height = '0px';
iframeEl.title = title || document.title;
return iframeEl;
}

export function loadStyleSheets(document: Document, element: HTMLElement | SVGElement) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks close to the data grid print logic, maybe something to share at one point.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it's a direct copy. I'll look into how we can share this.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here it is: #17548

const stylesheetLoadPromises: Promise<void>[] = [];
const doc = ownerDocument(element);

const rootCandidate = element.getRootNode();
const root =
rootCandidate.constructor.name === 'ShadowRoot' ? (rootCandidate as ShadowRoot) : doc;
const headStyleElements = root!.querySelectorAll("style, link[rel='stylesheet']");

for (let i = 0; i < headStyleElements.length; i += 1) {
const node = headStyleElements[i];
if (node.tagName === 'STYLE') {
const newHeadStyleElements = document.createElement(node.tagName);
const sheet = (node as HTMLStyleElement).sheet;

if (sheet) {
let styleCSS = '';
// NOTE: for-of is not supported by IE
for (let j = 0; j < sheet.cssRules.length; j += 1) {
if (typeof sheet.cssRules[j].cssText === 'string') {
styleCSS += `${sheet.cssRules[j].cssText}\r\n`;
}
}
newHeadStyleElements.appendChild(document.createTextNode(styleCSS));
document.head.appendChild(newHeadStyleElements);
}
} else if (node.getAttribute('href')) {
// If `href` tag is empty, avoid loading these links

const newHeadStyleElements = document.createElement(node.tagName);

for (let j = 0; j < node.attributes.length; j += 1) {
const attr = node.attributes[j];
if (attr) {
newHeadStyleElements.setAttribute(attr.nodeName, attr.nodeValue || '');
}
}

stylesheetLoadPromises.push(
new Promise((resolve) => {
newHeadStyleElements.addEventListener('load', () => resolve());
}),
);

document.head.appendChild(newHeadStyleElements);
}
}

return Promise.all(stylesheetLoadPromises);
}
Loading