Skip to content

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

Merged
merged 16 commits into from
Aug 14, 2017
Merged
Show file tree
Hide file tree
Changes from 6 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
31 changes: 27 additions & 4 deletions src/plot_api/plot_api.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ var Polar = require('../plots/polar');
var initInteractions = require('../plots/cartesian/graph_interact');

var Drawing = require('../components/drawing');
var Color = require('../components/color');
var ErrorBars = require('../components/errorbars');
var xmlnsNamespaces = require('../constants/xmlns_namespaces');
var svgTextUtils = require('../lib/svg_text_utils');
Expand Down Expand Up @@ -390,10 +391,22 @@ Plotly.plot = function(gd, data, layout, config) {
});
};

function setBackground(gd, bgColor) {
try {
gd._fullLayout._paper.style('background', bgColor);
} catch(e) {
Lib.error(e);
}
}

function opaqueSetBackground(gd, bgColor) {
gd._fullLayout._paperdiv.style('background', 'white');
Plotly.defaultConfig.setBackground(gd, bgColor);
setBackground(gd, bgColor);
}

function blendSetBackground(gd, bgColor) {
var blend = Color.combine(bgColor, 'white');
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Implements this (old) image server logic in plotly.js

Copy link
Collaborator

Choose a reason for hiding this comment

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

doesn't this end up visually equivalent to opaqueSetBackground? And if so can we remove opaqueSetBackground?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Maybe?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

RE 'opaque' vs 'blend', they don't generate the same PNG (gm compare generates a diff) but I can't find a case where the diff is detectable to my 👀

So, I guess a can make the new image server use 'opaque' instead of 'blend' and 🔪 blendSetBackground.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

done in 22a598b

setBackground(gd, blend);
}

function setPlotContext(gd, config) {
Expand All @@ -408,10 +421,15 @@ function setPlotContext(gd, config) {
key = keys[i];
if(key === 'editable' || key === 'edits') continue;
if(key in context) {
if(key === 'setBackground' && config[key] === 'opaque') {
context[key] = opaqueSetBackground;
if(key === 'setBackground') {
if(config[key] === 'opaque') {
context[key] = opaqueSetBackground;
} else if(config[key] === 'blend') {
context[key] = blendSetBackground;
}
} else {
context[key] = config[key];
}
else context[key] = config[key];
}
}

Expand Down Expand Up @@ -460,6 +478,11 @@ function setPlotContext(gd, config) {
if(context.displayModeBar === 'hover' && !hasHover) {
context.displayModeBar = true;
}

// default and fallback for setBackground
if(context.setBackground === 'transparent' || typeof context.setBackground !== 'function') {
context.setBackground = setBackground;
}
}

function plotPolar(gd, data, layout) {
Expand Down
21 changes: 4 additions & 17 deletions src/plot_api/plot_config.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@

'use strict';

/* eslint-disable no-console */

/**
* This will be transferred over to gd and overridden by
* config args to Plotly.plot.
Expand Down Expand Up @@ -109,8 +107,10 @@ module.exports = {
plotGlPixelRatio: 2,

// function to add the background color to a different container
// or 'opaque' to ensure there's white behind it
setBackground: defaultSetBackground,
// or 'opaque' to ensure there's white behind it,
// or 'blend' to blend bg color with white,
// or any other custom function of gd
setBackground: 'transparent',

// URL to topojson files used in geo charts
topojsonURL: 'https://cdn.plot.ly/',
Expand All @@ -128,16 +128,3 @@ module.exports = {
// specification needed
globalTransforms: []
};

// where and how the background gets set can be overridden by context
// so we define the default (plotly.js) behavior here
function defaultSetBackground(gd, bgColor) {
try {
gd._fullLayout._paper.style('background', bgColor);
}
catch(e) {
if(module.exports.logging > 0) {
console.error(e);
}
}
}
240 changes: 165 additions & 75 deletions src/plot_api/to_image.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
}
Copy link
Collaborator

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! But isBadlySet is confusing me... it's true if you either haven't set the attribute or if it has a valid input? Wouldn't that be isValidOrImplied or something? Anyway I don't see how you can use the same logic for format (which has a default) as for width and height (which don't).

Dimensions you could just look at fullOpts after coerce to see if you have something that's not undefined (the implicit dflt)...

Copy link
Collaborator

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)?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

might be clearer if this was just used as the default width = coerce('width', layout.width)?

right, but layout.width might not exist at this stage.

Copy link
Collaborator

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 in layout then what width do we get?

Copy link
Contributor Author

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

Copy link
Collaborator

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 be undefined at this point, then you coerce with the real dflt later on, overriding that undefined.

Anyway my initial comment stands, about isBadlySet being misnamed and confusing logic.

Copy link
Contributor Author

@etpinard etpinard Aug 14, 2017

Choose a reason for hiding this comment

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

isBadlySet -> isImpliedOrValid in 3c42efb


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,
Copy link
Collaborator

Choose a reason for hiding this comment

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

is plotGlPixelRatio something people might want to override? Would it work if they could?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, potentially. Good 👀

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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);
Copy link
Contributor Author

@etpinard etpinard Aug 8, 2017

Choose a reason for hiding this comment

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

Fast pass for svg with imageDataOnly where Snapshot.toSVG gives the correct result.

Note that PDF and EPS images should first be generated as svg and then converted to pdf or eps using another library. In-house PDF and EPS image generation is outside the scope of plotly.js.

Copy link
Collaborator

Choose a reason for hiding this comment

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

is it worth handling imageDataOnly=false here too - and just adding the prefix? Or is it more complicated than that?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, it shouldn't be too hard. I'll add that in.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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;
2 changes: 2 additions & 0 deletions src/plots/gl2d/scene2d.js
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,8 @@ proto.handleAnnotations = function() {
};

proto.destroy = function() {
if(!this.glplot) return;

var traces = this.traces;

if(traces) {
Expand Down
2 changes: 2 additions & 0 deletions src/plots/gl3d/scene.js
Original file line number Diff line number Diff line change
Expand Up @@ -593,6 +593,8 @@ proto.plot = function(sceneData, fullLayout, layout) {
};

proto.destroy = function() {
if(!this.glplot) return;

this.camera.mouseListener.enabled = false;
this.container.removeEventListener('wheel', this.camera.wheelListener);
this.camera = this.glplot.camera = null;
Expand Down
2 changes: 1 addition & 1 deletion src/plots/mapbox/mapbox.js
Original file line number Diff line number Diff line change
Expand Up @@ -454,8 +454,8 @@ proto.destroy = function() {
if(this.map) {
this.map.remove();
this.map = null;
this.container.removeChild(this.div);
}
this.container.removeChild(this.div);
};

proto.toImage = function() {
Expand Down
Loading