Skip to content

Commit cb2d3d1

Browse files
authored
Merge pull request #1939 from plotly/improve-toimage
Improve `Plotly.toImage`
2 parents f428026 + 640503a commit cb2d3d1

File tree

10 files changed

+361
-203
lines changed

10 files changed

+361
-203
lines changed

src/plot_api/plot_api.js

+17-3
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ var Polar = require('../plots/polar');
2525
var initInteractions = require('../plots/cartesian/graph_interact');
2626

2727
var Drawing = require('../components/drawing');
28+
var Color = require('../components/color');
2829
var ErrorBars = require('../components/errorbars');
2930
var xmlnsNamespaces = require('../constants/xmlns_namespaces');
3031
var svgTextUtils = require('../lib/svg_text_utils');
@@ -390,10 +391,17 @@ Plotly.plot = function(gd, data, layout, config) {
390391
});
391392
};
392393

394+
function setBackground(gd, bgColor) {
395+
try {
396+
gd._fullLayout._paper.style('background', bgColor);
397+
} catch(e) {
398+
Lib.error(e);
399+
}
400+
}
393401

394402
function opaqueSetBackground(gd, bgColor) {
395-
gd._fullLayout._paperdiv.style('background', 'white');
396-
Plotly.defaultConfig.setBackground(gd, bgColor);
403+
var blend = Color.combine(bgColor, 'white');
404+
setBackground(gd, blend);
397405
}
398406

