-
-
Notifications
You must be signed in to change notification settings - Fork 1.9k
Improve Plotly.toImage
#1939
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
Improve Plotly.toImage
#1939
Changes from 1 commit
f758c35
3d19676
dd003ce
9128e13
5d2b635
8866525
b3fac37
32f46c5
e5e35c0
22a598b
43a6150
7565d0b
3c42efb
4ec3e0f
816df16
640503a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -8,101 +8,191 @@ | |
|
||
'use strict'; | ||
|
||
var isNumeric = require('fast-isnumeric'); | ||
|
||
var Plotly = require('../plotly'); | ||
var Lib = require('../lib'); | ||
|
||
var helpers = require('../snapshot/helpers'); | ||
var clonePlot = require('../snapshot/cloneplot'); | ||
var toSVG = require('../snapshot/tosvg'); | ||
var svgToImg = require('../snapshot/svgtoimg'); | ||
|
||
/** | ||
* @param {object} gd figure Object | ||
* @param {object} opts option object | ||
* @param opts.format 'jpeg' | 'png' | 'webp' | 'svg' | ||
* @param opts.width width of snapshot in px | ||
* @param opts.height height of snapshot in px | ||
var getGraphDiv = require('./helpers').getGraphDiv; | ||
|
||
var attrs = { | ||
format: { | ||
valType: 'enumerated', | ||
values: ['png', 'jpeg', 'webp', 'svg'], | ||
dflt: 'png', | ||
description: 'Sets the format of exported image.' | ||
}, | ||
width: { | ||
valType: 'number', | ||
min: 1, | ||
description: [ | ||
'Sets the exported image width.', | ||
'Defaults to the value found in `layout.width`' | ||
].join(' ') | ||
}, | ||
height: { | ||
valType: 'number', | ||
min: 1, | ||
description: [ | ||
'Sets the exported image height.', | ||
'Defaults to the value found in `layout.height`' | ||
].join(' ') | ||
}, | ||
setBackground: { | ||
valType: 'any', | ||
dflt: false, | ||
description: [ | ||
'Sets the image background mode.', | ||
'By default, the image background is determined by `layout.paper_bgcolor`,', | ||
'the *transparent* mode.', | ||
'One might consider setting `setBackground` to *opaque* or *blend*', | ||
'when exporting a *jpeg* image as JPEGs do not support opacity.' | ||
].join(' ') | ||
}, | ||
imageDataOnly: { | ||
valType: 'boolean', | ||
dflt: false, | ||
description: [ | ||
'Determines whether or not the return value is prefixed by', | ||
'the image format\'s corresponding \'data:image;\' spec.' | ||
].join(' ') | ||
} | ||
}; | ||
|
||
var IMAGE_URL_PREFIX = /^data:image\/\w+;base64,/; | ||
|
||
/** Plotly.toImage | ||
* | ||
* @param {object | string | HTML div} gd | ||
* can either be a data/layout/config object | ||
* or an existing graph <div> | ||
* or an id to an existing graph <div> | ||
* @param {object} opts (see above) | ||
* @return {promise} | ||
*/ | ||
function toImage(gd, opts) { | ||
opts = opts || {}; | ||
|
||
var data; | ||
var layout; | ||
var config; | ||
|
||
if(Lib.isPlainObject(gd)) { | ||
data = gd.data || []; | ||
layout = gd.layout || {}; | ||
config = gd.config || {}; | ||
} else { | ||
gd = getGraphDiv(gd); | ||
data = Lib.extendDeep([], gd.data); | ||
layout = Lib.extendDeep({}, gd.layout); | ||
config = gd._context; | ||
} | ||
|
||
function isBadlySet(attr) { | ||
return !(attr in opts) || Lib.validate(opts[attr], attrs[attr]); | ||
} | ||
|
||
if(!isBadlySet('width') || !isBadlySet('height')) { | ||
throw new Error('Height and width should be pixel values.'); | ||
} | ||
|
||
if(!isBadlySet('format')) { | ||
throw new Error('Image format is not jpeg, png, svg or webp.'); | ||
} | ||
|
||
var fullOpts = {}; | ||
|
||
function coerce(attr, dflt) { | ||
return Lib.coerce(opts, fullOpts, attrs, attr, dflt); | ||
} | ||
|
||
var format = coerce('format'); | ||
var width = coerce('width'); | ||
var height = coerce('height'); | ||
var setBackground = coerce('setBackground'); | ||
var imageDataOnly = coerce('imageDataOnly'); | ||
|
||
// put the cloned div somewhere off screen before attaching to DOM | ||
var clonedGd = document.createElement('div'); | ||
clonedGd.style.position = 'absolute'; | ||
clonedGd.style.left = '-5000px'; | ||
document.body.appendChild(clonedGd); | ||
|
||
// extend layout with image options | ||
var layoutImage = Lib.extendFlat({}, layout); | ||
if(width) layoutImage.width = width; | ||
if(height) layoutImage.height = height; | ||
|
||
// extend config for static plot | ||
var configImage = Lib.extendFlat({}, config, { | ||
staticPlot: true, | ||
plotGlPixelRatio: 2, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. is There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, potentially. Good 👀 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. done in 7565d0b |
||
displaylogo: false, | ||
showLink: false, | ||
showTips: false, | ||
setBackground: setBackground | ||
}); | ||
|
||
var promise = new Promise(function(resolve, reject) { | ||
// check for undefined opts | ||
opts = opts || {}; | ||
// default to png | ||
opts.format = opts.format || 'png'; | ||
|
||
var isSizeGood = function(size) { | ||
// undefined and null are valid options | ||
if(size === undefined || size === null) { | ||
return true; | ||
} | ||
var redrawFunc = helpers.getRedrawFunc(clonedGd); | ||
|
||
if(isNumeric(size) && size > 1) { | ||
return true; | ||
} | ||
function wait() { | ||
return new Promise(function(resolve) { | ||
setTimeout(resolve, helpers.getDelay(clonedGd._fullLayout)); | ||
}); | ||
} | ||
|
||
return false; | ||
}; | ||
function convert() { | ||
return new Promise(function(resolve, reject) { | ||
var svg = toSVG(clonedGd); | ||
|
||
if(!isSizeGood(opts.width) || !isSizeGood(opts.height)) { | ||
reject(new Error('Height and width should be pixel values.')); | ||
} | ||
if(format === 'svg' && imageDataOnly) { | ||
return resolve(svg); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fast pass for Note that PDF and EPS images should first be generated as There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. is it worth handling There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, it shouldn't be too hard. I'll add that in. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. done in 4ec3e0f |
||
} | ||
|
||
// first clone the GD so we can operate in a clean environment | ||
var clone = clonePlot(gd, {format: 'png', height: opts.height, width: opts.width}); | ||
var clonedGd = clone.gd; | ||
|
||
// put the cloned div somewhere off screen before attaching to DOM | ||
clonedGd.style.position = 'absolute'; | ||
clonedGd.style.left = '-5000px'; | ||
document.body.appendChild(clonedGd); | ||
|
||
function wait() { | ||
var delay = helpers.getDelay(clonedGd._fullLayout); | ||
|
||
return new Promise(function(resolve, reject) { | ||
setTimeout(function() { | ||
var svg = toSVG(clonedGd); | ||
|
||
var canvas = document.createElement('canvas'); | ||
canvas.id = Lib.randstr(); | ||
|
||
svgToImg({ | ||
format: opts.format, | ||
width: clonedGd._fullLayout.width, | ||
height: clonedGd._fullLayout.height, | ||
canvas: canvas, | ||
svg: svg, | ||
// ask svgToImg to return a Promise | ||
// rather than EventEmitter | ||
// leave EventEmitter for backward | ||
// compatibility | ||
promise: true | ||
}).then(function(url) { | ||
if(clonedGd) document.body.removeChild(clonedGd); | ||
resolve(url); | ||
}).catch(function(err) { | ||
reject(err); | ||
}); | ||
|
||
}, delay); | ||
var canvas = document.createElement('canvas'); | ||
canvas.id = Lib.randstr(); | ||
|
||
svgToImg({ | ||
format: format, | ||
width: clonedGd._fullLayout.width, | ||
height: clonedGd._fullLayout.height, | ||
canvas: canvas, | ||
svg: svg, | ||
// ask svgToImg to return a Promise | ||
// rather than EventEmitter | ||
// leave EventEmitter for backward | ||
// compatibility | ||
promise: true | ||
}) | ||
.then(function(url) { | ||
Plotly.purge(clonedGd); | ||
document.body.removeChild(clonedGd); | ||
resolve(url); | ||
}) | ||
.catch(function(err) { | ||
reject(err); | ||
}); | ||
}); | ||
} | ||
|
||
function urlToImageData(url) { | ||
if(imageDataOnly) { | ||
return url.replace(IMAGE_URL_PREFIX, ''); | ||
} else { | ||
return url; | ||
} | ||
} | ||
|
||
var redrawFunc = helpers.getRedrawFunc(clonedGd); | ||
|
||
Plotly.plot(clonedGd, clone.data, clone.layout, clone.config) | ||
return new Promise(function(resolve, reject) { | ||
Plotly.plot(clonedGd, data, layoutImage, configImage) | ||
.then(redrawFunc) | ||
.then(wait) | ||
.then(function(url) { resolve(url); }) | ||
.catch(function(err) { | ||
reject(err); | ||
}); | ||
.then(convert) | ||
.then(function(url) { resolve(urlToImageData(url)); }) | ||
.catch(function(err) { reject(err); }); | ||
}); | ||
|
||
return promise; | ||
} | ||
|
||
module.exports = toImage; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice job using
coerce
for this! ButisBadlySet
is confusing me... it'strue
if you either haven't set the attribute or if it has a valid input? Wouldn't that beisValidOrImplied
or something? Anyway I don't see how you can use the same logic forformat
(which has a default) as forwidth
andheight
(which don't).Dimensions you could just look at
fullOpts
aftercoerce
to see if you have something that's notundefined
(the implicitdflt
)...There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh I see, it is OK to omit dimensions, then they default to what's in the layout... might be clearer if this was just used as the default
width = coerce('width', layout.width)
?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
right, but
layout.width
might not exist at this stage.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OK, but if it's not in
opts
and it's not inlayout
then what width do we get?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We get the dflt from
plots/layout_attributes.js
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Alright, I don't want to beat a dead horse and it's not a big deal, but I believe it would still work just fine if you coerce with a
dflt
which might beundefined
at this point, then you coerce with the realdflt
later on, overriding thatundefined
.Anyway my initial comment stands, about
isBadlySet
being misnamed and confusing logic.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
isBadlySet -> isImpliedOrValid in 3c42efb