diff --git a/snapshot.md b/snapshot.md new file mode 100644 index 00000000000..2b8450012e9 --- /dev/null +++ b/snapshot.md @@ -0,0 +1,48 @@ +# Plotly Snapshots + +## Purpose +The purpose of this markdown document is to document exploration of how to best attach the `Plotly.Snapshot.toImage` function to the plot/`div` itself most fully discussed in [issue 83](https://github.com/plotly/plotly.js/issues/83). Another very nice ability would be to offer resize options for the snapshot. + + + +## Questions +Where do we attach toImage on the graph div? + Is it _toImage? + Do we just require /snapshot and bind to `this`? + +Will any of the chart types require special snapshot abilities or features? + +What is the expected use case of our new ability? + +How do we piggyback on the snapshot button in the toolbar? + +How do we ask for new size? + +Are there reference points from other libraries that we could mimic or learn from? + + +## Thoughts + +- `Plotly.Snapshot.clone` could be used to resize by adding this to `options` when/if we use `Plotly.plot` with our cloned `div`. We could also dynamically show a resulting view in a modal or something similar and adjust with `Plotly.relayout`. + +- `Plotly.Snapshot.clone` by default sets `staticPlot:true` in `config`. + +- A very basic way to attach this assuming there is a modebar would be to do something like this. See [codepen](http://codepen.io/timelyportfolio/pen/ZWvyYM). +``` +gd._toImage = function(){ + this._fullLayout._modeBar.buttons.filter( + function(btn){return btn[0].name==="toImage" + })[0][0].click(this) +} +``` + +- `Plotly.Snapshot.clone` already has thumbnail ability by specifying [options tileClass:"thumbnail"](https://github.com/plotly/plotly.js/blob/master/src/snapshot/cloneplot.js#L76) for the specific thumbnail use case. + + + +- Quick code to experiment from R +``` +library(plotly) + +ggplotly(ggplot(cars,aes(speed,dist))+geom_point()) +``` \ No newline at end of file diff --git a/src/components/modebar/buttons.js b/src/components/modebar/buttons.js index 67d118fc3b9..f40b6e16443 100644 --- a/src/components/modebar/buttons.js +++ b/src/components/modebar/buttons.js @@ -11,7 +11,7 @@ var Plotly = require('../../plotly'); var Lib = require('../../lib'); -var Snapshot = require('../../snapshot'); +var downloadImage = require('../../snapshot/download'); var Icons = require('../../../build/ploticon'); @@ -48,48 +48,22 @@ modeBarButtons.toImage = { title: 'Download plot as a png', icon: Icons.camera, click: function(gd) { - var format = 'png'; - if(Lib.isIE()) { Lib.notifier('Snapshotting is unavailable in Internet Explorer. ' + 'Consider exporting your images using the Plotly Cloud', 'long'); return; } - + if(gd._snapshotInProgress) { Lib.notifier('Snapshotting is still in progress - please hold', 'long'); return; } - + gd._snapshotInProgress = true; Lib.notifier('Taking snapshot - this may take a few seconds', 'long'); - var ev = Snapshot.toImage(gd, {format: format}); - - var filename = gd.fn || 'newplot'; - filename += '.' + format; - - ev.once('success', function(result) { - gd._snapshotInProgress = false; - - var downloadLink = document.createElement('a'); - downloadLink.href = result; - downloadLink.download = filename; // only supported by FF and Chrome - - document.body.appendChild(downloadLink); - downloadLink.click(); - document.body.removeChild(downloadLink); - - ev.clean(); - }); - - ev.once('error', function(err) { - gd._snapshotInProgress = false; - - Lib.notifier('Sorry there was a problem downloading your ' + format, 'long'); - console.error(err); - - ev.clean(); + downloadImage(gd).catch(function(err){ + Lib.notifier('Sorry there was a problem downloading your snapshot', 'long'); }); } }; diff --git a/src/core.js b/src/core.js index 00da65fcfba..8cc898d1f10 100644 --- a/src/core.js +++ b/src/core.js @@ -31,6 +31,8 @@ exports.moveTraces = Plotly.moveTraces; exports.purge = Plotly.purge; exports.setPlotConfig = require('./plot_api/set_plot_config'); exports.register = Plotly.register; +exports.toImage = require('./plot_api/to_image'); +exports.downloadImage = require('./snapshot/download'); // plot icons exports.Icons = require('../build/ploticon'); diff --git a/src/plot_api/to_image.js b/src/plot_api/to_image.js new file mode 100644 index 00000000000..ccfc34ff980 --- /dev/null +++ b/src/plot_api/to_image.js @@ -0,0 +1,106 @@ +/** +* Copyright 2012-2016, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + +/*eslint dot-notation: [2, {"allowPattern": "^catch$"}]*/ + +'use strict'; + +var Plotly = require('../plotly'); + +var isNumeric = require('fast-isnumeric'); + +/** + * @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 + */ +function toImage(gd, opts) { + var Snapshot = require('../snapshot'); + + var promise = new Promise(function(resolve, reject) { + // check for undefined opts + opts = opts || {}; + // default to png + opts.format = opts.format || 'png'; + + if( + (opts.width && isNumeric(opts.width) && opts.width < 1) || + (opts.height && isNumeric(opts.height) && opts.height < 1) + ) { + reject(new Error('Height and width should be pixel values.')); + } + + // first clone the GD so we can operate in a clean environment + var clone = Snapshot.clone(gd, {format: 'png', height: opts.height, width: opts.width}); + var clonedGd = clone.td; + + // 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 = Snapshot.getDelay(clonedGd._fullLayout); + + return new Promise(function(resolve, reject) { + setTimeout(function() { + var svg = Snapshot.toSVG(clonedGd); + + var canvasContainer = window.document.createElement('div'); + var canvas = window.document.createElement('canvas'); + + // window.document.body.appendChild(canvasContainer); + canvasContainer.appendChild(canvas); + + canvasContainer.id = Plotly.Lib.randstr(); + canvas.id = Plotly.Lib.randstr(); + + Snapshot.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) clonedGd.remove(); + resolve(url); + }).catch(function(err) { + reject(err); + }); + }, delay); + }); + } + + var redrawFunc = Snapshot.getRedrawFunc(clonedGd); + + Plotly.plot(clonedGd, clone.data, clone.layout, clone.config) + // TODO: the following is Plotly.Plots.redrawText but without the waiting. + // we shouldn't need to do this, but in *occasional* cases we do. Figure + // out why and take it out. + + // not sure the above TODO makes sense anymore since + // we have converted to promises + .then(redrawFunc) + .then(wait) + .then(function(url) { resolve(url); }) + .catch(function(err) { + reject(err); + }); + }); + + return promise; +} + +module.exports = toImage; diff --git a/src/snapshot/download.js b/src/snapshot/download.js new file mode 100644 index 00000000000..c799c1f5914 --- /dev/null +++ b/src/snapshot/download.js @@ -0,0 +1,62 @@ +/** +* Copyright 2012-2016, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + + +'use strict'; + +var toImage = require('../plot_api/to_image'); + +/** + * @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 + * @param opts.filename name of file excluding extension + */ +function downloadImage(gd, opts) { + + // check for undefined opts + opts = opts || {}; + + // default to png + opts.format = opts.format || 'png'; + + return new Promise(function(resolve,reject){ + if(gd._snapshotInProgress){ + reject(new Error('Snapshotting is unavailable in Internet Explorer. ' + + 'Consider exporting your images using the Plotly Cloud')); + } + + gd._snapshotInProgress = true; + var promise = toImage(gd, opts); + + var filename = opts.filename || gd.fn || 'newplot'; + filename += '.' + opts.format; + + promise.then(function(result) { + gd._snapshotInProgress = false; + + var downloadLink = document.createElement('a'); + downloadLink.href = result; + downloadLink.download = filename; // only supported by FF and Chrome + + document.body.appendChild(downloadLink); + downloadLink.click(); + document.body.removeChild(downloadLink); + resolve(); + }) + .catch(function(err) { + gd._snapshotInProgress = false; + console.error(err); + reject(err); + }); + }); +}; + +module.exports = downloadImage; diff --git a/src/snapshot/index.js b/src/snapshot/index.js index 2b662846928..5bfd8a0dfa1 100644 --- a/src/snapshot/index.js +++ b/src/snapshot/index.js @@ -34,7 +34,8 @@ var Snapshot = { clone: require('./cloneplot'), toSVG: require('./tosvg'), svgToImg: require('./svgtoimg'), - toImage: require('./toimage') + toImage: require('./toimage'), + downloadImage: require('./download') }; module.exports = Snapshot; diff --git a/src/snapshot/svgtoimg.js b/src/snapshot/svgtoimg.js index 8dc868cb937..d1298a0079f 100644 --- a/src/snapshot/svgtoimg.js +++ b/src/snapshot/svgtoimg.js @@ -6,64 +6,87 @@ * LICENSE file in the root directory of this source tree. */ +var EventEmitter = require('events').EventEmitter; 'use strict'; -var EventEmitter = require('events').EventEmitter; - function svgToImg(opts) { - - var ev = opts.emitter ? opts.emitter : new EventEmitter(); - - var Image = window.Image; - var Blob = window.Blob; - - var svg = opts.svg; - var format = opts.format || 'png'; - var canvas = opts.canvas; - - var ctx = canvas.getContext('2d'); - var img = new Image(); - var DOMURL = window.URL || window.webkitURL; - var svgBlob = new Blob([svg], {type: 'image/svg+xml;charset=utf-8'}); - var url = DOMURL.createObjectURL(svgBlob); - - canvas.height = opts.height || 150; - canvas.width = opts.width || 300; - - img.onload = function() { - var imgData; - - DOMURL.revokeObjectURL(url); - ctx.drawImage(img, 0, 0); - - switch(format) { - case 'jpeg': - imgData = canvas.toDataURL('image/jpeg'); - break; - case 'png': - imgData = canvas.toDataURL('image/png'); - break; - case 'webp': - imgData = canvas.toDataURL('image/webp'); - break; - case 'svg': - imgData = svg; - break; - default: - return ev.emit('error', 'Image format is not jpeg, png or svg'); - } - - ev.emit('success', imgData); - }; - - img.onerror = function(err) { - DOMURL.revokeObjectURL(url); - return ev.emit('error', err); - }; - - img.src = url; - + + var ev = opts.emitter || new EventEmitter(); + + var promise = new Promise(function(resolve, reject) { + + var Image = window.Image; + var Blob = window.Blob; + + var svg = opts.svg; + var format = opts.format || 'png'; + var canvas = opts.canvas; + + var ctx = canvas.getContext('2d'); + var img = new Image(); + var DOMURL = window.URL || window.webkitURL; + var svgBlob = new Blob([svg], {type: 'image/svg+xml;charset=utf-8'}); + var url = DOMURL.createObjectURL(svgBlob); + + canvas.height = opts.height || 150; + canvas.width = opts.width || 300; + + img.onload = function() { + var imgData; + + DOMURL.revokeObjectURL(url); + ctx.drawImage(img, 0, 0); + + switch(format) { + case 'jpeg': + imgData = canvas.toDataURL('image/jpeg'); + break; + case 'png': + imgData = canvas.toDataURL('image/png'); + break; + case 'webp': + imgData = canvas.toDataURL('image/webp'); + break; + case 'svg': + imgData = svg; + break; + default: + reject(new Error('Image format is not jpeg, png or svg')); + // eventually remove the ev + // in favor of promises + if(!opts.promise){ + return ev.emit('error', 'Image format is not jpeg, png or svg'); + } + } + resolve(imgData); + // eventually remove the ev + // in favor of promises + if(!opts.promise){ + ev.emit('success', imgData); + } + }; + + img.onerror = function(err) { + DOMURL.revokeObjectURL(url); + reject(err); + // eventually remove the ev + // in favor of promises + if(!opts.promise){ + return ev.emit('error', err); + } + }; + + img.src = url; + }); + + // temporary for backward compatibility + // move to only Promise in 2.0.0 + // and eliminate the EventEmitter + if(opts.promise) { + return promise; + } + return ev; } diff --git a/test/jasmine/tests/toimage_test.js b/test/jasmine/tests/toimage_test.js new file mode 100644 index 00000000000..2270edec4ae --- /dev/null +++ b/test/jasmine/tests/toimage_test.js @@ -0,0 +1,107 @@ +// move toimage to plot_api_test.js +// once established and confirmed + +var Plotly = require('@lib/index'); +var createGraphDiv = require('../assets/create_graph_div'); +var destroyGraphDiv = require('../assets/destroy_graph_div'); +var subplotMock = require('../../image/mocks/multiple_subplots.json'); +var plot3dMock = require('../../image/mocks/gl3d_bunny.json'); + + +describe('Plotly.toImage', function() { + 'use strict'; + var gd; + + beforeEach(function() { + gd = createGraphDiv(); + }); + + afterEach(destroyGraphDiv); + + it('should be attached to Plotly', function() { + expect(Plotly.toImage).toBeDefined(); + }); + + it('should return a promise', function(done) { + function isPromise(x) { + return !!x.then || typeof x.then === 'function'; + } + + var returnValue = Plotly.plot(gd, subplotMock.data, subplotMock.layout) + .then(Plotly.toImage); + + expect(isPromise(returnValue)).toBe(true); + + returnValue.then(done); + }); + + it('should throw error with unsupported file type', function(done) { + // error should actually come in the svgToImg step + + Plotly.plot(gd, subplotMock.data, subplotMock.layout) + .then(function(gd) { + Plotly.toImage(gd, {format: 'x'}).catch(function(err) { + expect(err.message).toEqual('Image format is not jpeg, png or svg'); + done(); + }); + }); + + }); + + it('should throw error with height and width < 1', function(done) { + // let user know that Plotly expects pixel values + Plotly.plot(gd, subplotMock.data, subplotMock.layout) + .then(function(gd) { + Plotly.toImage(gd, {height: 0.5}).catch(function(err){ + expect(err.message).toEqual('Height and width should be pixel values.'); + done(); + }); + }); + }); + + it('should create img with proper height and width', function(done) { + var img = document.createElement('img'); + + // specify height and width + subplotMock.layout.height = 600; + subplotMock.layout.width = 700; + + Plotly.plot(gd, subplotMock.data, subplotMock.layout).then(function(gd) { + return Plotly.toImage(gd); + }).then(function(url) { + img.src = url; + expect(img.height).toBe(600); + expect(img.width).toBe(700); + // now provide height and width in opts + return Plotly.toImage(gd, {height: 400, width: 400}); + }).then(function(url) { + img.src = url; + expect(img.height).toBe(400); + expect(img.width).toBe(400); + done(); + }); + }); + + it('should create proper file type', function(done) { + var plot = Plotly.plot(gd, subplotMock.data, subplotMock.layout); + + plot.then(function(gd) { + return Plotly.toImage(gd, {format: 'png'}); + }).then(function(url) { + expect(url.substr(0,15)).toBe('data:image/png;'); + // now do jpeg + return Plotly.toImage(gd, {format: 'jpeg'}); + }).then(function(url) { + expect(url.substr(0,16)).toBe('data:image/jpeg;'); + // now do webp + return Plotly.toImage(gd, {format: 'webp'}); + }).then(function(url) { + expect(url.substr(0,16)).toBe('data:image/webp;'); + // now do svg + return Plotly.toImage(gd, {format: 'svg'}); + }).then(function(url) { + expect(url.substr(1,3)).toBe('svg'); + done(); + }); + }); +});