399407
function setPlotContext(gd, config) {
@@ -410,8 +418,9 @@ function setPlotContext(gd, config) {
410418
if(key in context) {
411419
if(key === 'setBackground' && config[key] === 'opaque') {
412420
context[key] = opaqueSetBackground;
421+
} else {
422+
context[key] = config[key];
413423
}
414-
else context[key] = config[key];
415424
}
416425
}
417426

@@ -460,6 +469,11 @@ function setPlotContext(gd, config) {
460469
if(context.displayModeBar === 'hover' && !hasHover) {
461470
context.displayModeBar = true;
462471
}
472+
473+
// default and fallback for setBackground
474+
if(context.setBackground === 'transparent' || typeof context.setBackground !== 'function') {
475+
context.setBackground = setBackground;
476+
}
463477
}
464478

465479
function plotPolar(gd, data, layout) {

src/plot_api/plot_config.js

+5-18
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,6 @@
88

99
'use strict';
1010

11-
/* eslint-disable no-console */
12-
1311
/**
1412
* This will be transferred over to gd and overridden by
1513
* config args to Plotly.plot.
@@ -108,9 +106,11 @@ module.exports = {
108106
// increase the pixel ratio for Gl plot images
109107
plotGlPixelRatio: 2,
110108

111-
// function to add the background color to a different container
112-
// or 'opaque' to ensure there's white behind it
113-
setBackground: defaultSetBackground,
109+
// background setting function
110+
// 'transparent' sets the background `layout.paper_color`
111+
// 'opaque' blends bg color with white ensuring an opaque background
112+
// or any other custom function of gd
113+
setBackground: 'transparent',
114114

115115
// URL to topojson files used in geo charts
116116
topojsonURL: 'https://cdn.plot.ly/',
@@ -128,16 +128,3 @@ module.exports = {
128128
// specification needed
129129
globalTransforms: []
130130
};
131-
132-
// where and how the background gets set can be overridden by context
133-
// so we define the default (plotly.js) behavior here
134-
function defaultSetBackground(gd, bgColor) {
135-
try {
136-
gd._fullLayout._paper.style('background', bgColor);
137-
}
138-
catch(e) {
139-
if(module.exports.logging > 0) {
140-
console.error(e);
141-
}
142-
}
143-
}

src/plot_api/to_image.js

+168-78
Original file line numberDiff line numberDiff line change
@@ -8,101 +8,191 @@
88

99
'use strict';
1010

11-
var isNumeric = require('fast-isnumeric');
12-
1311
var Plotly = require('../plotly');
1412
var Lib = require('../lib');
1513

1614
var helpers = require('../snapshot/helpers');
17-
var clonePlot = require('../snapshot/cloneplot');
1815
var toSVG = require('../snapshot/tosvg');
1916
var svgToImg = require('../snapshot/svgtoimg');
2017

21-
/**
22-
* @param {object} gd figure Object
23-
* @param {object} opts option object
24-
* @param opts.format 'jpeg' | 'png' | 'webp' | 'svg'
25-
* @param opts.width width of snapshot in px
26-
* @param opts.height height of snapshot in px
18+
var getGraphDiv = require('./helpers').getGraphDiv;
19+
20+
var attrs = {
21+
format: {
22+
valType: 'enumerated',
23+
values: ['png', 'jpeg', 'webp', 'svg'],
24+
dflt: 'png',
25+
description: 'Sets the format of exported image.'
26+
},
27+
width: {
28+
valType: 'number',
29+
min: 1,
30+
description: [
31+
'Sets the exported image width.',
32+
'Defaults to the value found in `layout.width`'
33+
].join(' ')
34+
},
35+
height: {
36+
valType: 'number',
37+
min: 1,
38+
description: [
39+
'Sets the exported image height.',
40+
'Defaults to the value found in `layout.height`'
41+
].join(' ')
42+
},
43+
setBackground: {
44+
valType: 'any',
45+
dflt: false,
46+
description: [
47+
'Sets the image background mode.',
48+
'By default, the image background is determined by `layout.paper_bgcolor`,',
49+
'the *transparent* mode.',
50+
'One might consider setting `setBackground` to *opaque*',
51+
'when exporting a *jpeg* image as JPEGs do not support opacity.'
52+
].join(' ')
53+
},
54+
imageDataOnly: {
55+
valType: 'boolean',
56+
dflt: false,
57+
description: [
58+
'Determines whether or not the return value is prefixed by',
59+
'the image format\'s corresponding \'data:image;\' spec.'
60+
].join(' ')
61+
}
62+
};
63+
64+
var IMAGE_URL_PREFIX = /^data:image\/\w+;base64,/;
65+
66+
/** Plotly.toImage
67+
*
68+
* @param {object | string | HTML div} gd
69+
* can either be a data/layout/config object
70+
* or an existing graph <div>
71+
* or an id to an existing graph <div>
72+
* @param {object} opts (see above)
73+
* @return {promise}
2774
*/
2875
function toImage(gd, opts) {
76+
opts = opts || {};
77+
78+
var data;
79+
var layout;
80+
var config;
81+
82+
if(Lib.isPlainObject(gd)) {
83+
data = gd.data || [];
84+
layout = gd.layout || {};
85+
config = gd.config || {};
86+
} else {
87+
gd = getGraphDiv(gd);
88+
data = Lib.extendDeep([], gd.data);
89+
layout = Lib.extendDeep({}, gd.layout);
90+
config = gd._context;
91+
}
92+
93+
function isImpliedOrValid(attr) {
94+
return !(attr in opts) || Lib.validate(opts[attr], attrs[attr]);
95+
}
96+
97+
if(!isImpliedOrValid('width') || !isImpliedOrValid('height')) {
98+
throw new Error('Height and width should be pixel values.');
99+
}
100+
101+
if(!isImpliedOrValid('format')) {
102+
throw new Error('Image format is not jpeg, png, svg or webp.');
103+
}
104+
105+
var fullOpts = {};
106+
107+
function coerce(attr, dflt) {
108+
return Lib.coerce(opts, fullOpts, attrs, attr, dflt);
109+
}
110+
111+
var format = coerce('format');
112+
var width = coerce('width');
113+
var height = coerce('height');
114+
var setBackground = coerce('setBackground');
115+
var imageDataOnly = coerce('imageDataOnly');
116+
117+
// put the cloned div somewhere off screen before attaching to DOM
118+
var clonedGd = document.createElement('div');
119+
clonedGd.style.position = 'absolute';
120+
clonedGd.style.left = '-5000px';
121+
document.body.appendChild(clonedGd);
122+
123+
// extend layout with image options
124+
var layoutImage = Lib.extendFlat({}, layout);
125+
if(width) layoutImage.width = width;
126+
if(height) layoutImage.height = height;
127+
128+
// extend config for static plot
129+
var configImage = Lib.extendFlat({}, config, {
130+
staticPlot: true,
131+
plotGlPixelRatio: config.plotGlPixelRatio || 2,
132+
setBackground: setBackground
133+
});
29134

30-
var promise = new Promise(function(resolve, reject) {
31-
// check for undefined opts
32-
opts = opts || {};
33-
// default to png
34-
opts.format = opts.format || 'png';
35-
36-
var isSizeGood = function(size) {
37-
// undefined and null are valid options
38-
if(size === undefined || size === null) {
39-
return true;
40-
}
41-
42-
if(isNumeric(size) && size > 1) {
43-
return true;
135+
var redrawFunc = helpers.getRedrawFunc(clonedGd);
136+
137+
function wait() {
138+
return new Promise(function(resolve) {
139+
setTimeout(resolve, helpers.getDelay(clonedGd._fullLayout));
140+
});
141+
}
142+
143+
function convert() {
144+
return new Promise(function(resolve, reject) {
145+
var svg = toSVG(clonedGd);
146+
var width = clonedGd._fullLayout.width;
147+
var height = clonedGd._fullLayout.height;
148+
149+
Plotly.purge(clonedGd);
150+
document.body.removeChild(clonedGd);
151+
152+
if(format === 'svg') {
153+
if(imageDataOnly) {
154+
return resolve(svg);
155+
} else {
156+
return resolve('data:image/svg+xml,' + encodeURIComponent(svg));
157+
}
44158
}
45159

46-
return false;
47-
};
48-
49-
if(!isSizeGood(opts.width) || !isSizeGood(opts.height)) {
50-
reject(new Error('Height and width should be pixel values.'));
51-
}
52-
53-
// first clone the GD so we can operate in a clean environment
54-
var clone = clonePlot(gd, {format: 'png', height: opts.height, width: opts.width});
55-
var clonedGd = clone.gd;
56-
57-
// put the cloned div somewhere off screen before attaching to DOM
58-
clonedGd.style.position = 'absolute';
59-
clonedGd.style.left = '-5000px';
60-
document.body.appendChild(clonedGd);
61-
62-
function wait() {
63-
var delay = helpers.getDelay(clonedGd._fullLayout);
64-
65-
return new Promise(function(resolve, reject) {
66-
setTimeout(function() {
67-
var svg = toSVG(clonedGd);
68-
69-
var canvas = document.createElement('canvas');
70-
canvas.id = Lib.randstr();
71-
72-
svgToImg({
73-
format: opts.format,
74-
width: clonedGd._fullLayout.width,
75-
height: clonedGd._fullLayout.height,
76-
canvas: canvas,
77-
svg: svg,
78-
// ask svgToImg to return a Promise
79-
// rather than EventEmitter
80-
// leave EventEmitter for backward
81-
// compatibility
82-
promise: true
83-
}).then(function(url) {
84-
if(clonedGd) document.body.removeChild(clonedGd);
85-
resolve(url);
86-
}).catch(function(err) {
87-
reject(err);
88-
});
89-
90-
}, delay);
91-
});
160+
var canvas = document.createElement('canvas');
161+
canvas.id = Lib.randstr();
162+
163+
svgToImg({
164+
format: format,
165+
width: width,
166+
height: height,
167+
canvas: canvas,
168+
svg: svg,
169+
// ask svgToImg to return a Promise
170+
// rather than EventEmitter
171+
// leave EventEmitter for backward
172+
// compatibility
173+
promise: true
174+
})
175+
.then(resolve)
176+
.catch(reject);
177+
});
178+
}
179+
180+
function urlToImageData(url) {
181+
if(imageDataOnly) {
182+
return url.replace(IMAGE_URL_PREFIX, '');
183+
} else {
184+
return url;
92185
}
186+
}
93187

94-
var redrawFunc = helpers.getRedrawFunc(clonedGd);
95-
96-
Plotly.plot(clonedGd, clone.data, clone.layout, clone.config)
188+
return new Promise(function(resolve, reject) {
189+
Plotly.plot(clonedGd, data, layoutImage, configImage)
97190
.then(redrawFunc)
98191
.then(wait)
99-
.then(function(url) { resolve(url); })
100-
.catch(function(err) {
101-
reject(err);
102-
});
192+
.then(convert)
193+
.then(function(url) { resolve(urlToImageData(url)); })
194+
.catch(function(err) { reject(err); });
103195
});
104-
105-
return promise;
106196
}
107197

108198
module.exports = toImage;

src/plots/gl2d/scene2d.js

+2
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,8 @@ proto.handleAnnotations = function() {
356356
};
357357

358358
proto.destroy = function() {
359+
if(!this.glplot) return;
360+
359361
var traces = this.traces;
360362

361363
if(traces) {

src/plots/gl3d/scene.js

+2
Original file line numberDiff line numberDiff line change
@@ -593,6 +593,8 @@ proto.plot = function(sceneData, fullLayout, layout) {
593593
};
594594

595595
proto.destroy = function() {
596+
if(!this.glplot) return;
597+
596598
this.camera.mouseListener.enabled = false;
597599
this.container.removeEventListener('wheel', this.camera.wheelListener);
598600
this.camera = this.glplot.camera = null;

src/plots/mapbox/mapbox.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -454,8 +454,8 @@ proto.destroy = function() {
454454
if(this.map) {
455455
this.map.remove();
456456
this.map = null;
457+
this.container.removeChild(this.div);
457458
}
458-
this.container.removeChild(this.div);
459459
};
460460

461461
proto.toImage = function() {

0 commit comments

Comments
 (0)