diff --git a/src/components/images/attributes.js b/src/components/images/attributes.js new file mode 100644 index 00000000000..a7f35575256 --- /dev/null +++ b/src/components/images/attributes.js @@ -0,0 +1,158 @@ +/** +* 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 cartesianConstants = require('../../plots/cartesian/constants'); + + +module.exports = { + _isLinkedToArray: true, + + source: { + valType: 'string', + role: 'info', + description: [ + 'Specifies the URL of the image to be used.', + 'The URL must be accessible from the domain where the', + 'plot code is run, and can be either relative or absolute.' + + ].join(' ') + }, + + layer: { + valType: 'enumerated', + values: ['below', 'above'], + dflt: 'above', + role: 'info', + description: [ + 'Specifies whether images are drawn below or above traces.', + 'When `xref` and `yref` are both set to `paper`,', + 'image is drawn below the entire plot area.' + ].join(' ') + }, + + sizex: { + valType: 'number', + role: 'info', + dflt: 0, + description: [ + 'Sets the image container size horizontally.', + 'The image will be sized based on the `position` value.', + 'When `xref` is set to `paper`, units are sized relative', + 'to the plot width.' + ].join(' ') + }, + + sizey: { + valType: 'number', + role: 'info', + dflt: 0, + description: [ + 'Sets the image container size vertically.', + 'The image will be sized based on the `position` value.', + 'When `yref` is set to `paper`, units are sized relative', + 'to the plot height.' + ].join(' ') + }, + + sizing: { + valType: 'enumerated', + values: ['fill', 'contain', 'stretch'], + dflt: 'contain', + role: 'info', + description: [ + 'Specifies which dimension of the image to constrain.' + ].join(' ') + }, + + opacity: { + valType: 'number', + role: 'info', + min: 0, + max: 1, + dflt: 1, + description: 'Sets the opacity of the image.' + }, + + x: { + valType: 'number', + role: 'info', + dflt: 0, + description: [ + 'Sets the image\'s x position.', + 'When `xref` is set to `paper`, units are sized relative', + 'to the plot height.', + 'See `xref` for more info' + ].join(' ') + }, + + y: { + valType: 'number', + role: 'info', + dflt: 0, + description: [ + 'Sets the image\'s y position.', + 'When `yref` is set to `paper`, units are sized relative', + 'to the plot height.', + 'See `yref` for more info' + ].join(' ') + }, + + xanchor: { + valType: 'enumerated', + values: ['left', 'center', 'right'], + dflt: 'left', + role: 'info', + description: 'Sets the anchor for the x position' + }, + + yanchor: { + valType: 'enumerated', + values: ['top', 'middle', 'bottom'], + dflt: 'top', + role: 'info', + description: 'Sets the anchor for the y position.' + }, + + xref: { + valType: 'enumerated', + values: [ + 'paper', + cartesianConstants.idRegex.x.toString() + ], + dflt: 'paper', + role: 'info', + description: [ + 'Sets the images\'s x coordinate axis.', + 'If set to a x axis id (e.g. *x* or *x2*), the `x` position', + 'refers to an x data coordinate', + 'If set to *paper*, the `x` position refers to the distance from', + 'the left of plot in normalized coordinates', + 'where *0* (*1*) corresponds to the left (right).' + ].join(' ') + }, + + yref: { + valType: 'enumerated', + values: [ + 'paper', + cartesianConstants.idRegex.y.toString() + ], + dflt: 'paper', + role: 'info', + description: [ + 'Sets the images\'s y coordinate axis.', + 'If set to a y axis id (e.g. *y* or *y2*), the `y` position', + 'refers to a y data coordinate.', + 'If set to *paper*, the `y` position refers to the distance from', + 'the bottom of the plot in normalized coordinates', + 'where *0* (*1*) corresponds to the bottom (top).' + ].join(' ') + } +}; diff --git a/src/components/images/defaults.js b/src/components/images/defaults.js new file mode 100644 index 00000000000..1be89b4f05a --- /dev/null +++ b/src/components/images/defaults.js @@ -0,0 +1,64 @@ +/** +* 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 Axes = require('../../plots/cartesian/axes'); +var Lib = require('../../lib'); +var attributes = require('./attributes'); + + +module.exports = function supplyLayoutDefaults(layoutIn, layoutOut) { + + if(!layoutIn.images || !Array.isArray(layoutIn.images)) return; + + + var containerIn = layoutIn.images, + containerOut = layoutOut.images = []; + + + for(var i = 0; i < containerIn.length; i++) { + var image = containerIn[i]; + + if(!image.source) continue; + + var defaulted = imageDefaults(containerIn[i] || {}, containerOut[i] || {}, layoutOut); + containerOut.push(defaulted); + } +}; + + +function imageDefaults(imageIn, imageOut, fullLayout) { + + imageOut = imageOut || {}; + + function coerce(attr, dflt) { + return Lib.coerce(imageIn, imageOut, attributes, attr, dflt); + } + + coerce('source'); + coerce('layer'); + coerce('x'); + coerce('y'); + coerce('xanchor'); + coerce('yanchor'); + coerce('sizex'); + coerce('sizey'); + coerce('sizing'); + coerce('opacity'); + + for(var i = 0; i < 2; i++) { + var tdMock = { _fullLayout: fullLayout }, + axLetter = ['x', 'y'][i]; + + // 'paper' is the fallback axref + Axes.coerceRef(imageIn, imageOut, tdMock, axLetter, 'paper'); + } + + return imageOut; +} diff --git a/src/components/images/draw.js b/src/components/images/draw.js new file mode 100644 index 00000000000..a489c9eb026 --- /dev/null +++ b/src/components/images/draw.js @@ -0,0 +1,171 @@ +/** +* 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 d3 = require('d3'); +var Drawing = require('../drawing'); +var Axes = require('../../plots/cartesian/axes'); + +module.exports = function draw(gd) { + + var fullLayout = gd._fullLayout, + imageDataAbove = [], + imageDataSubplot = [], + imageDataBelow = []; + + if(!fullLayout.images) return; + + + // Sort into top, subplot, and bottom layers + for(var i = 0; i < fullLayout.images.length; i++) { + var img = fullLayout.images[i]; + + if(img.layer === 'below' && img.xref !== 'paper' && img.yref !== 'paper') { + imageDataSubplot.push(img); + } else if(img.layer === 'above') { + imageDataAbove.push(img); + } else { + imageDataBelow.push(img); + } + } + + + var anchors = { + x: { + left: { sizing: 'xMin', offset: 0 }, + center: { sizing: 'xMid', offset: -1 / 2 }, + right: { sizing: 'xMax', offset: -1 } + }, + y: { + top: { sizing: 'YMin', offset: 0 }, + middle: { sizing: 'YMid', offset: -1 / 2 }, + bottom: { sizing: 'YMax', offset: -1 } + } + }; + + + // Images must be converted to dataURL's for exporting. + function setImage(d) { + + var thisImage = d3.select(this); + + var imagePromise = new Promise(function(resolve) { + + var img = new Image(); + + // If not set, a `tainted canvas` error is thrown + img.setAttribute('crossOrigin', 'anonymous'); + img.onerror = errorHandler; + img.onload = function() { + + var canvas = document.createElement('canvas'); + canvas.width = this.width; + canvas.height = this.height; + + var ctx = canvas.getContext('2d'); + ctx.drawImage(this, 0, 0); + + var dataURL = canvas.toDataURL('image/png'); + + thisImage.attr('xlink:href', dataURL); + }; + + + thisImage.on('error', errorHandler); + thisImage.on('load', resolve); + + img.src = d.source; + + function errorHandler() { + thisImage.remove(); + resolve(); + } + }); + + gd._promises.push(imagePromise); + } + + function applyAttributes(d) { + + var thisImage = d3.select(this); + + // Axes if specified + var xref = Axes.getFromId(gd, d.xref), + yref = Axes.getFromId(gd, d.yref); + + var size = fullLayout._size, + width = xref ? Math.abs(xref.l2p(d.sizex) - xref.l2p(0)) : d.sizex * size.w, + height = yref ? Math.abs(yref.l2p(d.sizey) - yref.l2p(0)) : d.sizey * size.h; + + // Offsets for anchor positioning + var xOffset = width * anchors.x[d.xanchor].offset + size.l, + yOffset = height * anchors.y[d.yanchor].offset + size.t; + + var sizing = anchors.x[d.xanchor].sizing + anchors.y[d.yanchor].sizing; + + // Final positions + var xPos = (xref ? xref.l2p(d.x) : d.x * size.w) + xOffset, + yPos = (yref ? yref.l2p(d.y) : size.h - d.y * size.h) + yOffset; + + + // Construct the proper aspectRatio attribute + switch(d.sizing) { + case 'fill': + sizing += ' slice'; + break; + + case 'stretch': + sizing = 'none'; + break; + } + + thisImage.attr({ + x: xPos, + y: yPos, + width: width, + height: height, + preserveAspectRatio: sizing, + opacity: d.opacity + }); + + + // Set proper clipping on images + var xId = xref ? xref._id : '', + yId = yref ? yref._id : '', + clipAxes = xId + yId; + + thisImage.call(Drawing.setClipUrl, 'clip' + fullLayout._uid + clipAxes); + } + + + // Required for updating images + function keyFunction(d) { + return d.source; + } + + + var imagesBelow = fullLayout._imageLowerLayer.selectAll('image') + .data(imageDataBelow, keyFunction), + imagesSubplot = fullLayout._imageSubplotLayer.selectAll('image') + .data(imageDataSubplot, keyFunction), + imagesAbove = fullLayout._imageUpperLayer.selectAll('image') + .data(imageDataAbove, keyFunction); + + imagesBelow.enter().append('image').each(setImage); + imagesSubplot.enter().append('image').each(setImage); + imagesAbove.enter().append('image').each(setImage); + + imagesBelow.exit().remove(); + imagesSubplot.exit().remove(); + imagesAbove.exit().remove(); + + imagesBelow.each(applyAttributes); + imagesSubplot.each(applyAttributes); + imagesAbove.each(applyAttributes); +}; diff --git a/src/components/images/index.js b/src/components/images/index.js new file mode 100644 index 00000000000..227b2ee1b03 --- /dev/null +++ b/src/components/images/index.js @@ -0,0 +1,21 @@ +/** +* 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 draw = require('./draw'); +var supplyLayoutDefaults = require('./defaults'); +var attributes = require('./attributes'); + + +module.exports = { + draw: draw, + layoutAttributes: attributes, + supplyLayoutDefaults: supplyLayoutDefaults +}; diff --git a/src/components/rangeslider/defaults.js b/src/components/rangeslider/defaults.js index f0d0efeebd7..0095c7b243e 100644 --- a/src/components/rangeslider/defaults.js +++ b/src/components/rangeslider/defaults.js @@ -21,8 +21,7 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, axName, coun containerOut = layoutOut[axName].rangeslider = {}; function coerce(attr, dflt) { - return Lib.coerce(containerIn, containerOut, - attributes, attr, dflt); + return Lib.coerce(containerIn, containerOut, attributes, attr, dflt); } coerce('bgcolor'); diff --git a/src/components/shapes/index.js b/src/components/shapes/index.js index a3eebd52ff9..0e493e7a287 100644 --- a/src/components/shapes/index.js +++ b/src/components/shapes/index.js @@ -98,7 +98,7 @@ shapes.drawAll = function(gd) { // Remove previous shapes before drawing new in shapes in fullLayout.shapes fullLayout._shapeUpperLayer.selectAll('path').remove(); fullLayout._shapeLowerLayer.selectAll('path').remove(); - fullLayout._subplotShapeLayer.selectAll('path').remove(); + fullLayout._shapeSubplotLayer.selectAll('path').remove(); for(var i = 0; i < fullLayout.shapes.length; i++) { shapes.draw(gd, i); @@ -356,7 +356,7 @@ function getShapeLayer(gd, index) { else if(shape.layer === 'below') { shapeLayer = (shape.xref === 'paper' && shape.yref === 'paper') ? gd._fullLayout._shapeLowerLayer : - gd._fullLayout._subplotShapeLayer; + gd._fullLayout._shapeSubplotLayer; } return shapeLayer; diff --git a/src/lib/index.js b/src/lib/index.js index 9f8d40b0928..eb4f17d844c 100644 --- a/src/lib/index.js +++ b/src/lib/index.js @@ -486,3 +486,58 @@ lib.setTranslate = function(element, x, y) { lib.isIE = function() { return typeof window.navigator.msSaveBlob !== 'undefined'; }; + + +/** + * Converts a string path to an object. + * + * When given a string containing an array element, it will create a `null` + * filled array of the given size. + * + * @example + * lib.objectFromPath('nested.test[2].path', 'value'); + * // returns { nested: { test: [null, null, { path: 'value' }]} + * + * @param {string} path to nested value + * @param {*} any value to be set + * + * @return {Object} the constructed object with a full nested path + */ +lib.objectFromPath = function(path, value) { + var keys = path.split('.'), + tmpObj, + obj = tmpObj = {}; + + for(var i = 0; i < keys.length; i++) { + var key = keys[i]; + var el = null; + + var parts = keys[i].match(/(.*)\[([0-9]+)\]/); + + if(parts) { + key = parts[1]; + el = parts[2]; + + tmpObj = tmpObj[key] = []; + + if(i === keys.length - 1) { + tmpObj[el] = value; + } else { + tmpObj[el] = {}; + } + + tmpObj = tmpObj[el]; + } else { + + if(i === keys.length - 1) { + tmpObj[key] = value; + } else { + tmpObj[key] = {}; + } + + tmpObj = tmpObj[key]; + } + } + + return obj; +}; diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index ab707c30cf2..d43276ff186 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -24,6 +24,7 @@ var Fx = require('../plots/cartesian/graph_interact'); var Color = require('../components/color'); var Drawing = require('../components/drawing'); var ErrorBars = require('../components/errorbars'); +var Images = require('../components/images'); var Legend = require('../components/legend'); var RangeSlider = require('../components/rangeslider'); var RangeSelector = require('../components/rangeselector'); @@ -301,6 +302,7 @@ Plotly.plot = function(gd, data, layout, config) { // be set to false before these will work properly. function finalDraw() { Shapes.drawAll(gd); + Images.draw(gd); Plotly.Annotations.drawAll(gd); Legend.draw(gd); RangeSlider.draw(gd); @@ -2297,6 +2299,12 @@ Plotly.relayout = function relayout(gd, astr, val) { // as it is we get separate calls for x and y (or ax and ay) on move objModule.draw(gd, objNum, p.parts.slice(2).join('.'), aobj[ai]); delete aobj[ai]; + } else if(p.parts[0] === 'images') { + var update = Lib.objectFromPath(astr, vi); + Lib.extendDeepAll(gd.layout, update); + + Images.supplyLayoutDefaults(gd.layout, gd._fullLayout); + Images.draw(gd); } // alter gd.layout else { @@ -2644,6 +2652,8 @@ function makePlotFramework(gd) { // (only for shapes to be drawn below the whole plot) var layerBelow = fullLayout._paper.append('g') .classed('layer-below', true); + fullLayout._imageLowerLayer = layerBelow.append('g') + .classed('imagelayer', true); fullLayout._shapeLowerLayer = layerBelow.append('g') .classed('shapelayer', true); @@ -2658,13 +2668,16 @@ function makePlotFramework(gd) { fullLayout._ternarylayer = fullLayout._paper.append('g').classed('ternarylayer', true); // shape layers in subplots - fullLayout._subplotShapeLayer = fullLayout._paper - .selectAll('.shapelayer-subplot'); + var layerSubplot = fullLayout._paper.selectAll('.layer-subplot'); + fullLayout._imageSubplotLayer = layerSubplot.selectAll('.imagelayer'); + fullLayout._shapeSubplotLayer = layerSubplot.selectAll('.shapelayer'); // upper shape layer // (only for shapes to be drawn above the whole plot, including subplots) var layerAbove = fullLayout._paper.append('g') .classed('layer-above', true); + fullLayout._imageUpperLayer = layerAbove.append('g') + .classed('imagelayer', true); fullLayout._shapeUpperLayer = layerAbove.append('g') .classed('shapelayer', true); @@ -2797,10 +2810,16 @@ function makeCartesianPlotFramwork(gd, subplots) { // the plot and containers for overlays plotinfo.bg = plotgroup.append('rect') .style('stroke-width', 0); - // shape layer - // (only for shapes to be drawn below a subplot) - plotinfo.shapelayer = plotgroup.append('g') - .classed('shapelayer shapelayer-subplot', true); + + // back layer for shapes and images to + // be drawn below a subplot + var backlayer = plotgroup.append('g') + .classed('layer-subplot', true); + + plotinfo.shapelayer = backlayer.append('g') + .classed('shapelayer', true); + plotinfo.imagelayer = backlayer.append('g') + .classed('imagelayer', true); plotinfo.gridlayer = plotgroup.append('g'); plotinfo.overgrid = plotgroup.append('g'); plotinfo.zerolinelayer = plotgroup.append('g'); diff --git a/src/plotly.js b/src/plotly.js index 3ae37158882..5ceb2019839 100644 --- a/src/plotly.js +++ b/src/plotly.js @@ -49,6 +49,7 @@ exports.ErrorBars = require('./components/errorbars'); exports.Annotations = require('./components/annotations'); exports.Shapes = require('./components/shapes'); exports.Legend = require('./components/legend'); +exports.Images = require('./components/images'); exports.ModeBar = require('./components/modebar'); exports.register = function register(_modules) { diff --git a/src/plots/cartesian/axes.js b/src/plots/cartesian/axes.js index 42f898387e7..461bb077744 100644 --- a/src/plots/cartesian/axes.js +++ b/src/plots/cartesian/axes.js @@ -38,7 +38,7 @@ axes.getFromTrace = axisIds.getFromTrace; // find the list of possible axes to reference with an xref or yref attribute // and coerce it to that list -axes.coerceRef = function(containerIn, containerOut, gd, axLetter) { +axes.coerceRef = function(containerIn, containerOut, gd, axLetter, dflt) { var axlist = gd._fullLayout._has('gl2d') ? [] : axes.listIds(gd, axLetter), refAttr = axLetter + 'ref', attrDef = {}; @@ -48,7 +48,7 @@ axes.coerceRef = function(containerIn, containerOut, gd, axLetter) { attrDef[refAttr] = { valType: 'enumerated', values: axlist.concat(['paper']), - dflt: axlist[0] || 'paper' + dflt: dflt || axlist[0] || 'paper' }; // xref, yref diff --git a/src/plots/cartesian/dragbox.js b/src/plots/cartesian/dragbox.js index a539e3d7e1b..6dc13c6ad01 100644 --- a/src/plots/cartesian/dragbox.js +++ b/src/plots/cartesian/dragbox.js @@ -520,6 +520,7 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { redrawObjs(fullLayout.annotations || [], Plotly.Annotations); redrawObjs(fullLayout.shapes || [], Plotly.Shapes); + redrawObjs(fullLayout.images || [], Plotly.Images); } function doubleClick() { diff --git a/src/plots/layout_attributes.js b/src/plots/layout_attributes.js index 52f016e8af5..06b4ab0913c 100644 --- a/src/plots/layout_attributes.js +++ b/src/plots/layout_attributes.js @@ -182,6 +182,7 @@ module.exports = { 'legend': 'Legend', 'annotations': 'Annotations', 'shapes': 'Shapes', + 'images': 'Images', 'ternary': 'ternary' } }; diff --git a/src/plots/plots.js b/src/plots/plots.js index f71e4733d72..2c2d365f094 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -781,7 +781,7 @@ plots.supplyLayoutModuleDefaults = function(layoutIn, layoutOut, fullData) { // TODO register these // Legend must come after traces (e.g. it depends on 'barmode') - var moduleLayoutDefaults = ['Fx', 'Annotations', 'Shapes', 'Legend']; + var moduleLayoutDefaults = ['Fx', 'Annotations', 'Shapes', 'Legend', 'Images']; for(i = 0; i < moduleLayoutDefaults.length; i++) { _module = moduleLayoutDefaults[i]; diff --git a/src/traces/heatmap/style.js b/src/traces/heatmap/style.js index 2a466860377..2d1fdb3f431 100644 --- a/src/traces/heatmap/style.js +++ b/src/traces/heatmap/style.js @@ -12,7 +12,7 @@ var d3 = require('d3'); module.exports = function style(gd) { - d3.select(gd).selectAll('image') + d3.select(gd).selectAll('.hm image') .style('opacity', function(d) { return d.trace.opacity; }); diff --git a/test/image/baselines/layout_image.png b/test/image/baselines/layout_image.png new file mode 100644 index 00000000000..bff7bdaa056 Binary files /dev/null and b/test/image/baselines/layout_image.png differ diff --git a/test/image/mocks/layout_image.json b/test/image/mocks/layout_image.json new file mode 100644 index 00000000000..71951fd3a2e --- /dev/null +++ b/test/image/mocks/layout_image.json @@ -0,0 +1,72 @@ +{ + "data": [ + { + "x": [1,2,3], + "y": [1,2,3] + } + ], + "layout": { + "images": [ + { + "source": "https://images.plot.ly/language-icons/api-home/python-logo.png", + "xref": "paper", + "yref": "paper", + "x": 0, + "y": 1, + "sizex": 0.2, + "sizey": 0.2, + "xanchor": "right", + "yanchor": "bottom" + }, + { + "source": "https://images.plot.ly/language-icons/api-home/js-logo.png", + "xref": "x", + "yref": "y", + "x": 1.5, + "y": 2, + "sizex": 1, + "sizey": 1, + "xanchor": "right", + "yanchor": "bottom" + }, + { + "source": "https://images.plot.ly/language-icons/api-home/r-logo.png", + "xref": "x", + "yref": "y", + "x": 1, + "y": 3, + "sizex": 2, + "sizey": 2, + "sizing": "stretch", + "opacity": 0.4, + "layer": "below" + }, + { + "source": "https://images.plot.ly/language-icons/api-home/matlab-logo.png", + "xref": "x", + "yref": "paper", + "x": 3, + "y": 0, + "sizex": 0.5, + "sizey": 1, + "opacity": 1, + "xanchor": "right", + "yanchor": "middle" + }, + { + "source": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAANwAAAD1CAMAAAAMJ2tNAAAC9FBMVEUAAABEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEettEets5iYiFAAAA+3RSTlMAABVISkcS8/nwPvY/AUZraWpLAwSrtgZSmJZ7DIzRFIvQCAkXqcHAwlce4XQCI7G/L+z+X+sfBx0K5OWEMNTeN/H8VPuXy8jMdiW5ycc6wzJtyqQPho8FGLBb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9B5+0OOq3ZkGTBM/BPUJx0yKIG1BLZyjlZ0DJbnBwm8MfmRWHY7WXmlW1p53fqU0iJ34XpHkK1efpxlgSIrYl+9UHHUQ51wfgO8f/eDRu6sXRur8zIlD6iOvL5NDH/fnetOWtpuVwYnhWRWLnBMud7/DOzX+9GBg6bApijscLPUkci6tEA4aSF08sJ06vj6sdDgvb9ZlYYjMURbrufuFYto2vjizJ4O7Ub8wLm7FpRze46iGYz50spozKgyOuZagX3Ztybw96V99EF4yhxNoBUNOQmxsQraVG+Qsc2r60ldNppfb/nwiycevJIGxbst7r5tMExavGRnFhoajXaHeHN6PDEHzURjuz35auAAUgOP4lh+AFuGicVNVXEFV/wIdbgCa1/pZGoQDY00xVaRFuBkY+0y5rPIQZCDDARzCFzfapkW4s5j+LF+i9mhuAMOBgZ0Ymp2lPTiHnx2GfuB+1d8xBCwc6Huwk4TjkjUDB/pEq/ArkImdTOh42VesUt23wIM7tf3+oTrrvp0qbh/ywkFDcJ5/3yh3wlN8/hvgcCebPM0nyAENl1aj9lP9xGzgwZ3bzUIR8YvGd74I8HR1RYSHhy+pG0DWMqHsz44uhTdM7Qv0N6JISEhISEhISEhISEhISEieg/wHXMU13xThNeUAAABhdEVYdGNvbW1lbnQARmlsZSBzb3VyY2U6IGh0dHA6Ly9jb21tb25zLndpa2ltZWRpYS5vcmcvd2lraS9GaWxlOlBsb3RseV9sb2dvX2Zvcl9kaWdpdGFsX2ZpbmFsXyg2KS5wbmetqmbcAAAAJXRFWHRkYXRlOmNyZWF0ZQAyMDE0LTA5LTIxVDAzOjM1OjExKzAwOjAwKhOa5QAAACV0RVh0ZGF0ZTptb2RpZnkAMjAxNC0wOS0yMVQwMzozNToxMSswMDowMFtOIlkAAABGdEVYdHNvZnR3YXJlAEltYWdlTWFnaWNrIDYuNi45LTcgMjAxNC0wMy0wNiBRMTYgaHR0cDovL3d3dy5pbWFnZW1hZ2ljay5vcmeB07PDAAAAGHRFWHRUaHVtYjo6RG9jdW1lbnQ6OlBhZ2VzADGn/7svAAAAGXRFWHRUaHVtYjo6SW1hZ2U6OmhlaWdodAAxNzA0VsMjbQAAABh0RVh0VGh1bWI6OkltYWdlOjpXaWR0aAAxNTMxndUDgQAAABl0RVh0VGh1bWI6Ok1pbWV0eXBlAGltYWdlL3BuZz+yVk4AAAAXdEVYdFRodW1iOjpNVGltZQAxNDExMjcwNTEx0Rvi6AAAABN0RVh0VGh1bWI6OlNpemUAMjkuOUtCQvmCzmYAAAAzdEVYdFRodW1iOjpVUkkAZmlsZTovLy90bXAvbG9jYWxjb3B5X2UwNDYwYmM4MDVlYS0xLnBuZ1YfjUoAAAAASUVORK5CYII=", + "xref": "paper", + "yref": "paper", + "x": 0.5, + "y": 1, + "sizex": 0.2, + "sizey": 0.2, + "opacity": 1, + "xanchor": "middle", + "yanchor": "bottom" + } + ], + "width": 800, + "height": 500 + } +} diff --git a/test/jasmine/tests/layout_images_test.js b/test/jasmine/tests/layout_images_test.js new file mode 100644 index 00000000000..4a885ac3dba --- /dev/null +++ b/test/jasmine/tests/layout_images_test.js @@ -0,0 +1,275 @@ +var Plotly = require('@lib/index'); +var Plots = require('@src/plots/plots'); +var Images = require('@src/components/images'); +var createGraphDiv = require('../assets/create_graph_div'); +var destroyGraphDiv = require('../assets/destroy_graph_div'); +var mouseEvent = require('../assets/mouse_event'); + +var jsLogo = 'https://images.plot.ly/language-icons/api-home/js-logo.png'; +var pythonLogo = 'https://images.plot.ly/language-icons/api-home/python-logo.png'; + +describe('Layout images', function() { + + describe('supplyLayoutDefaults', function() { + + var layoutIn, + layoutOut; + + beforeEach(function() { + layoutIn = { images: [] }; + layoutOut = { _has: Plots._hasPlotType }; + }); + + it('should reject when there is no `source`', function() { + layoutIn.images[0] = { opacity: 0.5, sizex: 0.2, sizey: 0.2 }; + + Images.supplyLayoutDefaults(layoutIn, layoutOut); + + expect(layoutOut.images.length).toEqual(0); + }); + + it('should reject when not an array', function() { + layoutIn.images = { + source: jsLogo, + opacity: 0.5, + sizex: 0.2, + sizey: 0.2 + }; + + Images.supplyLayoutDefaults(layoutIn, layoutOut); + + expect(layoutOut.images).not.toBeDefined(); + }); + + it('should coerce the correct defaults', function() { + layoutIn.images[0] = { source: jsLogo }; + + var expected = { + source: jsLogo, + layer: 'above', + x: 0, + y: 0, + xanchor: 'left', + yanchor: 'top', + sizex: 0, + sizey: 0, + sizing: 'contain', + opacity: 1, + xref: 'paper', + yref: 'paper' + }; + + Images.supplyLayoutDefaults(layoutIn, layoutOut); + + expect(layoutOut.images[0]).toEqual(expected); + }); + + }); + + describe('drawing', function() { + + var gd, + data = [{ x: [1,2,3], y: [1,2,3] }]; + + beforeEach(function() { + gd = createGraphDiv(); + }); + + afterEach(destroyGraphDiv); + + it('should draw images on the right layers', function() { + + var layer; + + Plotly.plot(gd, data, { images: [{ + source: 'imageabove', + layer: 'above' + }]}); + + layer = gd._fullLayout._imageUpperLayer; + expect(layer.length).toBe(1); + + destroyGraphDiv(); + gd = createGraphDiv(); + Plotly.plot(gd, data, { images: [{ + source: 'imagebelow', + layer: 'below' + }]}); + + layer = gd._fullLayout._imageLowerLayer; + expect(layer.length).toBe(1); + + destroyGraphDiv(); + gd = createGraphDiv(); + Plotly.plot(gd, data, { images: [{ + source: 'imagesubplot', + layer: 'below', + xref: 'x', + yref: 'y' + }]}); + + layer = gd._fullLayout._imageSubplotLayer; + expect(layer.length).toBe(1); + }); + + describe('with anchors and sizing', function() { + + function testAspectRatio(xAnchor, yAnchor, sizing, expected) { + var anchorName = xAnchor + yAnchor; + Plotly.plot(gd, data, { images: [{ + source: anchorName, + xanchor: xAnchor, + yanchor: yAnchor, + sizing: sizing + }]}); + + var image = Plotly.d3.select('image'), + parValue = image.attr('preserveAspectRatio'); + + expect(parValue).toBe(expected); + } + + it('should work for center middle', function() { + testAspectRatio('center', 'middle', undefined, 'xMidYMid'); + }); + + it('should work for left top', function() { + testAspectRatio('left', 'top', undefined, 'xMinYMin'); + }); + + it('should work for right bottom', function() { + testAspectRatio('right', 'bottom', undefined, 'xMaxYMax'); + }); + + it('should work for stretch sizing', function() { + testAspectRatio('middle', 'center', 'stretch', 'none'); + }); + + it('should work for fill sizing', function() { + testAspectRatio('invalid', 'invalid', 'fill', 'xMinYMin slice'); + }); + + }); + + }); + + describe('when the plot is dragged', function() { + var gd, + data = [{ x: [1,2,3], y: [1,2,3] }]; + + beforeEach(function() { + gd = createGraphDiv(); + }); + + afterEach(destroyGraphDiv); + + it('should not move when referencing the paper', function(done) { + var image = { + source: jsLogo, + xref: 'paper', + yref: 'paper', + x: 0, + y: 0, + sizex: 0.1, + sizey: 0.1 + }; + + Plotly.plot(gd, data, { + images: [image], + dragmode: 'pan', + width: 600, + height: 400 + }).then(function() { + var img = Plotly.d3.select('image').node(), + oldPos = img.getBoundingClientRect(); + + mouseEvent('mousedown', 250, 200); + mouseEvent('mousemove', 300, 250); + + var newPos = img.getBoundingClientRect(); + + expect(newPos.left).toBe(oldPos.left); + expect(newPos.top).toBe(oldPos.top); + + mouseEvent('mouseup', 300, 250); + }).then(done); + }); + + it('should move when referencing axes', function(done) { + var image = { + source: jsLogo, + xref: 'x', + yref: 'y', + x: 2, + y: 2, + sizex: 1, + sizey: 1 + }; + + Plotly.plot(gd, data, { + images: [image], + dragmode: 'pan', + width: 600, + height: 400 + }).then(function() { + var img = Plotly.d3.select('image').node(), + oldPos = img.getBoundingClientRect(); + + mouseEvent('mousedown', 250, 200); + mouseEvent('mousemove', 300, 250); + + var newPos = img.getBoundingClientRect(); + + expect(newPos.left).toBe(oldPos.left + 50); + expect(newPos.top).toBe(oldPos.top + 50); + + mouseEvent('mouseup', 300, 250); + }).then(done); + }); + + }); + + describe('when relayout', function() { + + var gd, + data = [{ x: [1,2,3], y: [1,2,3] }]; + + beforeEach(function(done) { + gd = createGraphDiv(); + Plotly.plot(gd, data, { + images: [{ + source: jsLogo, + x: 2, + y: 2, + sizex: 1, + sizey: 1 + }] + }).then(done); + }); + + afterEach(destroyGraphDiv); + + it('should update the image if changed', function(done) { + var img = Plotly.d3.select('image'), + url = img.attr('xlink:href'); + + Plotly.relayout(gd, 'images[0].source', pythonLogo).then(function() { + var newImg = Plotly.d3.select('image'), + newUrl = newImg.attr('xlink:href'); + expect(url).not.toBe(newUrl); + }).then(done); + }); + + it('should remove the image tag if an invalid source', function(done) { + + var selection = Plotly.d3.select('image'); + expect(selection.size()).toBe(1); + + Plotly.relayout(gd, 'images[0].source', 'invalidUrl').then(function() { + var newSelection = Plotly.d3.select('image'); + expect(newSelection.size()).toBe(0); + }).then(done); + }); + }); + +}); diff --git a/test/jasmine/tests/lib_test.js b/test/jasmine/tests/lib_test.js index e30a7378e6f..d91328e1dc2 100644 --- a/test/jasmine/tests/lib_test.js +++ b/test/jasmine/tests/lib_test.js @@ -441,6 +441,40 @@ describe('Test lib.js:', function() { }); }); + describe('objectFromPath', function() { + + it('should return an object', function() { + var obj = Lib.objectFromPath('test', 'object'); + + expect(obj).toEqual({ test: 'object' }); + }); + + it('should work for deep objects', function() { + var obj = Lib.objectFromPath('deep.nested.test', 'object'); + + expect(obj).toEqual({ deep: { nested: { test: 'object' }}}); + }); + + it('should work for arrays', function() { + var obj = Lib.objectFromPath('nested[2].array', 'object'); + + expect(Object.keys(obj)).toEqual(['nested']); + expect(Array.isArray(obj.nested)).toBe(true); + expect(obj.nested[0]).toBe(undefined); + expect(obj.nested[2]).toEqual({ array: 'object' }); + }); + + it('should work for any given value', function() { + var obj = Lib.objectFromPath('test.type', { an: 'object' }); + + expect(obj).toEqual({ test: { type: { an: 'object' }}}); + + obj = Lib.objectFromPath('test.type', [42]); + + expect(obj).toEqual({ test: { type: [42] }}); + }); + }); + describe('coerce', function() { var coerce = Lib.coerce, out; @@ -811,7 +845,6 @@ describe('Test lib.js:', function() { el.attr('transform', 'rotate(20)'); expect(Lib.getTranslate(el)).toEqual({ x: 0, y: 0 }); }); - }); describe('setTranslate', function() { diff --git a/test/jasmine/tests/plotschema_test.js b/test/jasmine/tests/plotschema_test.js index 7064557f0af..d4ea5dda49f 100644 --- a/test/jasmine/tests/plotschema_test.js +++ b/test/jasmine/tests/plotschema_test.js @@ -115,7 +115,7 @@ describe('plot schema', function() { it('should convert _isLinkedToArray attributes to items object', function() { var astrs = [ - 'annotations', 'shapes', + 'annotations', 'shapes', 'images', 'xaxis.rangeselector.buttons', 'yaxis.rangeselector.buttons' ]; diff --git a/test/jasmine/tests/shapes_test.js b/test/jasmine/tests/shapes_test.js index 74993cf628f..c553b55a396 100644 --- a/test/jasmine/tests/shapes_test.js +++ b/test/jasmine/tests/shapes_test.js @@ -58,7 +58,7 @@ describe('Test shapes:', function() { } function countShapeLayerNodesInSubplots() { - return d3.selectAll('.shapelayer-subplot').size(); + return d3.selectAll('.layer-subplot').size(); } function countSubplots(gd) { @@ -74,7 +74,7 @@ describe('Test shapes:', function() { } function countShapePathsInSubplots() { - return d3.selectAll('.shapelayer-subplot > path').size(); + return d3.selectAll('.layer-subplot > .shapelayer > path').size(); } describe('*shapeLowerLayer*', function() {