Skip to content

Commit e844687

Browse files
authored
feat: add PNG export (#451)
- Add PNG export of chart (including IE11!) - Story added in Interactions section Closes #82
1 parent 7738aa9 commit e844687

File tree

8 files changed

+191
-53
lines changed

8 files changed

+191
-53
lines changed

.playground/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
left: 0px;
2222
}
2323
.chart {
24-
background: black;
24+
background: white;
2525
display: inline-block;
2626
position: relative;
2727
width: 900px;

.playground/playgroud.tsx

Lines changed: 52 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,67 @@
11
import React from 'react';
2-
import { Axis, Chart, getAxisId, getSpecId, Position, ScaleType, Settings, LineSeries } from '../src';
3-
import { Fit } from '../src/chart_types/xy_chart/utils/specs';
4-
5-
const data = [
6-
{ x: 0, y: null },
7-
{ x: 1, y: 3 },
8-
{ x: 2, y: 5 },
9-
{ x: 3, y: null },
10-
{ x: 4, y: 4 },
11-
{ x: 5, y: null },
12-
{ x: 6, y: 5 },
13-
{ x: 7, y: 6 },
14-
{ x: 8, y: null },
15-
{ x: 9, y: null },
16-
{ x: 10, y: null },
17-
{ x: 11, y: 12 },
18-
{ x: 12, y: null },
19-
];
20-
2+
import {
3+
Axis,
4+
Chart,
5+
getAxisId,
6+
getSpecId,
7+
Position,
8+
ScaleType,
9+
HistogramBarSeries,
10+
Settings,
11+
LIGHT_THEME,
12+
niceTimeFormatter,
13+
} from '../src';
14+
import { KIBANA_METRICS } from '../src/utils/data_samples/test_dataset_kibana';
2115
export class Playground extends React.Component {
16+
chartRef: React.RefObject<Chart> = React.createRef();
17+
onSnapshot = () => {
18+
if (!this.chartRef.current) {
19+
return;
20+
}
21+
const snapshot = this.chartRef.current.getPNGSnapshot({
22+
backgroundColor: 'white',
23+
pixelRatio: 1,
24+
});
25+
if (!snapshot) {
26+
return;
27+
}
28+
const fileName = 'chart.png';
29+
switch (snapshot.browser) {
30+
case 'IE11':
31+
return navigator.msSaveBlob(snapshot.blobOrDataUrl, fileName);
32+
default:
33+
const link = document.createElement('a');
34+
link.download = fileName;
35+
link.href = snapshot.blobOrDataUrl;
36+
document.body.appendChild(link);
37+
link.click();
38+
document.body.removeChild(link);
39+
}
40+
};
2241
render() {
42+
const data = KIBANA_METRICS.metrics.kibana_os_load[0].data.slice(0, 100);
43+
2344
return (
2445
<>
46+
<button onClick={this.onSnapshot}>Snapshot</button>
2547
<div className="chart">
26-
<Chart className="story-chart">
27-
<Settings
28-
showLegend
29-
theme={{
30-
areaSeriesStyle: {
31-
point: {
32-
visible: true,
33-
},
34-
},
35-
}}
36-
/>
48+
<Chart ref={this.chartRef}>
49+
<Settings theme={LIGHT_THEME} showLegend={true} />
3750
<Axis
38-
id={getAxisId('bottom')}
51+
id={getAxisId('time')}
3952
position={Position.Bottom}
40-
title={'Bottom axis'}
41-
showOverlappingTicks={true}
53+
tickFormat={niceTimeFormatter([data[0][0], data[data.length - 1][0]])}
4254
/>
43-
<Axis id={getAxisId('left')} title={'Left axis'} position={Position.Left} />
44-
<LineSeries
45-
id={getSpecId('test')}
55+
<Axis id={getAxisId('count')} position={Position.Left} />
56+
57+
<HistogramBarSeries
58+
id={getSpecId('series bars chart')}
4659
xScaleType={ScaleType.Linear}
4760
yScaleType={ScaleType.Linear}
48-
xAccessor={'x'}
49-
yAccessors={['y']}
50-
// curve={2}
51-
// splitSeriesAccessors={['g']}
52-
// stackAccessors={['x']}
53-
fit={Fit.Linear}
61+
xAccessor={0}
62+
yAccessors={[1]}
5463
data={data}
55-
// fit={{
56-
// type: Fit.Average,
57-
// endValue: 0,
58-
// }}
59-
// data={data}
64+
yScaleToDataExtent={true}
6065
/>
6166
</Chart>
6267
</div>
Loading

src/components/chart.snap.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { Chart } from '../components/chart';
2+
3+
describe('test getPNGSnapshot in Chart class', () => {
4+
jest.mock('../components/chart');
5+
it('should be called', () => {
6+
const chart = new Chart({});
7+
const spy = jest.spyOn(chart, 'getPNGSnapshot');
8+
chart.getPNGSnapshot({ backgroundColor: 'white', pixelRatio: 1 });
9+
10+
expect(spy).toBeCalled();
11+
});
12+
});

src/components/chart.tsx

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import { isHorizontalAxis } from '../chart_types/xy_chart/utils/axis_utils';
1515
import { Position } from '../chart_types/xy_chart/utils/specs';
1616
import { CursorEvent } from '../specs/settings';
1717
import { ChartSize, getChartSize } from '../utils/chart_size';
18+
import { Stage } from 'react-konva';
19+
import Konva from 'konva';
1820

1921
interface ChartProps {
2022
/** The type of rendered
@@ -38,9 +40,11 @@ export class Chart extends React.Component<ChartProps, ChartState> {
3840
};
3941
private chartSpecStore: ChartStore;
4042
private chartContainerRef: React.RefObject<HTMLDivElement>;
43+
private chartStageRef: React.RefObject<Stage>;
4144
constructor(props: any) {
4245
super(props);
4346
this.chartContainerRef = createRef();
47+
this.chartStageRef = createRef();
4448
this.chartSpecStore = new ChartStore(props.id);
4549
this.state = {
4650
legendPosition: this.chartSpecStore.legendPosition.get(),
@@ -91,6 +95,56 @@ export class Chart extends React.Component<ChartProps, ChartState> {
9195
}
9296
}
9397
}
98+
99+
getPNGSnapshot(
100+
options = {
101+
backgroundColor: 'transparent',
102+
pixelRatio: 2,
103+
},
104+
): {
105+
blobOrDataUrl: any;
106+
browser: 'IE11' | 'other';
107+
} | null {
108+
if (!this.chartStageRef.current) {
109+
return null;
110+
}
111+
const stage = this.chartStageRef.current.getStage().clone();
112+
const width = stage.getWidth();
113+
const height = stage.getHeight();
114+
const backgroundLayer = new Konva.Layer();
115+
const backgroundRect = new Konva.Rect({
116+
fill: options.backgroundColor,
117+
x: 0,
118+
y: 0,
119+
width,
120+
height,
121+
});
122+
123+
backgroundLayer.add(backgroundRect);
124+
stage.add(backgroundLayer);
125+
backgroundLayer.moveToBottom();
126+
stage.draw();
127+
const canvasStage = stage.toCanvas({
128+
width,
129+
height,
130+
callback: () => {},
131+
});
132+
// @ts-ignore
133+
if (canvasStage.msToBlob) {
134+
// @ts-ignore
135+
const blobOrDataUrl = canvasStage.msToBlob();
136+
return {
137+
blobOrDataUrl,
138+
browser: 'IE11',
139+
};
140+
} else {
141+
return {
142+
blobOrDataUrl: stage.toDataURL({ pixelRatio: options.pixelRatio }),
143+
browser: 'other',
144+
};
145+
}
146+
}
147+
94148
getChartContainerRef = () => {
95149
return this.chartContainerRef;
96150
};
@@ -119,8 +173,8 @@ export class Chart extends React.Component<ChartProps, ChartState> {
119173
<ChartResizer />
120174
<Crosshair />
121175
{// TODO reenable when SVG rendered is aligned with canvas one
122-
renderer === 'svg' && <ChartContainer />}
123-
{renderer === 'canvas' && <ChartContainer />}
176+
renderer === 'svg' && <ChartContainer forwardRef={this.chartStageRef} />}
177+
{renderer === 'canvas' && <ChartContainer forwardRef={this.chartStageRef} />}
124178
<Tooltips getChartContainerRef={this.getChartContainerRef} />
125179
<AnnotationTooltip getChartContainerRef={this.getChartContainerRef} />
126180
<Highlighter />

src/components/react_canvas/chart_container.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ import React from 'react';
22
import { inject, observer } from 'mobx-react';
33
import { ChartStore } from '../../chart_types/xy_chart/store/chart_state';
44
import { ReactiveChart } from './reactive_chart';
5+
import { Stage } from 'react-konva';
56
interface ReactiveChartProps {
67
chartStore?: ChartStore; // FIX until we find a better way on ts mobx
8+
forwardRef: React.RefObject<Stage>;
79
}
810

911
class ChartContainerComponent extends React.Component<ReactiveChartProps> {
@@ -36,7 +38,7 @@ class ChartContainerComponent extends React.Component<ReactiveChartProps> {
3638
this.props.chartStore!.handleChartClick();
3739
}}
3840
>
39-
<ReactiveChart />
41+
<ReactiveChart forwardRef={this.props.forwardRef} />
4042
</div>
4143
);
4244
}

src/components/react_canvas/reactive_chart.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { Clippings } from './utils/rendering_props_utils';
2626

2727
interface ReactiveChartProps {
2828
chartStore?: ChartStore; // FIX until we find a better way on ts mobx
29+
forwardRef: React.RefObject<Stage>;
2930
}
3031
interface ReactiveChartState {
3132
brushing: boolean;
@@ -95,7 +96,6 @@ class Chart extends React.Component<ReactiveChartProps, ReactiveChartState> {
9596
return [];
9697
}
9798
const highlightedLegendItem = this.getHighlightedLegendItem();
98-
9999
const element = (
100100
<BarGeometries
101101
key={'bar-geometries'}
@@ -412,6 +412,7 @@ class Chart extends React.Component<ReactiveChartProps, ReactiveChartState> {
412412
height: '100%',
413413
}}
414414
{...brushProps}
415+
ref={this.props.forwardRef}
415416
>
416417
<Layer hitGraphEnabled={false} listening={false}>
417418
{this.renderGrids()}

stories/interactions.tsx

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import {
2323
TooltipValueFormatter,
2424
} from '../src/';
2525

26-
import { array, boolean, number, select } from '@storybook/addon-knobs';
26+
import { array, boolean, number, select, button } from '@storybook/addon-knobs';
2727
import { DateTime } from 'luxon';
2828
import { switchTheme } from '../.storybook/theme_service';
2929
import { BARCHART_2Y2G } from '../src/utils/data_samples/test_dataset';
@@ -635,4 +635,68 @@ storiesOf('Interactions', module)
635635
{
636636
info: 'Sends an event every time the cursor changes. This is provided to sync cursors between multiple charts.',
637637
},
638+
)
639+
.add(
640+
'PNG export action',
641+
() => {
642+
/**
643+
* The handler section of this story demonstrates the PNG export functionality
644+
*/
645+
const data = KIBANA_METRICS.metrics.kibana_os_load[0].data.slice(0, 100);
646+
const label = 'Export PNG';
647+
const chartRef: React.RefObject<Chart> = React.createRef();
648+
const handler = () => {
649+
if (!chartRef.current) {
650+
return;
651+
}
652+
const snapshot = chartRef.current.getPNGSnapshot({
653+
// you can set the background and pixel ratio for the PNG export
654+
backgroundColor: 'white',
655+
pixelRatio: 2,
656+
});
657+
if (!snapshot) {
658+
return;
659+
}
660+
// will save as chart.png
661+
const fileName = 'chart.png';
662+
switch (snapshot.browser) {
663+
case 'IE11':
664+
return navigator.msSaveBlob(snapshot.blobOrDataUrl, fileName);
665+
default:
666+
const link = document.createElement('a');
667+
link.download = fileName;
668+
link.href = snapshot.blobOrDataUrl;
669+
document.body.appendChild(link);
670+
link.click();
671+
document.body.removeChild(link);
672+
}
673+
};
674+
const groupId = 'PNG-1';
675+
button(label, handler, groupId);
676+
return (
677+
<Chart className={'story-chart'} ref={chartRef}>
678+
<Settings showLegend={true} />
679+
<Axis
680+
id={getAxisId('time')}
681+
position={Position.Bottom}
682+
tickFormat={niceTimeFormatter([data[0][0], data[data.length - 1][0]])}
683+
/>
684+
<Axis id={getAxisId('count')} position={Position.Left} />
685+
686+
<BarSeries
687+
id={getSpecId('series bars chart')}
688+
xScaleType={ScaleType.Linear}
689+
yScaleType={ScaleType.Linear}
690+
xAccessor={0}
691+
yAccessors={[1]}
692+
data={data}
693+
yScaleToDataExtent={true}
694+
/>
695+
</Chart>
696+
);
697+
},
698+
{
699+
info:
700+
'Generate a PNG of the chart by clicking on the Export PNG button in the knobs section. In this example, the button handler is setting the PNG background to white with a pixel ratio of 2. If the browser is detected to be IE11, msSaveBlob will be used instead of a PNG capture.',
701+
},
638702
);

0 commit comments

Comments
 (0)