diff --git a/animate-api.md b/animate-api.md new file mode 100644 index 00000000000..8b65e1aee14 --- /dev/null +++ b/animate-api.md @@ -0,0 +1,133 @@ +## Top-level Plotly API methods + +#### `Plotly.transition(gd, data, layout[, traceIndices[, config]])` +Transition (eased or abruptly if desired) to a new set of data. Knows nothing about the larger state of transitions and frames; identically a 'transition the plot to look like X over Y ms' command. + +**Parameters**: +- `data`: an *array* of *objects* containing trace data, e.g. `[{x: [1, 2, 3], 'lines.color': 'red'}, {y: [7,8]}]`, mapped to traces. +- `layout`: layout properties to which to transition, probably mostly just axis ranges +- `traceIndices`: a mapping between the items of `data` and the trace indices, e.g. `[0, 2]`. If omitted, is inferred from semantics like for `restyle`—which means maybe affecting all traces? +- `config`: object containing transition configuration, including: + - `duration`: duration in ms of transition + - `ease`: d3 easing function, e.g. `elastic-in-out` + - `delay`: delay until animation; not so useful, just very very easy to pass to d3 + - `cascade`: transition points in sequence for a nice visual effect. Maybe just leave out. Kind of a common visual effect for eye candy purposes. Very easy. Can leave out if it leads to weird corner cases. See: http://rickyreusser.com/animation-experiments/#object-constancy + +**Returns**: promise that resolves when animation begins or rejects if config is invalid. + +**Events**: +- `plotly_starttransition` +- `plotly_endtransition` + +
+ +#### `Plotly.animate(gd, frame[, config])` +Transition to a keyframe. Animation sequence is: + +1. Compute the requested frame +2. Separate animatable and non-animatable properties into separate objects +3. Mark exactly what needs to happen. This includes transitions vs. non-animatable properties, whether the axis needs to be redrawn (`needsRelayout`?), and any other optimizations that seem relevant. Since for some cases very simple updates may be coming through at up to 60fps, cutting out work here could be fairly important. + +**Parameters**: +- `frame`: name of the frame to which to animate +- `config`: see `.transition`. + +**Returns**: promise that resolves when animation begins or rejects if config is invalid. + +**Events**: +- `plotly_startanimation` +- `plotly_endanimation` + +
+ +#### `Plotly.addFrames(gd, frames[, frameIndices])` +Add or overwrite frames. New frames are appended to current frame list. + +**Parameters** +- `frames`: an array of objects containing any of `name`, `data`, `layout` and `traceIndices` fields as specified above. If no name is provided, a unique name (e.g. `frame 7`) will be assigned. If the frame already exists, then its definition is overwritten. +- `frameIndices`: optional array of indices at which to insert the given frames. If indices are omitted or a specific index is falsey, then frame is appended. + +**Returns**: Promise that resolves on completion. (In this case, that's synchronously and mainly for the sake of API consistency.) + +
+ +#### `Plotly.deleteFrames(gd, frameIndices)` +Remove frames by frame index. + +**Parameters**: +- `frameIndices`: an array of integer indices of the frames to be removed. + +**Returns**: Promise that resolves on completion (which here means synchronously). + +
+ +## Frame definition + +Frames are defined similarly to mirror the input format, *not* that of `Plotly.restyle`. The easiest way to explain seems to be via an example that touches all features: + +```json +{ + "data": [{ + "x": [1, 2, 3], + "y": [4, 5, 6], + "identifiers": ["China", "Pakistan", "Australia"], + "lines": { + "color": "red" + } + }, { + "x": [1, 2, 3], + "y": [3, 8, 9], + "markers": { + "color": "red" + } + }], + "layout": { + "slider": { + "visible": true, + "plotly_method": "animate", + "args": ["$value", {"duration": 500}] + }, + "slider2": { + "visible": true, + "plotly_method": "animate", + "args": ["$value", {"duration": 500}] + } + }, + "frames": [ + { + "name": "base", + "y": [4, 5, 7], + "identifiers": ["China", "Pakistan", "Australia"], + }, { + "name": "1960", + "data": [{ + "y": [1, 2, 3], + "identifiers": ["China", "Pakistan", "Australia"], + }], + "layout": { + "xaxis": {"range": [7, 3]}, + "yaxis": {"range": [0, 5]} + }, + "baseFrame": "base", + "traceIndices": [0] + }, { + "name": "1965", + "data": [{ + "y": [5, 3, 2], + "identifiers": ["China", "Pakistan", "Australia"], + }], + "layout": { + "xaxis": {"range": [7, 3]}, + "yaxis": {"range": [0, 5]} + }, + "baseFrame": "base", + "traceIndices": [0] + } + ] +} +``` + +Notes on JSON: +- `identifiers` is used as a d3 `key` argument. +- `baseFrame` is merged… recursively? non-recursively? We'll see. Not a crucial implementation choice. +- `frames` seems maybe best stored at top level. Or maybe best on the object. If on the object, `Plotly.plot` would have to be variadic (probably), accepting `Plotly.plot(gd, data, layout[, frames], config)`. That's backward-compatible but a bit ugly. If not on the object, then it would have to be shoved into `layout` (except how, because it's really awkward place in `layout`. diff --git a/build/plotcss.js b/build/plotcss.js index 169edfce295..556adf30e3b 100644 --- a/build/plotcss.js +++ b/build/plotcss.js @@ -1,6 +1,5 @@ 'use strict'; -var Plotly = require('../src/plotly'); var rules = { "X,X div": "font-family:'Open Sans', verdana, arial, sans-serif;margin:0;padding:0;", "X input,X button": "font-family:'Open Sans', verdana, arial, sans-serif;", @@ -54,9 +53,4 @@ var rules = { "Y .notifier-close:hover": "color:#444;text-decoration:none;cursor:pointer;" }; -for(var selector in rules) { - var fullSelector = selector.replace(/^,/,' ,') - .replace(/X/g, '.js-plotly-plot .plotly') - .replace(/Y/g, '.plotly-notifier'); - Plotly.Lib.addStyleRule(fullSelector, rules[selector]); -} +module.exports = rules; diff --git a/src/components/colorbar/has_colorbar.js b/src/components/colorbar/has_colorbar.js index e7c750933a7..40990086b43 100644 --- a/src/components/colorbar/has_colorbar.js +++ b/src/components/colorbar/has_colorbar.js @@ -9,10 +9,9 @@ 'use strict'; +var Lib = require('../../lib'); + module.exports = function hasColorbar(container) { - return ( - typeof container.colorbar === 'object' && - container.colorbar !== null - ); + return Lib.isPlainObject(container.colorbar); }; diff --git a/src/components/colorscale/has_colorscale.js b/src/components/colorscale/has_colorscale.js index 9e28d51f5de..5cbce08634d 100644 --- a/src/components/colorscale/has_colorscale.js +++ b/src/components/colorscale/has_colorscale.js @@ -33,12 +33,12 @@ module.exports = function hasColorscale(trace, containerStr) { } return ( - (typeof container === 'object' && container !== null) && ( + Lib.isPlainObject(container) && ( isArrayWithOneNumber || container.showscale === true || (isNumeric(container.cmin) && isNumeric(container.cmax)) || isValidScale(container.colorscale) || - (typeof container.colorbar === 'object' && container.colorbar !== null) + Lib.isPlainObject(container.colorbar) ) ); }; diff --git a/src/components/dragelement/index.js b/src/components/dragelement/index.js index a57a0038248..44a7687fa62 100644 --- a/src/components/dragelement/index.js +++ b/src/components/dragelement/index.js @@ -86,7 +86,7 @@ dragElement.init = function init(options) { if(options.prepFn) options.prepFn(e, startX, startY); - dragCover = coverSlip(); + dragCover = coverSlip(gd); dragCover.onmousemove = onMove; dragCover.onmouseup = onDone; @@ -139,7 +139,7 @@ dragElement.init = function init(options) { if(options.doneFn) options.doneFn(gd._dragged, numClicks); if(!gd._dragged) { - var e2 = document.createEvent('MouseEvents'); + var e2 = gd._document.createEvent('MouseEvents'); e2.initEvent('click', true, true); initialTarget.dispatchEvent(e2); } @@ -159,8 +159,8 @@ dragElement.init = function init(options) { options.element.style.pointerEvents = 'all'; }; -function coverSlip() { - var cover = document.createElement('div'); +function coverSlip(gd) { + var cover = gd._document.createElement('div'); cover.className = 'dragcover'; var cStyle = cover.style; @@ -172,7 +172,7 @@ function coverSlip() { cStyle.zIndex = 999999999; cStyle.background = 'none'; - document.body.appendChild(cover); + gd._document.body.appendChild(cover); return cover; } diff --git a/src/components/drawing/index.js b/src/components/drawing/index.js index 2c9a6a6261c..36984f0fd0d 100644 --- a/src/components/drawing/index.js +++ b/src/components/drawing/index.js @@ -46,16 +46,62 @@ drawing.setRect = function(s, x, y, w, h) { s.call(drawing.setPosition, x, y).call(drawing.setSize, w, h); }; -drawing.translatePoints = function(s, xa, ya) { - s.each(function(d) { +drawing.translatePoints = function(s, xa, ya, trace, transitionConfig, joinDirection) { + var size; + + var hasTransition = transitionConfig && (transitionConfig || {}).duration > 0; + + if(hasTransition) { + size = s.size(); + } + + s.each(function(d, i) { // put xp and yp into d if pixel scaling is already done var x = d.xp || xa.c2p(d.x), y = d.yp || ya.c2p(d.y), p = d3.select(this); if(isNumeric(x) && isNumeric(y)) { // for multiline text this works better - if(this.nodeName === 'text') p.attr('x', x).attr('y', y); - else p.attr('transform', 'translate(' + x + ',' + y + ')'); + if(this.nodeName === 'text') { + p.attr('x', x).attr('y', y); + } else { + if(hasTransition) { + var trans; + if(!joinDirection) { + trans = p.transition() + .delay(transitionConfig.delay + transitionConfig.cascade / size * i) + .duration(transitionConfig.duration) + .ease(transitionConfig.ease) + .attr('transform', 'translate(' + x + ',' + y + ')'); + + if(trace) { + trans.call(drawing.pointStyle, trace); + } + } else if(joinDirection === -1) { + trans = p.style('opacity', 1) + .transition() + .duration(transitionConfig.duration) + .ease(transitionConfig.ease) + .style('opacity', 0) + .remove(); + } else if(joinDirection === 1) { + trans = p.attr('transform', 'translate(' + x + ',' + y + ')'); + + if(trace) { + trans.call(drawing.pointStyle, trace); + } + + trans.style('opacity', 0) + .transition() + .duration(transitionConfig.duration) + .ease(transitionConfig.ease) + .style('opacity', 1); + } + + } else { + p.attr('transform', 'translate(' + x + ',' + y + ')'); + } + } } else p.remove(); }); diff --git a/src/components/errorbars/plot.js b/src/components/errorbars/plot.js index 3f44ed58df1..5f074b63bc2 100644 --- a/src/components/errorbars/plot.js +++ b/src/components/errorbars/plot.js @@ -12,14 +12,18 @@ var d3 = require('d3'); var isNumeric = require('fast-isnumeric'); -var Lib = require('../../lib'); var subTypes = require('../../traces/scatter/subtypes'); +var styleError = require('./style'); -module.exports = function plot(traces, plotinfo) { +module.exports = function plot(traces, plotinfo, transitionConfig) { + var isNew; var xa = plotinfo.x(), ya = plotinfo.y(); + transitionConfig = transitionConfig || {}; + var hasAnimation = isNumeric(transitionConfig.duration) && transitionConfig.duration > 0; + traces.each(function(d) { var trace = d[0].trace, // || {} is in case the trace (specifically scatterternary) @@ -29,6 +33,12 @@ module.exports = function plot(traces, plotinfo) { xObj = trace.error_x || {}, yObj = trace.error_y || {}; + var keyFunc; + + if(trace.identifier) { + keyFunc = function(d) {return d.identifier;}; + } + var sparse = ( subTypes.hasMarkers(trace) && trace.marker.maxdisplayed > 0 @@ -37,11 +47,21 @@ module.exports = function plot(traces, plotinfo) { if(!yObj.visible && !xObj.visible) return; var errorbars = d3.select(this).selectAll('g.errorbar') - .data(Lib.identity); + .data(d, keyFunc); - errorbars.enter().append('g') + errorbars.exit().remove(); + + errorbars.style('opacity', 1); + + var enter = errorbars.enter().append('g') .classed('errorbar', true); + if(hasAnimation) { + enter.style('opacity', 0).transition() + .duration(transitionConfig.duration) + .style('opacity', 1); + } + errorbars.each(function(d) { var errorbar = d3.select(this); var coords = errorCoords(d, xa, ya); @@ -59,14 +79,28 @@ module.exports = function plot(traces, plotinfo) { coords.yh + 'h' + (2 * yw) + // hat 'm-' + yw + ',0V' + coords.ys; // bar + if(!coords.noYS) path += 'm-' + yw + ',0h' + (2 * yw); // shoe - errorbar.append('path') - .classed('yerror', true) - .attr('d', path); + var yerror = errorbar.select('path.yerror'); + + isNew = !yerror.size(); + + if(isNew) { + yerror = errorbar.append('path') + .classed('yerror', true); + } else if(hasAnimation) { + yerror = yerror + .transition() + .duration(transitionConfig.duration) + .ease(transitionConfig.ease) + .delay(transitionConfig.delay); + } + + yerror.attr('d', path); } - if(xObj.visible && isNumeric(coords.y) && + if(xObj.visible && isNumeric(coords.x) && isNumeric(coords.xh) && isNumeric(coords.xs)) { var xw = (xObj.copy_ystyle ? yObj : xObj).width; @@ -77,11 +111,26 @@ module.exports = function plot(traces, plotinfo) { if(!coords.noXS) path += 'm0,-' + xw + 'v' + (2 * xw); // shoe - errorbar.append('path') - .classed('xerror', true) - .attr('d', path); + var xerror = errorbar.select('path.xerror'); + + isNew = !xerror.size(); + + if(isNew) { + xerror = errorbar.append('path') + .classed('xerror', true); + } else if(hasAnimation) { + xerror = xerror + .transition() + .duration(transitionConfig.duration) + .ease(transitionConfig.ease) + .delay(transitionConfig.delay); + } + + xerror.attr('d', path); } }); + + d3.select(this).call(styleError); }); }; diff --git a/src/components/modebar/buttons.js b/src/components/modebar/buttons.js index 23b6ae8a72c..a43279afdef 100644 --- a/src/components/modebar/buttons.js +++ b/src/components/modebar/buttons.js @@ -50,19 +50,19 @@ modeBarButtons.toImage = { click: function(gd) { var format = 'png'; - Lib.notifier('Taking snapshot - this may take a few seconds', 'long'); + Lib.notifier(gd, 'Taking snapshot - this may take a few seconds', 'long'); if(Lib.isIE()) { - Lib.notifier('IE only supports svg. Changing format to svg.', 'long'); + Lib.notifier(gd, 'IE only supports svg. Changing format to svg.', 'long'); format = 'svg'; } downloadImage(gd, {'format': format}) .then(function(filename) { - Lib.notifier('Snapshot succeeded - ' + filename, 'long'); + Lib.notifier(gd, 'Snapshot succeeded - ' + filename, 'long'); }) .catch(function() { - Lib.notifier('Sorry there was a problem downloading your snapshot!', 'long'); + Lib.notifier(gd, 'Sorry there was a problem downloading your snapshot!', 'long'); }); } }; diff --git a/src/components/rangeselector/defaults.js b/src/components/rangeselector/defaults.js index b2c02e846fe..108b888c8eb 100644 --- a/src/components/rangeselector/defaults.js +++ b/src/components/rangeselector/defaults.js @@ -57,6 +57,8 @@ function buttonsDefaults(containerIn, containerOut) { buttonIn = buttonsIn[i]; buttonOut = {}; + if(!Lib.isPlainObject(buttonIn)) continue; + var step = coerce('step'); if(step !== 'all') { coerce('stepmode'); diff --git a/src/components/rangeslider/create_slider.js b/src/components/rangeslider/create_slider.js index 83caa2ad7eb..00a9b3d06b1 100644 --- a/src/components/rangeslider/create_slider.js +++ b/src/components/rangeslider/create_slider.js @@ -33,7 +33,7 @@ module.exports = function createSlider(gd) { var minStart = 0, maxStart = width; - var slider = document.createElementNS(svgNS, 'g'); + var slider = gd._document.createElementNS(svgNS, 'g'); helpers.setAttributes(slider, { 'class': 'range-slider', 'data-min': minStart, @@ -43,7 +43,7 @@ module.exports = function createSlider(gd) { }); - var sliderBg = document.createElementNS(svgNS, 'rect'), + var sliderBg = gd._document.createElementNS(svgNS, 'rect'), borderCorrect = options.borderwidth % 2 === 0 ? options.borderwidth : options.borderwidth - 1; helpers.setAttributes(sliderBg, { 'fill': options.bgcolor, @@ -56,7 +56,7 @@ module.exports = function createSlider(gd) { }); - var maskMin = document.createElementNS(svgNS, 'rect'); + var maskMin = gd._document.createElementNS(svgNS, 'rect'); helpers.setAttributes(maskMin, { 'x': 0, 'width': minStart, @@ -65,7 +65,7 @@ module.exports = function createSlider(gd) { }); - var maskMax = document.createElementNS(svgNS, 'rect'); + var maskMax = gd._document.createElementNS(svgNS, 'rect'); helpers.setAttributes(maskMax, { 'x': maxStart, 'width': width - maxStart, @@ -74,9 +74,9 @@ module.exports = function createSlider(gd) { }); - var grabberMin = document.createElementNS(svgNS, 'g'), - grabAreaMin = document.createElementNS(svgNS, 'rect'), - handleMin = document.createElementNS(svgNS, 'rect'); + var grabberMin = gd._document.createElementNS(svgNS, 'g'), + grabAreaMin = gd._document.createElementNS(svgNS, 'rect'), + handleMin = gd._document.createElementNS(svgNS, 'rect'); helpers.setAttributes(grabberMin, { 'transform': 'translate(' + (minStart - handleWidth - 1) + ')' }); helpers.setAttributes(grabAreaMin, { 'width': 10, @@ -97,9 +97,9 @@ module.exports = function createSlider(gd) { helpers.appendChildren(grabberMin, [handleMin, grabAreaMin]); - var grabberMax = document.createElementNS(svgNS, 'g'), - grabAreaMax = document.createElementNS(svgNS, 'rect'), - handleMax = document.createElementNS(svgNS, 'rect'); + var grabberMax = gd._document.createElementNS(svgNS, 'g'), + grabAreaMax = gd._document.createElementNS(svgNS, 'rect'), + handleMax = gd._document.createElementNS(svgNS, 'rect'); helpers.setAttributes(grabberMax, { 'transform': 'translate(' + maxStart + ')' }); helpers.setAttributes(grabAreaMax, { 'width': 10, @@ -120,7 +120,7 @@ module.exports = function createSlider(gd) { helpers.appendChildren(grabberMax, [handleMax, grabAreaMax]); - var slideBox = document.createElementNS(svgNS, 'rect'); + var slideBox = gd._document.createElementNS(svgNS, 'rect'); helpers.setAttributes(slideBox, { 'x': minStart, 'width': maxStart - minStart, @@ -137,8 +137,8 @@ module.exports = function createSlider(gd) { minVal = slider.getAttribute('data-min'), maxVal = slider.getAttribute('data-max'); - window.addEventListener('mousemove', mouseMove); - window.addEventListener('mouseup', mouseUp); + gd._document.defaultView.addEventListener('mousemove', mouseMove); + gd._document.defaultView.addEventListener('mouseup', mouseUp); function mouseMove(e) { var delta = +e.clientX - startX, @@ -189,8 +189,8 @@ module.exports = function createSlider(gd) { } function mouseUp() { - window.removeEventListener('mousemove', mouseMove); - window.removeEventListener('mouseup', mouseUp); + gd._document.defaultView.removeEventListener('mousemove', mouseMove); + gd._document.defaultView.removeEventListener('mouseup', mouseUp); slider.style.cursor = 'auto'; } }); @@ -222,8 +222,8 @@ module.exports = function createSlider(gd) { function setDataRange(dataMin, dataMax) { - if(window.requestAnimationFrame) { - window.requestAnimationFrame(function() { + if(gd._document.defaultView.requestAnimationFrame) { + gd._document.defaultView.requestAnimationFrame(function() { Plotly.relayout(gd, 'xaxis.range', [dataMin, dataMax]); }); } else { diff --git a/src/components/rangeslider/defaults.js b/src/components/rangeslider/defaults.js index 0095c7b243e..c06502b7068 100644 --- a/src/components/rangeslider/defaults.js +++ b/src/components/rangeslider/defaults.js @@ -13,10 +13,9 @@ var attributes = require('./attributes'); module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, axName, counterAxes) { - if(!layoutIn[axName].rangeslider) return; - var containerIn = typeof layoutIn[axName].rangeslider === 'object' ? + var containerIn = Lib.isPlainObject(layoutIn[axName].rangeslider) ? layoutIn[axName].rangeslider : {}, containerOut = layoutOut[axName].rangeslider = {}; diff --git a/src/core.js b/src/core.js index 795a2b6367c..f222cd9c4d3 100644 --- a/src/core.js +++ b/src/core.js @@ -28,6 +28,10 @@ exports.prependTraces = Plotly.prependTraces; exports.addTraces = Plotly.addTraces; exports.deleteTraces = Plotly.deleteTraces; exports.moveTraces = Plotly.moveTraces; +exports.addFrames = Plotly.addFrames; +exports.deleteFrames = Plotly.deleteFrames; +exports.transition = Plotly.transition; +exports.animate = Plotly.animate; exports.purge = Plotly.purge; exports.setPlotConfig = require('./plot_api/set_plot_config'); exports.register = Plotly.register; diff --git a/src/lib/extend.js b/src/lib/extend.js index 5b1eb5ce827..d76740d326d 100644 --- a/src/lib/extend.js +++ b/src/lib/extend.js @@ -27,15 +27,19 @@ function primitivesLoopSplice(source, target) { } exports.extendFlat = function() { - return _extend(arguments, false, false); + return _extend(arguments, false, false, false); }; exports.extendDeep = function() { - return _extend(arguments, true, false); + return _extend(arguments, true, false, false); }; exports.extendDeepAll = function() { - return _extend(arguments, true, true); + return _extend(arguments, true, true, false); +}; + +exports.extendDeepNoArrays = function() { + return _extend(arguments, true, false, true); }; /* @@ -55,7 +59,7 @@ exports.extendDeepAll = function() { * Warning: this might result in infinite loops. * */ -function _extend(inputs, isDeep, keepAllKeys) { +function _extend(inputs, isDeep, keepAllKeys, noArrayCopies) { var target = inputs[0], length = inputs.length; @@ -79,8 +83,13 @@ function _extend(inputs, isDeep, keepAllKeys) { src = target[key]; copy = input[key]; + // Stop early and just transfer the array if array copies are disallowed: + if(noArrayCopies && isArray(copy)) { + target[key] = copy; + } + // recurse if we're merging plain objects or arrays - if(isDeep && copy && (isPlainObject(copy) || (copyIsArray = isArray(copy)))) { + else if(isDeep && copy && (isPlainObject(copy) || (copyIsArray = isArray(copy)))) { if(copyIsArray) { copyIsArray = false; clone = src && isArray(src) ? src : []; @@ -89,7 +98,7 @@ function _extend(inputs, isDeep, keepAllKeys) { } // never move original objects, clone them - target[key] = _extend([clone, copy], isDeep, keepAllKeys); + target[key] = _extend([clone, copy], isDeep, keepAllKeys, noArrayCopies); } // don't bring in undefined values, except for extendDeepAll diff --git a/src/lib/index.js b/src/lib/index.js index 9f3686462e4..d8b2d7efaae 100644 --- a/src/lib/index.js +++ b/src/lib/index.js @@ -58,12 +58,16 @@ var extendModule = require('./extend'); lib.extendFlat = extendModule.extendFlat; lib.extendDeep = extendModule.extendDeep; lib.extendDeepAll = extendModule.extendDeepAll; +lib.extendDeepNoArrays = extendModule.extendDeepNoArrays; var loggersModule = require('./loggers'); lib.log = loggersModule.log; lib.warn = loggersModule.warn; lib.error = loggersModule.error; +var cssModule = require('./plotcss_utils'); +lib.injectStyles = cssModule.injectStyles; + lib.notifier = require('./notifier'); /** @@ -388,30 +392,6 @@ lib.removeElement = function(el) { if(elParent) elParent.removeChild(el); }; -/** - * for dynamically adding style rules - * makes one stylesheet that contains all rules added - * by all calls to this function - */ -lib.addStyleRule = function(selector, styleString) { - if(!lib.styleSheet) { - var style = document.createElement('style'); - // WebKit hack :( - style.appendChild(document.createTextNode('')); - document.head.appendChild(style); - lib.styleSheet = style.sheet; - } - var styleSheet = lib.styleSheet; - - if(styleSheet.insertRule) { - styleSheet.insertRule(selector + '{' + styleString + '}', 0); - } - else if(styleSheet.addRule) { - styleSheet.addRule(selector, styleString, 0); - } - else lib.warn('addStyleRule failed'); -}; - lib.getTranslate = function(element) { var re = /.*\btranslate\((\d*\.?\d*)[^\d]*(\d*\.?\d*)[^\d].*/, @@ -571,6 +551,39 @@ lib.objectFromPath = function(path, value) { return obj; }; +/** + * Iterate through an object in-place, converting dotted properties to objects. + * + * @example + * lib.expandObjectPaths({'nested.test.path': 'value'}); + * // returns { nested: { test: {path: 'value'}}} + */ + +// Store this to avoid recompiling regex on every prop since this may happen many +// many times for animations. +// TODO: Premature optimization? Remove? +var dottedPropertyRegex = /^([^\.]*)\../; + +lib.expandObjectPaths = function(data) { + var match, key, prop, datum; + if(typeof data === 'object' && !Array.isArray(data)) { + for(key in data) { + if(data.hasOwnProperty(key)) { + if((match = key.match(dottedPropertyRegex))) { + datum = data[key]; + prop = match[1]; + + delete data[key]; + + data[prop] = lib.extendDeepNoArrays(data[prop] || {}, lib.objectFromPath(key, lib.expandObjectPaths(datum))[prop]); + } else { + data[key] = lib.expandObjectPaths(data[key]); + } + } + } + } + return data; +}; /** * Converts value to string separated by the provided separators. @@ -615,3 +628,89 @@ lib.numSeparate = function(value, separators) { return x1 + x2; }; + +/* + * Compute a keyframe. Merge a keyframe into its base frame(s) and + * expand properties. + * + * @param {object} frameLookup + * An object containing frames keyed by name (i.e. gd._frameData._frameHash) + * @param {string} frame + * The name of the keyframe to be computed + * + * Returns: a new object with the merged content + */ +lib.computeFrame = function(frameLookup, frameName) { + var i, traceIndices, traceIndex, expandedObj, destIndex, copy; + + var framePtr = frameLookup[frameName]; + + // Return false if the name is invalid: + if(!framePtr) { + return false; + } + + var frameStack = [framePtr]; + var frameNameStack = [framePtr.name]; + + // Follow frame pointers: + while((framePtr = frameLookup[framePtr.baseFrame])) { + // Avoid infinite loops: + if(frameNameStack.indexOf(framePtr.name) !== -1) break; + + frameStack.push(framePtr); + frameNameStack.push(framePtr.name); + } + + // A new object for the merged result: + var result = {}; + + // Merge, starting with the last and ending with the desired frame: + while((framePtr = frameStack.pop())) { + if(framePtr.layout) { + copy = lib.extendDeepNoArrays({}, framePtr.layout); + expandedObj = lib.expandObjectPaths(copy); + result.layout = lib.extendDeepNoArrays(result.layout || {}, expandedObj); + } + + if(framePtr.data) { + if(!result.data) { + result.data = []; + } + traceIndices = framePtr.traceIndices; + + if(!traceIndices) { + // If not defined, assume serial order starting at zero + traceIndices = []; + for(i = 0; i < framePtr.data.length; i++) { + traceIndices[i] = i; + } + } + + if(!result.traceIndices) { + result.traceIndices = []; + } + + for(i = 0; i < framePtr.data.length; i++) { + // Loop through this frames data, find out where it should go, + // and merge it! + traceIndex = traceIndices[i]; + if(traceIndex === undefined || traceIndex === null) { + continue; + } + + destIndex = result.traceIndices.indexOf(traceIndex); + if(destIndex === -1) { + destIndex = result.data.length; + result.traceIndices[destIndex] = traceIndex; + } + + copy = lib.extendDeepNoArrays({}, framePtr.data[i]); + expandedObj = lib.expandObjectPaths(copy); + result.data[destIndex] = lib.extendDeepNoArrays(result.data[destIndex] || {}, expandedObj); + } + } + } + + return result; +}; diff --git a/src/lib/is_plain_object.js b/src/lib/is_plain_object.js index ced058e1bb5..1f0748e8e27 100644 --- a/src/lib/is_plain_object.js +++ b/src/lib/is_plain_object.js @@ -11,6 +11,15 @@ // more info: http://stackoverflow.com/questions/18531624/isplainobject-thing module.exports = function isPlainObject(obj) { + + // We need to be a little less strict in the `imagetest` container because + // of how async image requests are handled. + // + // N.B. isPlainObject(new Constructor()) will return true in `imagetest` + if(window && window.process && window.process.versions) { + return Object.prototype.toString.call(obj) === '[object Object]'; + } + return ( Object.prototype.toString.call(obj) === '[object Object]' && Object.getPrototypeOf(obj) === Object.prototype diff --git a/src/lib/notifier.js b/src/lib/notifier.js index a1bfbfcc14f..ae6a741783f 100644 --- a/src/lib/notifier.js +++ b/src/lib/notifier.js @@ -16,12 +16,13 @@ var NOTEDATA = []; /** * notifier + * @param {object} gd figure Object * @param {String} text The person's user name * @param {Number} [delay=1000] The delay time in milliseconds * or 'long' which provides 2000 ms delay time. * @return {undefined} this function does not return a value */ -module.exports = function(text, displayLength) { +module.exports = function(gd, text, displayLength) { if(NOTEDATA.indexOf(text) !== -1) return; NOTEDATA.push(text); @@ -30,7 +31,7 @@ module.exports = function(text, displayLength) { if(isNumeric(displayLength)) ts = displayLength; else if(displayLength === 'long') ts = 3000; - var notifierContainer = d3.select('body') + var notifierContainer = d3.select(gd._document.body) .selectAll('.plotly-notifier') .data([0]); notifierContainer.enter() diff --git a/src/lib/plotcss_utils.js b/src/lib/plotcss_utils.js new file mode 100644 index 00000000000..58f7383e8f6 --- /dev/null +++ b/src/lib/plotcss_utils.js @@ -0,0 +1,81 @@ +/** +* 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 lib = require('./index'); +var plotcss = require('../../build/plotcss'); + +// Inject styling information into the document containing the graph div +exports.injectStyles = function injectStyles(gd) { + // If the graph div has already been styled, bail + if(gd._plotCSSLoaded) return; + + var targetSelectors = exports.getAllRuleSelectors(gd._document); + var targetStyleSheet = null; + + if(gd._document.getElementsByTagName('style').length === 0) { + var style = gd._document.createElement('style'); + // WebKit hack :( + style.appendChild(gd._document.createTextNode('')); + gd._document.head.appendChild(style); + targetStyleSheet = style.sheet; + } + else { + // Just grab the first style element to append to + targetStyleSheet = gd._document.getElementsByTagName('style')[0].sheet; + } + + for(var selector in plotcss) { + var fullSelector = exports.buildFullSelector(selector); + + // Don't duplicate selectors + if(targetSelectors.indexOf(fullSelector) === -1) { + if(targetStyleSheet.insertRule) { + targetStyleSheet.insertRule(fullSelector + '{' + plotcss[selector] + '}', 0); + } + else if(targetStyleSheet.addRule) { + targetStyleSheet.addRule(fullSelector, plotcss[selector], 0); + } + else lib.warn('injectStyles failed'); + } + } + + gd._plotCSSLoaded = true; +}; + +// expands a plotcss selector +exports.buildFullSelector = function buildFullSelector(selector) { + var fullSelector = selector.replace(/,/, ', ') + .replace(/:after/g, '::after') + .replace(/:before/g, '::before') + .replace(/X/g, '.js-plotly-plot .plotly') + .replace(/Y/g, '.plotly-notifier'); + + return fullSelector; +}; + +// Gets all the rules currently attached to the document +exports.getAllRuleSelectors = function getAllRuleSelectors(sourceDocument) { + var allSelectors = []; + + for(var i = 0; i < sourceDocument.styleSheets.length; i++) { + var styleSheet = sourceDocument.styleSheets[i]; + + if(!styleSheet.cssRules) continue; // It's possible for rules to be undefined + + for(var j = 0; j < styleSheet.cssRules.length; j++) { + var cssRule = styleSheet.cssRules[j]; + + allSelectors.push(cssRule.selectorText); + } + } + + return allSelectors; +}; diff --git a/src/lib/svg_text_utils.js b/src/lib/svg_text_utils.js index 922c4b0213e..fe995cc74f7 100644 --- a/src/lib/svg_text_utils.js +++ b/src/lib/svg_text_utils.js @@ -242,6 +242,15 @@ util.plainText = function(_str) { return (_str || '').replace(STRIP_TAGS, ' '); }; +function encodeForHTML(_str) { + return (_str || '').replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(/\//g, '/'); +} + function convertToSVG(_str) { var htmlEntitiesDecoded = Plotly.util.html_entity_decode(_str); var result = htmlEntitiesDecoded @@ -270,15 +279,14 @@ function convertToSVG(_str) { // remove quotes, leading '=', replace '&' with '&' var href = extra.substr(4) .replace(/["']/g, '') - .replace(/=/, '') - .replace(/&/g, '&'); + .replace(/=/, ''); // check protocol var dummyAnchor = document.createElement('a'); dummyAnchor.href = href; if(PROTOCOLS.indexOf(dummyAnchor.protocol) === -1) return ''; - return ''; + return ''; } } else if(tag === 'br') return '
'; @@ -302,7 +310,7 @@ function convertToSVG(_str) { // most of the svg css users will care about is just like html, // but font color is different. Let our users ignore this. extraStyle = extraStyle[1].replace(/(^|;)\s*color:/, '$1 fill:'); - style = (style ? style + ';' : '') + extraStyle; + style = (style ? style + ';' : '') + encodeForHTML(extraStyle); } return tspanStart + (style ? ' style="' + style + '"' : '') + '>'; diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 9dc8c7b1e57..0b5f11ecfb3 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -54,6 +54,14 @@ Plotly.plot = function(gd, data, layout, config) { gd = getGraphDiv(gd); + // Get the document the graph div lives in, so we can make sure things like + // drag covers are attached to the correct document + gd._document = gd.ownerDocument || window.document; + + // Inject the plot styles into the document where we're plotting, bails if + // already styled + Lib.injectStyles(gd); + // Events.init is idempotent and bails early if gd has already been init'd Events.init(gd); @@ -326,10 +334,6 @@ Plotly.plot = function(gd, data, layout, config) { // so that the caller doesn't care which route we took return Promise.all(gd._promises).then(function() { return gd; - }, function() { - // clear the promise queue if one of them got rejected - Lib.log('Clearing previous rejected promises from queue.'); - gd._promises = []; }); }; @@ -355,6 +359,12 @@ function getGraphDiv(gd) { return gd; // otherwise assume that gd is a DOM element } +// clear the promise queue if one of them got rejected +function clearPromiseQueue(gd) { + Lib.log('Clearing previous rejected promises from queue.'); + gd._promises = []; +} + function opaqueSetBackground(gd, bgColor) { gd._fullLayout._paperdiv.style('background', 'white'); Plotly.defaultConfig.setBackground(gd, bgColor); @@ -847,13 +857,17 @@ Plotly.newPlot = function(gd, data, layout, config) { return Plotly.plot(gd, data, layout, config); }; -function doCalcdata(gd) { +function doCalcdata(gd, traces) { var axList = Plotly.Axes.list(gd), fullData = gd._fullData, fullLayout = gd._fullLayout, i; - var calcdata = gd.calcdata = new Array(fullData.length); + // XXX: Is this correct? Needs a closer look so that *some* traces can be recomputed without + // *all* needing doCalcdata: + var calcdata = new Array(fullData.length); + var oldCalcdata = (gd.calcdata || []).slice(0); + gd.calcdata = calcdata; // extra helper variables // firstscatter: fill-to-next on the first trace goes to zero @@ -877,9 +891,22 @@ function doCalcdata(gd) { } for(i = 0; i < fullData.length; i++) { + // If traces were specified and this trace was not included, then transfer it over from + // the old calcdata: + if(Array.isArray(traces) && traces.indexOf(i) === -1) { + calcdata[i] = oldCalcdata[i]; + continue; + } + var trace = fullData[i], _module = trace._module, cd = []; + // If traces were specified and this trace was not included, then transfer it over from + // the old calcdata: + if(Array.isArray(traces) && traces.indexOf(i) === -1) { + calcdata[i] = oldCalcdata[i]; + continue; + } if(_module && trace.visible === true) { if(_module.calc) cd = _module.calc(gd, trace); @@ -1536,6 +1563,7 @@ Plotly.moveTraces = function moveTraces(gd, currentIndices, newIndices) { // style files that want to specify cyclical default values). Plotly.restyle = function restyle(gd, astr, val, traces) { gd = getGraphDiv(gd); + clearPromiseQueue(gd); var i, fullLayout = gd._fullLayout, aobj = {}; @@ -2076,6 +2104,7 @@ function swapXYData(trace) { // allows setting multiple attributes simultaneously Plotly.relayout = function relayout(gd, astr, val) { gd = getGraphDiv(gd); + clearPromiseQueue(gd); if(gd.framework && gd.framework.isPolar) { return Promise.resolve(gd); @@ -2479,6 +2508,314 @@ Plotly.relayout = function relayout(gd, astr, val) { }); }; +/** + * Transition to a set of new data and layout properties + * + * @param {string id or DOM element} gd + * the id or DOM element of the graph container div + */ +Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { + gd = getGraphDiv(gd); + + var i, traceIdx; + var fullLayout = gd._fullLayout; + + transitionConfig = Lib.extendFlat({ + ease: 'cubic-in-out', + duration: 500, + delay: 0, + cascade: 0 + }, transitionConfig || {}); + + // Create a single transition to be passed around: + if(transitionConfig.duration > 0) { + gd._currentTransition = d3.transition() + .duration(transitionConfig.duration) + .delay(transitionConfig.delay) + .ease(transitionConfig.ease); + } else { + gd._currentTransition = null; + } + + var dataLength = Array.isArray(data) ? data.length : 0; + + // Select which traces will be updated: + if(isNumeric(traceIndices)) traceIndices = [traceIndices]; + else if(!Array.isArray(traceIndices) || !traceIndices.length) { + traceIndices = gd._fullData.map(function(v, i) {return i;}); + } + + if(traceIndices.length > dataLength) { + traceIndices = traceIndices.slice(0, dataLength); + } + + var transitionedTraces = []; + + function prepareTransitions() { + for(i = 0; i < traceIndices.length; i++) { + var traceIdx = traceIndices[i]; + var trace = gd._fullData[traceIdx]; + var module = trace._module; + + if(!module.animatable) { + continue; + } + + transitionedTraces.push(traceIdx); + + // This is a multi-step process. First clone w/o arrays so that + // we're not modifying the original: + var update = Lib.extendDeepNoArrays({}, data[i]); + + // Then expand object paths since we don't obey object-overwrite + // semantics here: + update = Lib.expandObjectPaths(update); + + // Finally apply the update (without copying arrays, of course): + Lib.extendDeepNoArrays(gd.data[traceIndices[i]], update); + } + + Plots.supplyDefaults(gd); + + // TODO: Add logic that computes transitionedTraces to avoid unnecessary work while + // still handling things like box plots that are interrelated. + // doCalcdata(gd, transitionedTraces); + + doCalcdata(gd); + + ErrorBars.calc(gd); + } + + var restyleList = []; + var completionTimeout = null; + var resolveTransitionCallback = null; + + function executeTransitions() { + var hasTraceTransition = false; + var j; + var basePlotModules = fullLayout._basePlotModules; + for(j = 0; j < basePlotModules.length; j++) { + if(basePlotModules[j].animatable) { + hasTraceTransition = true; + } + basePlotModules[j].plot(gd, transitionedTraces, transitionConfig); + } + + var hasAxisTransition = false; + + if(layout) { + for(j = 0; j < basePlotModules.length; j++) { + if(basePlotModules[j].transitionAxes) { + var newLayout = Lib.expandObjectPaths(layout); + hasAxisTransition = hasAxisTransition || basePlotModules[j].transitionAxes(gd, newLayout, transitionConfig); + } + } + } + + if(!hasAxisTransition && !hasTraceTransition) { + return false; + } + + return new Promise(function(resolve) { + resolveTransitionCallback = resolve; + completionTimeout = setTimeout(resolve, transitionConfig.duration); + }); + } + + function interruptPreviousTransitions() { + clearTimeout(completionTimeout); + + if(resolveTransitionCallback) { + resolveTransitionCallback(); + } + + while(gd._frameData._layoutInterrupts.length) { + (gd._frameData._layoutInterrupts.pop())(); + } + + while(gd._frameData._styleInterrupts.length) { + (gd._frameData._styleInterrupts.pop())(); + } + } + + for(i = 0; i < traceIndices.length; i++) { + traceIdx = traceIndices[i]; + var contFull = gd._fullData[traceIdx]; + var module = contFull._module; + + if(!module.animatable) { + var thisUpdate = {}; + + for(var ai in data[i]) { + thisUpdate[ai] = [data[i][ai]]; + } + + restyleList.push((function(md, data, traces) { + return function() { + return Plotly.restyle(gd, data, traces); + }; + }(module, thisUpdate, [traceIdx]))); + } + } + + var seq = [Plots.previousPromises, interruptPreviousTransitions, prepareTransitions, executeTransitions]; + seq = seq.concat(restyleList); + + var plotDone = Lib.syncOrAsync(seq, gd); + + if(!plotDone || !plotDone.then) plotDone = Promise.resolve(); + + return plotDone.then(function() { + gd.emit('plotly_beginanimate', []); + return gd; + }); +}; + +/** + * Animate to a keyframe + * + * @param {string} name + * name of the keyframe to create + * @param {object} transitionConfig + * configuration for transition + */ +Plotly.animate = function(gd, frameName, transitionConfig) { + gd = getGraphDiv(gd); + + if(!gd._frameData._frameHash[frameName]) { + Lib.warn('animateToFrame failure: keyframe does not exist', frameName); + return Promise.reject(); + } + + var computedFrame = Plots.computeFrame(gd, frameName); + + return Plotly.transition(gd, + computedFrame.data, + computedFrame.layout, + computedFrame.traceIndices, + transitionConfig + ); +}; + +/** + * Create new keyframes + * + * @param {array of objects} frameList + * list of frame definitions, in which each object includes any of: + * - name: {string} name of keyframe to add + * - data: {array of objects} trace data + * - layout {object} layout definition + * - traces {array} trace indices + * - baseFrame {string} name of keyframe from which this keyframe gets defaults + */ +Plotly.addFrames = function(gd, frameList, indices) { + gd = getGraphDiv(gd); + + var i, frame, j, idx; + var _frames = gd._frameData._frames; + var _hash = gd._frameData._frameHash; + + + if(!Array.isArray(frameList)) { + Lib.warn('addFrames failure: frameList must be an Array of frame definitions', frameList); + return Promise.reject(); + } + + // Create a sorted list of insertions since we run into lots of problems if these + // aren't in ascending order of index: + // + // Strictly for sorting. Make sure this is guaranteed to never collide with any + // already-exisisting indices: + var bigIndex = _frames.length + frameList.length * 2; + + var insertions = []; + for(i = frameList.length - 1; i >= 0; i--) { + insertions.push({ + frame: frameList[i], + index: (indices && indices[i] !== undefined && indices[i] !== null) ? indices[i] : bigIndex + i + }); + } + + // Sort this, taking note that undefined insertions end up at the end: + insertions.sort(function(a, b) { + if(a.index > b.index) return -1; + if(a.index < b.index) return 1; + return 0; + }); + + var ops = []; + var revops = []; + var frameCount = _frames.length; + + for(i = insertions.length - 1; i >= 0; i--) { + frame = insertions[i].frame; + + if(!frame.name) { + // Repeatedly assign a default name, incrementing the counter each time until + // we get a name that's not in the hashed lookup table: + while(_hash[(frame.name = 'frame ' + gd._frameData._counter++)]); + } + + if(_hash[frame.name]) { + // If frame is present, overwrite its definition: + for(j = 0; j < _frames.length; j++) { + if(_frames[j].name === frame.name) break; + } + ops.push({type: 'replace', index: j, value: frame}); + revops.unshift({type: 'replace', index: j, value: _frames[j]}); + } else { + // Otherwise insert it at the end of the list: + idx = Math.max(0, Math.min(insertions[i].index, frameCount)); + + ops.push({type: 'insert', index: idx, value: frame}); + revops.unshift({type: 'delete', index: idx}); + frameCount++; + } + } + + var undoFunc = Plots.modifyFrames, + redoFunc = Plots.modifyFrames, + undoArgs = [gd, revops], + redoArgs = [gd, ops]; + + if(Queue) Queue.add(gd, undoFunc, undoArgs, redoFunc, redoArgs); + + return Plots.modifyFrames(gd, ops); +}; + +/** + * Delete keyframes + * + * @param {array of integers} frameList + * list of integer indices of frames to be deleted + */ +Plotly.deleteFrames = function(gd, frameList) { + gd = getGraphDiv(gd); + + var i, idx; + var _frames = gd._frameData._frames; + var ops = []; + var revops = []; + + frameList = frameList.slice(0); + frameList.sort(); + + for(i = frameList.length - 1; i >= 0; i--) { + idx = frameList[i]; + ops.push({type: 'delete', index: idx}); + revops.unshift({type: 'insert', index: idx, value: _frames[idx]}); + } + + var undoFunc = Plots.modifyFrames, + redoFunc = Plots.modifyFrames, + undoArgs = [gd, revops], + redoArgs = [gd, ops]; + + if(Queue) Queue.add(gd, undoFunc, undoArgs, redoFunc, redoArgs); + + return Plots.modifyFrames(gd, ops); +}; + /** * Purge a graph container div back to its initial pre-Plotly.plot state * @@ -2547,12 +2884,12 @@ function plotAutoSize(gd, aobj) { // embedded in an iframe - just take the full iframe size // if we get to this point, with no aspect ratio restrictions if(gd._context.fillFrame) { - newWidth = window.innerWidth; - newHeight = window.innerHeight; + newWidth = gd._document.defaultView.innerWidth; + newHeight = gd._document.defaultView.innerHeight; // somehow we get a few extra px height sometimes... // just hide it - document.body.style.overflow = 'hidden'; + gd._document.body.style.overflow = 'hidden'; } else if(isNumeric(context.frameMargins) && context.frameMargins > 0) { var reservedMargins = calculateReservedMargins(gd._boundingBoxMargins), @@ -2569,7 +2906,7 @@ function plotAutoSize(gd, aobj) { // provide height and width for the container div, // specify size in layout, or take the defaults, // but don't enforce any ratio restrictions - computedStyle = window.getComputedStyle(gd); + computedStyle = gd._document.defaultView.getComputedStyle(gd); newHeight = parseFloat(computedStyle.height) || fullLayout.height; newWidth = parseFloat(computedStyle.width) || fullLayout.width; } diff --git a/src/plotly.js b/src/plotly.js index 32f552a45a4..3f405bb39e0 100644 --- a/src/plotly.js +++ b/src/plotly.js @@ -26,9 +26,6 @@ var Lib = exports.Lib = require('./lib'); exports.util = require('./lib/svg_text_utils'); exports.Queue = require('./lib/queue'); -// plot css -require('../build/plotcss'); - // configuration exports.MathJaxConfig = require('./fonts/mathjax_config'); exports.defaultConfig = require('./plot_api/plot_config'); diff --git a/src/plots/cartesian/dragbox.js b/src/plots/cartesian/dragbox.js index 5b18574482f..81801d93d36 100644 --- a/src/plots/cartesian/dragbox.js +++ b/src/plots/cartesian/dragbox.js @@ -305,7 +305,7 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { dragTail(zoomMode); if(SHOWZOOMOUTTIP && gd.data && gd._context.showTips) { - Lib.notifier('Double-click to
zoom back out', 'long'); + Lib.notifier(gd, 'Double-click to
zoom back out', 'long'); SHOWZOOMOUTTIP = false; } } diff --git a/src/plots/cartesian/index.js b/src/plots/cartesian/index.js index 56f827f0ec6..63aa425bf1a 100644 --- a/src/plots/cartesian/index.js +++ b/src/plots/cartesian/index.js @@ -25,61 +25,89 @@ exports.attrRegex = constants.attrRegex; exports.attributes = require('./attributes'); -exports.plot = function(gd) { +exports.transitionAxes = require('./transition_axes'); + +exports.plot = function(gd, traces, transitionOpts) { + var cdSubplot, cd, trace, i, j, k; + var fullLayout = gd._fullLayout, subplots = Plots.getSubplotIds(fullLayout, 'cartesian'), calcdata = gd.calcdata, modules = fullLayout._modules; - function getCdSubplot(calcdata, subplot) { - var cdSubplot = []; - - for(var i = 0; i < calcdata.length; i++) { - var cd = calcdata[i]; - var trace = cd[0].trace; - - if(trace.xaxis + trace.yaxis === subplot) { - cdSubplot.push(cd); - } + if(!Array.isArray(traces)) { + // If traces is not provided, then it's a complete replot and missing + // traces are removed + traces = []; + for(i = 0; i < calcdata.length; i++) { + traces.push(i); } - - return cdSubplot; } - function getCdModule(cdSubplot, _module) { - var cdModule = []; + for(i = 0; i < subplots.length; i++) { + var subplot = subplots[i], + subplotInfo = fullLayout._plots[subplot]; + + // Get all calcdata for this subplot: + cdSubplot = []; + var pcd; + for(j = 0; j < calcdata.length; j++) { + cd = calcdata[j]; + trace = cd[0].trace; - for(var i = 0; i < cdSubplot.length; i++) { - var cd = cdSubplot[i]; - var trace = cd[0].trace; + // Skip trace if whitelist provided and it's not whitelisted: + // if (Array.isArray(traces) && traces.indexOf(i) === -1) continue; - if((trace._module === _module) && (trace.visible === true)) { - cdModule.push(cd); + if(trace.xaxis + trace.yaxis === subplot) { + // Okay, so example: traces 0, 1, and 2 have fill = tonext. You animate + // traces 0 and 2. Trace 1 also needs to be updated, otherwise its fill + // is outdated. So this retroactively adds the previous trace if the + // traces are interdependent. + if(pcd && + ['tonextx', 'tonexty', 'tonext'].indexOf(trace.fill) !== -1 && + cdSubplot.indexOf(pcd) === -1) { + cdSubplot.push(pcd); + } + + // If this trace is specifically requested, add it to the list: + if(traces.indexOf(trace.index) !== -1) { + cdSubplot.push(cd); + } + + // Track the previous trace on this subplot for the retroactive-add step + // above: + pcd = cd; } } - return cdModule; - } - - for(var i = 0; i < subplots.length; i++) { - var subplot = subplots[i], - subplotInfo = fullLayout._plots[subplot], - cdSubplot = getCdSubplot(calcdata, subplot); - // remove old traces, then redraw everything - // TODO: use enter/exit appropriately in the plot functions - // so we don't need this - should sometimes be a big speedup - if(subplotInfo.plot) subplotInfo.plot.selectAll('g.trace').remove(); + // TODO: scatterlayer is manually excluded from this since it knows how + // to update instead of fully removing and redrawing every time. The + // remaining plot traces should also be able to do this. Once implemented, + // we won't need this - which should sometimes be a big speedup. + if(subplotInfo.plot) { + subplotInfo.plot.selectAll('g:not(.scatterlayer)').selectAll('g.trace').remove(); + } - for(var j = 0; j < modules.length; j++) { + // Plot all traces for each module at once: + for(j = 0; j < modules.length; j++) { var _module = modules[j]; // skip over non-cartesian trace modules if(_module.basePlotModule.name !== 'cartesian') continue; // plot all traces of this type on this subplot at once - var cdModule = getCdModule(cdSubplot, _module); - _module.plot(gd, subplotInfo, cdModule); + var cdModule = []; + for(k = 0; k < cdSubplot.length; k++) { + cd = cdSubplot[k]; + trace = cd[0].trace; + + if((trace._module === _module) && (trace.visible === true)) { + cdModule.push(cd); + } + } + + _module.plot(gd, subplotInfo, cdModule, transitionOpts); } } }; diff --git a/src/plots/cartesian/set_convert.js b/src/plots/cartesian/set_convert.js index 565c4ce53b3..5f34cde15e9 100644 --- a/src/plots/cartesian/set_convert.js +++ b/src/plots/cartesian/set_convert.js @@ -114,6 +114,7 @@ module.exports = function setConvert(ax) { if(!isFinite(ax._m) || !isFinite(ax._b)) { Lib.notifier( + ax._gd, 'Something went wrong with axis scaling', 'long'); ax._gd._replotting = false; diff --git a/src/plots/cartesian/transition_axes.js b/src/plots/cartesian/transition_axes.js new file mode 100644 index 00000000000..96aa8b8eca7 --- /dev/null +++ b/src/plots/cartesian/transition_axes.js @@ -0,0 +1,295 @@ +/** +* 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 Plotly = require('../../plotly'); +var Lib = require('../../lib'); +var Axes = require('./axes'); + +var axisRegex = /((x|y)([2-9]|[1-9][0-9]+)?)axis$/; + +module.exports = function transitionAxes(gd, newLayout, transitionConfig) { + var fullLayout = gd._fullLayout; + var axes = []; + + function computeUpdates(layout) { + var ai, attrList, match, axis, update; + var updates = {}; + + for(ai in layout) { + attrList = ai.split('.'); + match = attrList[0].match(axisRegex); + if(match) { + var axisName = match[1]; + axis = fullLayout[axisName + 'axis']; + update = {}; + + if(Array.isArray(layout[ai])) { + update.to = layout[ai].slice(0); + } else { + if(Array.isArray(layout[ai].range)) { + update.to = layout[ai].range.slice(0); + } + } + if(!update.to) continue; + + update.axis = axis; + update.length = axis._length; + + axes.push(axisName); + + updates[axisName] = update; + } + } + + return updates; + } + + function computeAffectedSubplots(fullLayout, updatedAxisIds, updates) { + var plotName; + var plotinfos = fullLayout._plots; + var affectedSubplots = []; + var toX, toY; + + for(plotName in plotinfos) { + var plotinfo = plotinfos[plotName]; + + if(affectedSubplots.indexOf(plotinfo) !== -1) continue; + + var x = plotinfo.xaxis._id; + var y = plotinfo.yaxis._id; + var fromX = plotinfo.xaxis.range; + var fromY = plotinfo.yaxis.range; + if(updates[x]) { + toX = updates[x].to; + } else { + toX = fromX; + } + if(updates[y]) { + toY = updates[y].to; + } else { + toY = fromY; + } + + if(fromX[0] === toX[0] && fromX[1] === toX[1] && fromY[0] === toY[0] && fromY[1] === toY[1]) continue; + + if(updatedAxisIds.indexOf(x) !== -1 || updatedAxisIds.indexOf(y) !== -1) { + affectedSubplots.push(plotinfo); + } + } + + return affectedSubplots; + } + + var updates = computeUpdates(newLayout); + var updatedAxisIds = Object.keys(updates); + var affectedSubplots = computeAffectedSubplots(fullLayout, updatedAxisIds, updates); + + if(!affectedSubplots.length) { + return false; + } + + function ticksAndAnnotations(xa, ya) { + var activeAxIds = [], + i; + + activeAxIds = [xa._id, ya._id]; + + for(i = 0; i < activeAxIds.length; i++) { + Axes.doTicks(gd, activeAxIds[i], true); + } + + function redrawObjs(objArray, module) { + var obji; + for(i = 0; i < objArray.length; i++) { + obji = objArray[i]; + if((activeAxIds.indexOf(obji.xref) !== -1) || + (activeAxIds.indexOf(obji.yref) !== -1)) { + module.draw(gd, i); + } + } + } + + redrawObjs(fullLayout.annotations || [], Plotly.Annotations); + redrawObjs(fullLayout.shapes || [], Plotly.Shapes); + redrawObjs(fullLayout.images || [], Plotly.Images); + } + + function unsetSubplotTransform(subplot) { + var xa2 = subplot.x(); + var ya2 = subplot.y(); + + var viewBox = [0, 0, xa2._length, ya2._length]; + + var xScaleFactor = xa2._length / viewBox[2], + yScaleFactor = ya2._length / viewBox[3]; + + var clipDx = viewBox[0], + clipDy = viewBox[1]; + + var fracDx = (viewBox[0] / viewBox[2] * xa2._length), + fracDy = (viewBox[1] / viewBox[3] * ya2._length); + + var plotDx = xa2._offset - fracDx, + plotDy = ya2._offset - fracDy; + + fullLayout._defs.selectAll('#' + subplot.clipId) + .call(Lib.setTranslate, clipDx, clipDy) + .call(Lib.setScale, 1 / xScaleFactor, 1 / yScaleFactor); + + subplot.plot + .call(Lib.setTranslate, plotDx, plotDy) + .call(Lib.setScale, xScaleFactor, yScaleFactor) + + // This is specifically directed at scatter traces, applying an inverse + // scale to individual points to counteract the scale of the trace + // as a whole: + .selectAll('.points').selectAll('.point') + .call(Lib.setPointGroupScale, 1 / xScaleFactor, 1 / yScaleFactor); + + } + + function updateSubplot(subplot, progress) { + var axis, r0, r1; + var xUpdate = updates[subplot.xaxis._id]; + var yUpdate = updates[subplot.yaxis._id]; + + var viewBox = []; + + if(xUpdate) { + axis = xUpdate.axis; + r0 = axis._r; + r1 = xUpdate.to; + viewBox[0] = (r0[0] * (1 - progress) + progress * r1[0] - r0[0]) / (r0[1] - r0[0]) * subplot.xaxis._length; + var dx1 = r0[1] - r0[0]; + var dx2 = r1[1] - r1[0]; + + axis.range[0] = r0[0] * (1 - progress) + progress * r1[0]; + axis.range[1] = r0[1] * (1 - progress) + progress * r1[1]; + + viewBox[2] = subplot.xaxis._length * ((1 - progress) + progress * dx2 / dx1); + } else { + viewBox[0] = 0; + viewBox[2] = subplot.xaxis._length; + } + + if(yUpdate) { + axis = yUpdate.axis; + r0 = axis._r; + r1 = yUpdate.to; + viewBox[1] = (r0[1] * (1 - progress) + progress * r1[1] - r0[1]) / (r0[0] - r0[1]) * subplot.yaxis._length; + var dy1 = r0[1] - r0[0]; + var dy2 = r1[1] - r1[0]; + + axis.range[0] = r0[0] * (1 - progress) + progress * r1[0]; + axis.range[1] = r0[1] * (1 - progress) + progress * r1[1]; + + viewBox[3] = subplot.yaxis._length * ((1 - progress) + progress * dy2 / dy1); + } else { + viewBox[1] = 0; + viewBox[3] = subplot.yaxis._length; + } + + ticksAndAnnotations(subplot.x(), subplot.y()); + + + var xa2 = subplot.x(); + var ya2 = subplot.y(); + + var editX = !!xUpdate; + var editY = !!yUpdate; + + var xScaleFactor = editX ? xa2._length / viewBox[2] : 1, + yScaleFactor = editY ? ya2._length / viewBox[3] : 1; + + var clipDx = editX ? viewBox[0] : 0, + clipDy = editY ? viewBox[1] : 0; + + var fracDx = editX ? (viewBox[0] / viewBox[2] * xa2._length) : 0, + fracDy = editY ? (viewBox[1] / viewBox[3] * ya2._length) : 0; + + var plotDx = xa2._offset - fracDx, + plotDy = ya2._offset - fracDy; + + fullLayout._defs.selectAll('#' + subplot.clipId) + .call(Lib.setTranslate, clipDx, clipDy) + .call(Lib.setScale, 1 / xScaleFactor, 1 / yScaleFactor); + + subplot.plot + .call(Lib.setTranslate, plotDx, plotDy) + .call(Lib.setScale, xScaleFactor, yScaleFactor) + + // This is specifically directed at scatter traces, applying an inverse + // scale to individual points to counteract the scale of the trace + // as a whole: + .selectAll('.points').selectAll('.point') + .call(Lib.setPointGroupScale, 1 / xScaleFactor, 1 / yScaleFactor); + + } + + // transitionTail - finish a drag event with a redraw + function transitionTail() { + var i; + var attrs = {}; + // revert to the previous axis settings, then apply the new ones + // through relayout - this lets relayout manage undo/redo + for(i = 0; i < updatedAxisIds.length; i++) { + var axi = updates[updatedAxisIds[i]].axis; + if(axi._r[0] !== axi.range[0]) attrs[axi._name + '.range[0]'] = axi.range[0]; + if(axi._r[1] !== axi.range[1]) attrs[axi._name + '.range[1]'] = axi.range[1]; + + axi.range = axi._r.slice(); + } + + for(i = 0; i < affectedSubplots.length; i++) { + unsetSubplotTransform(affectedSubplots[i]); + } + + Plotly.relayout(gd, attrs); + } + + var easeFn = d3.ease(transitionConfig.ease); + + return new Promise(function(resolve, reject) { + var t1, t2, raf; + + gd._frameData._layoutInterrupts.push(function() { + reject(); + cancelAnimationFrame(raf); + raf = null; + transitionTail(); + }); + + function doFrame() { + t2 = Date.now(); + + var tInterp = Math.min(1, (t2 - t1) / transitionConfig.duration); + var progress = easeFn(tInterp); + + for(var i = 0; i < affectedSubplots.length; i++) { + updateSubplot(affectedSubplots[i], progress); + } + + if(t2 - t1 > transitionConfig.duration) { + raf = cancelAnimationFrame(doFrame); + transitionTail(); + resolve(); + } else { + raf = requestAnimationFrame(doFrame); + resolve(); + } + } + + t1 = Date.now(); + raf = requestAnimationFrame(doFrame); + }); +}; diff --git a/src/plots/mapbox/layers.js b/src/plots/mapbox/layers.js index 8fa4890d384..c5de5901a5c 100644 --- a/src/plots/mapbox/layers.js +++ b/src/plots/mapbox/layers.js @@ -131,11 +131,8 @@ proto.dispose = function dispose() { function isVisible(opts) { var source = opts.source; - // For some weird reason Lib.isPlainObject fails - // to detect `source` as a plain object in nw.js 0.12. - return ( - typeof source === 'object' || + Lib.isPlainObject(source) || (typeof source === 'string' && source.length > 0) ); } diff --git a/src/plots/plots.js b/src/plots/plots.js index 2e0a4d28e85..0964303cf49 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -453,6 +453,10 @@ plots.sendDataToCloud = function(gd) { // gd._fullLayout._basePlotModules // is a list of all the plot modules required to draw the plot. // +// gd._frameData +// object containing frame definitions (_frameData._frames) and +// associated metadata. +// plots.supplyDefaults = function(gd) { var oldFullLayout = gd._fullLayout || {}, newFullLayout = gd._fullLayout = {}, @@ -508,6 +512,15 @@ plots.supplyDefaults = function(gd) { // relink functions and _ attributes to promote consistency between plots relinkPrivateKeys(newFullLayout, oldFullLayout); + // XXX: This is a hack that should be refactored by more generally removing the + // need for relinkPrivateKeys + var subplots = plots.getSubplotIds(newFullLayout, 'cartesian'); + for(i = 0; i < subplots.length; i++) { + var subplot = newFullLayout._plots[subplots[i]]; + subplot.xaxis = newFullLayout[subplot.xaxis._name]; + subplot.yaxis = newFullLayout[subplot.yaxis._name]; + } + plots.doAutoMargin(gd); // can't quite figure out how to get rid of this... each axis needs @@ -526,6 +539,31 @@ plots.supplyDefaults = function(gd) { (gd.calcdata[i][0] || {}).trace = trace; } } + + // Set up the default keyframe if it doesn't exist: + if(!gd._frameData) { + gd._frameData = {}; + } + + if(!gd._frameData._frames) { + gd._frameData._frames = []; + } + + if(!gd._frameData._frameHash) { + gd._frameData._frameHash = {}; + } + + if(!gd._frameData._counter) { + gd._frameData._counter = 0; + } + + if(!gd._frameData._layoutInterrupts) { + gd._frameData._layoutInterrupts = []; + } + + if(!gd._frameData._styleInterrupts) { + gd._frameData._styleInterrupts = []; + } }; // helper function to be bound to fullLayout to check @@ -568,12 +606,17 @@ plots.cleanPlot = function(newFullData, newFullLayout, oldFullData, oldFullLayou if(oldUid === newTrace.uid) continue oldLoop; } - // clean old heatmap and contour traces + // clean old heatmap, contour, and scatter traces + // + // Note: This is also how scatter traces (cartesian and scatterternary) get + // removed since otherwise the scatter module is not called (and so the join + // doesn't register the removal) if scatter traces disappear entirely. if(hasPaper) { oldFullLayout._paper.selectAll( '.hm' + oldUid + ',.contour' + oldUid + - ',#clip' + oldUid + ',#clip' + oldUid + + ',.trace' + oldUid ).remove(); } @@ -891,6 +934,10 @@ plots.purge = function(gd) { // remove modebar if(fullLayout._modeBar) fullLayout._modeBar.destroy(); + // styling + delete gd._document; + delete gd._plotCSSLoaded; + // data and layout delete gd.data; delete gd.layout; @@ -920,6 +967,7 @@ plots.purge = function(gd) { delete gd.numboxes; delete gd._hoverTimer; delete gd._lastHoverTime; + delete gd._frameData; // remove all event listeners if(gd.removeAllListeners) gd.removeAllListeners(); @@ -1184,3 +1232,68 @@ plots.graphJson = function(gd, dataonly, mode, output, useDefaults) { return (output === 'object') ? obj : JSON.stringify(obj); }; + +/** + * Modify a keyframe using a list of operations: + * + * @param {array of objects} operations + * Sequence of operations to be performed on the keyframes + */ +plots.modifyFrames = function(gd, operations) { + var i, op, frame; + var _frames = gd._frameData._frames; + var _hash = gd._frameData._frameHash; + + for(i = 0; i < operations.length; i++) { + op = operations[i]; + + switch(op.type) { + // No reason this couldn't exist, but is currently unused/untested: + /*case 'rename': + frame = _frames[op.index]; + delete _hash[frame.name]; + _hash[op.name] = frame; + frame.name = op.name; + break;*/ + case 'replace': + frame = op.value; + var oldName = _frames[op.index].name; + var newName = frame.name; + _frames[op.index] = _hash[newName] = frame; + + if(newName !== oldName) { + // If name has changed in addition to replacement, then update + // the lookup table: + delete _hash[oldName]; + _hash[newName] = frame; + } + + break; + case 'insert': + frame = op.value; + _hash[frame.name] = frame; + _frames.splice(op.index, 0, frame); + break; + case 'delete': + frame = _frames[op.index]; + delete _hash[frame.name]; + _frames.splice(op.index, 1); + break; + } + } + + return Promise.resolve(); +}; + +/* + * Compute a keyframe. Merge a keyframe into its base frame(s) and + * expand properties. + * + * @param {string} frame + * The name of the keyframe to be computed + * + * Returns: a new object with the merged content + */ +plots.computeFrame = function(gd, frameName) { + return Lib.computeFrame(gd._frameData._frameHash, frameName); +}; diff --git a/src/plots/ternary/ternary.js b/src/plots/ternary/ternary.js index 1fd6d7094f7..9a93f376205 100644 --- a/src/plots/ternary/ternary.js +++ b/src/plots/ternary/ternary.js @@ -570,7 +570,7 @@ proto.initInteractions = function() { Plotly.relayout(gd, attrs); if(SHOWZOOMOUTTIP && gd.data && gd._context.showTips) { - Lib.notifier('Double-click to
zoom back out', 'long'); + Lib.notifier(gd, 'Double-click to
zoom back out', 'long'); SHOWZOOMOUTTIP = false; } } diff --git a/src/traces/heatmap/calc.js b/src/traces/heatmap/calc.js index e8611ccc82e..828c3288db9 100644 --- a/src/traces/heatmap/calc.js +++ b/src/traces/heatmap/calc.js @@ -73,7 +73,7 @@ module.exports = function calc(gd, trace) { function noZsmooth(msg) { zsmooth = trace._input.zsmooth = trace.zsmooth = false; - Lib.notifier('cannot fast-zsmooth: ' + msg); + Lib.notifier(gd, 'cannot fast-zsmooth: ' + msg); } // check whether we really can smooth (ie all boxes are about the same size) diff --git a/src/traces/scatter/attributes.js b/src/traces/scatter/attributes.js index 28db0d1343e..2cc6cb00a68 100644 --- a/src/traces/scatter/attributes.js +++ b/src/traces/scatter/attributes.js @@ -65,6 +65,10 @@ module.exports = { 'See `y0` for more info.' ].join(' ') }, + identifier: { + valType: 'data_array', + description: 'A list of keys for object constancy of data points during animation' + }, text: { valType: 'string', role: 'info', @@ -152,6 +156,16 @@ module.exports = { 'Sets the style of the lines. Set to a dash string type', 'or a dash length in px.' ].join(' ') + }, + simplify: { + valType: 'boolean', + dflt: true, + role: 'info', + description: [ + 'Simplifies lines by removing nearly-collinear points. When transitioning', + 'lines, it may be desirable to disable this so that the number of points', + 'along the resulting SVG path is unaffected.' + ].join(' ') } }, connectgaps: { diff --git a/src/traces/scatter/calc.js b/src/traces/scatter/calc.js index 3ac952cce2d..61f3a6300cd 100644 --- a/src/traces/scatter/calc.js +++ b/src/traces/scatter/calc.js @@ -115,6 +115,10 @@ module.exports = function calc(gd, trace) { for(i = 0; i < serieslen; i++) { cd[i] = (isNumeric(x[i]) && isNumeric(y[i])) ? {x: x[i], y: y[i]} : {x: false, y: false}; + + if(trace.identifier && trace.identifier[i] !== undefined) { + cd[i].identifier = trace.identifier[i]; + } } // this has migrated up from arraysToCalcdata as we have a reference to 's' here diff --git a/src/traces/scatter/defaults.js b/src/traces/scatter/defaults.js index e8582802450..7efa194e810 100644 --- a/src/traces/scatter/defaults.js +++ b/src/traces/scatter/defaults.js @@ -38,6 +38,7 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout coerce('text'); coerce('mode', defaultMode); + coerce('identifier'); if(subTypes.hasLines(traceOut)) { handleLineDefaults(traceIn, traceOut, defaultColor, layout, coerce); diff --git a/src/traces/scatter/index.js b/src/traces/scatter/index.js index 3b576a561d0..5c21f7c6710 100644 --- a/src/traces/scatter/index.js +++ b/src/traces/scatter/index.js @@ -29,6 +29,7 @@ Scatter.colorbar = require('./colorbar'); Scatter.style = require('./style'); Scatter.hoverPoints = require('./hover'); Scatter.selectPoints = require('./select'); +Scatter.animatable = true; Scatter.moduleType = 'trace'; Scatter.name = 'scatter'; diff --git a/src/traces/scatter/line_defaults.js b/src/traces/scatter/line_defaults.js index f5bb0d249fa..e9d88b8274b 100644 --- a/src/traces/scatter/line_defaults.js +++ b/src/traces/scatter/line_defaults.js @@ -28,4 +28,5 @@ module.exports = function lineDefaults(traceIn, traceOut, defaultColor, layout, coerce('line.width'); coerce('line.dash'); + coerce('line.simplify'); }; diff --git a/src/traces/scatter/line_points.js b/src/traces/scatter/line_points.js index 60d7e3c77ea..390242a1fd7 100644 --- a/src/traces/scatter/line_points.js +++ b/src/traces/scatter/line_points.js @@ -15,6 +15,7 @@ var Axes = require('../../plots/cartesian/axes'); module.exports = function linePoints(d, opts) { var xa = opts.xaxis, ya = opts.yaxis, + simplify = opts.simplify, connectGaps = opts.connectGaps, baseTolerance = opts.baseTolerance, linear = opts.linear, @@ -48,6 +49,10 @@ module.exports = function linePoints(d, opts) { clusterMaxDeviation, thisDeviation; + if(!simplify) { + baseTolerance = minTolerance = -1; + } + // turn one calcdata point into pixel coordinates function getPt(index) { var x = xa.c2p(d[index].x), diff --git a/src/traces/scatter/link_traces.js b/src/traces/scatter/link_traces.js new file mode 100644 index 00000000000..801d02b0d64 --- /dev/null +++ b/src/traces/scatter/link_traces.js @@ -0,0 +1,39 @@ +/** +* 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'; + +module.exports = function linkTraces(gd, plotinfo, cdscatter) { + var cd, trace; + var prevtrace = null; + + for(var i = 0; i < cdscatter.length; ++i) { + cd = cdscatter[i]; + trace = cd[0].trace; + + // Note: The check which ensures all cdscatter here are for the same axis and + // are either cartesian or scatterternary has been removed. This code assumes + // the passed scattertraces have been filtered to the proper plot types and + // the proper subplots. + if(trace.visible === true) { + trace._nexttrace = null; + + if(['tonextx', 'tonexty', 'tonext'].indexOf(trace.fill) !== -1) { + trace._prevtrace = prevtrace; + + if(prevtrace) { + prevtrace._nexttrace = trace; + } + } + + prevtrace = trace; + } else { + trace._prevtrace = trace._nexttrace = null; + } + } +}; diff --git a/src/traces/scatter/plot.js b/src/traces/scatter/plot.js index b36ce3f05e9..23f9124e924 100644 --- a/src/traces/scatter/plot.js +++ b/src/traces/scatter/plot.js @@ -15,80 +15,178 @@ var Lib = require('../../lib'); var Drawing = require('../../components/drawing'); var ErrorBars = require('../../components/errorbars'); -var polygonTester = require('../../lib/polygon').tester; - var subTypes = require('./subtypes'); var arraysToCalcdata = require('./arrays_to_calcdata'); var linePoints = require('./line_points'); +var linkTraces = require('./link_traces'); +var polygonTester = require('../../lib/polygon').tester; +module.exports = function plot(gd, plotinfo, cdscatter, transitionConfig) { + var i, uids, selection, join; -module.exports = function plot(gd, plotinfo, cdscatter) { - selectMarkers(gd, plotinfo, cdscatter); + var scatterlayer = plotinfo.plot.select('g.scatterlayer'); - var xa = plotinfo.x(), - ya = plotinfo.y(); + // If transition config is provided, then it is only a partial replot and traces not + // updated are removed. + var isFullReplot = !transitionConfig; - // make the container for scatter plots - // (so error bars can find them along with bars) - var scattertraces = plotinfo.plot.select('.scatterlayer') - .selectAll('g.trace.scatter') - .data(cdscatter); + selection = scatterlayer.selectAll('g.trace'); + + join = selection.data(cdscatter, function(d) {return d[0].trace.uid;}); - scattertraces.enter().append('g') - .attr('class', 'trace scatter') + // Append new traces: + join.enter().append('g') + .attr('class', function(d) { + return 'trace scatter trace' + d[0].trace.uid; + }) .style('stroke-miterlimit', 2); - // error bars are at the bottom - scattertraces.call(ErrorBars.plot, plotinfo); + // After the elements are created but before they've been draw, we have to perform + // this extra step of linking the traces. This allows appending of fill layers so that + // the z-order of fill layers is correct. + linkTraces(gd, plotinfo, cdscatter); - // BUILD LINES AND FILLS - var prevpath = '', - prevPolygons = [], - ownFillEl3, ownFillDir, tonext, nexttonext; + createFills(gd, scatterlayer); - scattertraces.each(function(d) { - var trace = d[0].trace, - line = trace.line, - tr = d3.select(this); - if(trace.visible !== true) return; - - ownFillDir = trace.fill.charAt(trace.fill.length - 1); - if(ownFillDir !== 'x' && ownFillDir !== 'y') ownFillDir = ''; - - d[0].node3 = tr; // store node for tweaking by selectPoints - - arraysToCalcdata(d); - - if(!subTypes.hasLines(trace) && trace.fill === 'none') return; - - var thispath, - thisrevpath, - // fullpath is all paths for this curve, joined together straight - // across gaps, for filling - fullpath = '', - // revpath is fullpath reversed, for fill-to-next - revpath = '', - // functions for converting a point array to a path - pathfn, revpathbase, revpathfn; - - // make the fill-to-zero path now, so it shows behind the line - // fill to next puts the fill associated with one trace - // grouped with the previous - if(trace.fill.substr(0, 6) === 'tozero' || trace.fill === 'toself' || - (trace.fill.substr(0, 2) === 'to' && !prevpath)) { - ownFillEl3 = tr.append('path') - .classed('js-fill', true); - } - else ownFillEl3 = null; + // Sort the traces, once created, so that the ordering is preserved even when traces + // are shown and hidden. This is needed since we're not just wiping everything out + // and recreating on every update. + for(i = 0, uids = []; i < cdscatter.length; i++) { + uids[i] = cdscatter[i][0].trace.uid; + } + + scatterlayer.selectAll('g.trace').sort(function(a, b) { + var idx1 = uids.indexOf(a[0].trace.uid); + var idx2 = uids.indexOf(b[0].trace.uid); + return idx1 > idx2 ? 1 : -1; + }); + + // Must run the selection again since otherwise enters/updates get grouped together + // and these get executed out of order. Except we need them in order! + scatterlayer.selectAll('g.trace').each(function(d, i) { + plotOne(gd, i, plotinfo, d, cdscatter, this, transitionConfig); + }); + + if(isFullReplot) { + join.exit().remove(); + } + + // remove paths that didn't get used + scatterlayer.selectAll('path:not([d])').remove(); +}; + +function createFills(gd, scatterlayer) { + var trace; + + scatterlayer.selectAll('g.trace').each(function(d) { + var tr = d3.select(this); + + // Loop only over the traces being redrawn: + trace = d[0].trace; // make the fill-to-next path now for the NEXT trace, so it shows // behind both lines. - // nexttonext was created last time, but give it - // this curve's data for fill color - if(nexttonext) tonext = nexttonext.datum(d); + if(trace._nexttrace) { + trace._nextFill = tr.select('.js-fill.js-tonext'); + if(!trace._nextFill.size()) { + + // If there is an existing tozero fill, we must insert this *after* that fill: + var loc = ':first-child'; + if(tr.select('.js-fill.js-tozero').size()) { + loc += ' + *'; + } + + trace._nextFill = tr.insert('path', loc).attr('class', 'js-fill js-tonext'); + } + } else { + tr.selectAll('.js-fill.js-tonext').remove(); + trace._nextFill = null; + } + + if(trace.fill && (trace.fill.substr(0, 6) === 'tozero' || trace.fill === 'toself' || + (trace.fill.substr(0, 2) === 'to' && !trace._prevtrace))) { + trace._ownFill = tr.select('.js-fill.js-tozero'); + if(!trace._ownFill.size()) { + trace._ownFill = tr.insert('path', ':first-child').attr('class', 'js-fill js-tozero'); + } + } else { + tr.selectAll('.js-fill.js-tozero').remove(); + trace._ownFill = null; + } + }); +} + +function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element, transitionConfig) { + var join, i; + + // Since this has been reorganized and we're executing this on individual traces, + // we need to pass it the full list of cdscatter as well as this trace's index (idx) + // since it does an internal n^2 loop over comparisons with other traces: + selectMarkers(gd, idx, plotinfo, cdscatter, cdscatterAll); + + var hasTransition = !!transitionConfig && transitionConfig.duration > 0; + + function transition(selection) { + if(hasTransition) { + return selection.transition() + .duration(transitionConfig.duration) + .delay(transitionConfig.delay) + .ease(transitionConfig.ease); + } else { + return selection; + } + } - // now make a new nexttonext for next time - nexttonext = tr.append('path').classed('js-fill', true); + var xa = plotinfo.x(), + ya = plotinfo.y(); + + var trace = cdscatter[0].trace, + line = trace.line, + tr = d3.select(element); + + // (so error bars can find them along with bars) + // error bars are at the bottom + tr.call(ErrorBars.plot, plotinfo, transitionConfig); + + if(trace.visible !== true) return; + + // BUILD LINES AND FILLS + var ownFillEl3, tonext; + var ownFillDir = trace.fill.charAt(trace.fill.length - 1); + if(ownFillDir !== 'x' && ownFillDir !== 'y') ownFillDir = ''; + + // store node for tweaking by selectPoints + cdscatter[0].node3 = tr; + + arraysToCalcdata(cdscatter); + + var prevRevpath = ''; + var prevPolygons = []; + var prevtrace = trace._prevtrace; + + if(prevtrace) { + prevRevpath = prevtrace._prevRevpath || ''; + tonext = prevtrace._nextFill; + prevPolygons = prevtrace._polygons; + } + + var thispath, + thisrevpath, + // fullpath is all paths for this curve, joined together straight + // across gaps, for filling + fullpath = '', + // revpath is fullpath reversed, for fill-to-next + revpath = '', + // functions for converting a point array to a path + pathfn, revpathbase, revpathfn; + + ownFillEl3 = trace._ownFill; + + if(subTypes.hasLines(trace) || trace.fill !== 'none') { + if(tonext) { + // This tells .style which trace to use for fill information: + tonext.datum(cdscatter); + } if(['hv', 'vh', 'hvh', 'vhv'].indexOf(line.shape) !== -1) { pathfn = Drawing.steps(line.shape); @@ -120,32 +218,38 @@ module.exports = function plot(gd, plotinfo, cdscatter) { return revpathbase(pts.reverse()); }; - var segments = linePoints(d, { + var segments = linePoints(cdscatter, { xaxis: xa, yaxis: ya, connectGaps: trace.connectgaps, baseTolerance: Math.max(line.width || 1, 3) / 4, - linear: line.shape === 'linear' + linear: line.shape === 'linear', + simplify: line.simplify }); // since we already have the pixel segments here, use them to make // polygons for hover on fill // TODO: can we skip this if hoveron!=fills? That would mean we // need to redraw when you change hoveron... - var thisPolygons = trace._polygons = new Array(segments.length), - i; - + var thisPolygons = trace._polygons = new Array(segments.length); for(i = 0; i < segments.length; i++) { trace._polygons[i] = polygonTester(segments[i]); } + var pt0, lastSegment, pt1; + if(segments.length) { - var pt0 = segments[0][0], - lastSegment = segments[segments.length - 1], - pt1 = lastSegment[lastSegment.length - 1]; + pt0 = segments[0][0]; + lastSegment = segments[segments.length - 1]; + pt1 = lastSegment[lastSegment.length - 1]; + } - for(i = 0; i < segments.length; i++) { - var pts = segments[i]; + var lineSegments = segments.filter(function(s) { + return s.length > 1; + }); + + var makeUpdate = function(isEnter) { + return function(pts) { thispath = pathfn(pts); thisrevpath = revpathfn(pts); if(!fullpath) { @@ -160,13 +264,41 @@ module.exports = function plot(gd, plotinfo, cdscatter) { fullpath += 'Z' + thispath; revpath = thisrevpath + 'Z' + revpath; } + if(subTypes.hasLines(trace) && pts.length > 1) { - tr.append('path') - .classed('js-line', true) - .style('vector-effect', 'non-scaling-stroke') - .attr('d', thispath); + var el = d3.select(this); + + // This makes the coloring work correctly: + el.datum(cdscatter); + + if(isEnter) { + transition(el.style('opacity', 0) + .attr('d', thispath) + .call(Drawing.lineGroupStyle)) + .style('opacity', 1); + } else { + transition(el).attr('d', thispath) + .call(Drawing.lineGroupStyle); + } } - } + }; + }; + + var lineJoin = tr.selectAll('.js-line').data(lineSegments); + + transition(lineJoin.exit()) + .style('opacity', 0) + .remove(); + + lineJoin.each(makeUpdate(false)); + + lineJoin.enter().append('path') + .classed('js-line', true) + .style('vector-effect', 'non-scaling-stroke') + .call(Drawing.lineGroupStyle) + .each(makeUpdate(true)); + + if(segments.length) { if(ownFillEl3) { if(pt0 && pt1) { if(ownFillDir) { @@ -179,20 +311,24 @@ module.exports = function plot(gd, plotinfo, cdscatter) { // fill to zero: full trace path, plus extension of // the endpoints to the appropriate axis - ownFillEl3.attr('d', fullpath + 'L' + pt1 + 'L' + pt0 + 'Z'); + // For the sake of animations, wrap the points around so that + // the points on the axes are the first two points. Otherwise + // animations get a little crazy if the number of points changes. + transition(ownFillEl3).attr('d', 'M' + pt1 + 'L' + pt0 + 'L' + fullpath.substr(1)); + } else { + // fill to self: just join the path to itself + transition(ownFillEl3).attr('d', fullpath + 'Z'); } - // fill to self: just join the path to itself - else ownFillEl3.attr('d', fullpath + 'Z'); } } - else if(trace.fill.substr(0, 6) === 'tonext' && fullpath && prevpath) { + else if(trace.fill.substr(0, 6) === 'tonext' && fullpath && prevRevpath) { // fill to next: full trace path, plus the previous path reversed if(trace.fill === 'tonext') { // tonext: for use by concentric shapes, like manually constructed // contours, we just add the two paths closed on themselves. // This makes strange results if one path is *not* entirely // inside the other, but then that is a strange usage. - tonext.attr('d', fullpath + 'Z' + prevpath + 'Z'); + transition(tonext).attr('d', fullpath + 'Z' + prevRevpath + 'Z'); } else { // tonextx/y: for now just connect endpoints with lines. This is @@ -200,92 +336,137 @@ module.exports = function plot(gd, plotinfo, cdscatter) { // y/x, but if they *aren't*, we should ideally do more complicated // things depending on whether the new endpoint projects onto the // existing curve or off the end of it - tonext.attr('d', fullpath + 'L' + prevpath.substr(1) + 'Z'); + transition(tonext).attr('d', fullpath + 'L' + prevRevpath.substr(1) + 'Z'); } trace._polygons = trace._polygons.concat(prevPolygons); } - prevpath = revpath; - prevPolygons = thisPolygons; + trace._prevRevpath = revpath; + trace._prevPolygons = thisPolygons; } - }); + } - // remove paths that didn't get used - scattertraces.selectAll('path:not([d])').remove(); function visFilter(d) { return d.filter(function(v) { return v.vis; }); } - scattertraces.append('g') - .attr('class', 'points') - .each(function(d) { - var trace = d[0].trace, - s = d3.select(this), - showMarkers = subTypes.hasMarkers(trace), - showText = subTypes.hasText(trace); - - if((!showMarkers && !showText) || trace.visible !== true) s.remove(); - else { - if(showMarkers) { - s.selectAll('path.point') - .data(trace.marker.maxdisplayed ? visFilter : Lib.identity) - .enter().append('path') - .classed('point', true) - .call(Drawing.translatePoints, xa, ya); - } - if(showText) { - s.selectAll('g') - .data(trace.marker.maxdisplayed ? visFilter : Lib.identity) - // each text needs to go in its own 'g' in case - // it gets converted to mathjax - .enter().append('g') - .append('text') - .call(Drawing.translatePoints, xa, ya); + function keyFunc(d) { + return d.identifier; + } + + // Returns a function if the trace is keyed, otherwise returns undefined + function getKeyFunc(trace) { + if(trace.identifier) { + return keyFunc; + } + } + + function makePoints(d) { + var join, selection; + var trace = d[0].trace, + s = d3.select(this), + showMarkers = subTypes.hasMarkers(trace), + showText = subTypes.hasText(trace); + + if((!showMarkers && !showText) || trace.visible !== true) s.remove(); + else { + if(showMarkers) { + selection = s.selectAll('path.point'); + + join = selection + .data(trace.marker.maxdisplayed ? visFilter : Lib.identity, getKeyFunc(trace)); + + join.enter().append('path') + .classed('point', true) + .call(Drawing.pointStyle, trace) + .call(Drawing.translatePoints, xa, ya, trace, transitionConfig, 1); + + join.transition() + .call(Drawing.translatePoints, xa, ya, trace, transitionConfig, 0) + .call(Drawing.pointStyle, trace); + + if(hasTransition) { + join.exit() + .call(Drawing.translatePoints, xa, ya, trace, transitionConfig, -1); + } else { + join.exit().remove(); } } - }); -}; + if(showText) { + selection = s.selectAll('g'); -function selectMarkers(gd, plotinfo, cdscatter) { + join = selection + .data(trace.marker.maxdisplayed ? visFilter : Lib.identity); + + // each text needs to go in its own 'g' in case + // it gets converted to mathjax + join.enter().append('g') + .append('text') + .call(Drawing.translatePoints, xa, ya); + + selection + .call(Drawing.translatePoints, xa, ya); + + join.exit().remove(); + } + } + } + + // NB: selectAll is evaluated on instantiation: + var pointSelection = tr.selectAll('.points'); + + // Join with new data + join = pointSelection.data([cdscatter]); + + // Transition existing, but don't defer this to an async .transition since + // there's no timing involved: + pointSelection.each(makePoints); + + join.enter().append('g') + .classed('points', true) + .each(makePoints); + + join.exit().remove(); +} + +function selectMarkers(gd, idx, plotinfo, cdscatter, cdscatterAll) { var xa = plotinfo.x(), ya = plotinfo.y(), xr = d3.extent(xa.range.map(xa.l2c)), yr = d3.extent(ya.range.map(ya.l2c)); - cdscatter.forEach(function(d, i) { - var trace = d[0].trace; - if(!subTypes.hasMarkers(trace)) return; - // if marker.maxdisplayed is used, select a maximum of - // mnum markers to show, from the set that are in the viewport - var mnum = trace.marker.maxdisplayed; - - // TODO: remove some as we get away from the viewport? - if(mnum === 0) return; - - var cd = d.filter(function(v) { - return v.x >= xr[0] && v.x <= xr[1] && v.y >= yr[0] && v.y <= yr[1]; - }), - inc = Math.ceil(cd.length / mnum), - tnum = 0; - cdscatter.forEach(function(cdj, j) { - var tracei = cdj[0].trace; - if(subTypes.hasMarkers(tracei) && - tracei.marker.maxdisplayed > 0 && j < i) { - tnum++; - } - }); + var trace = cdscatter[0].trace; + if(!subTypes.hasMarkers(trace)) return; + // if marker.maxdisplayed is used, select a maximum of + // mnum markers to show, from the set that are in the viewport + var mnum = trace.marker.maxdisplayed; + + // TODO: remove some as we get away from the viewport? + if(mnum === 0) return; + + var cd = cdscatter.filter(function(v) { + return v.x >= xr[0] && v.x <= xr[1] && v.y >= yr[0] && v.y <= yr[1]; + }), + inc = Math.ceil(cd.length / mnum), + tnum = 0; + cdscatterAll.forEach(function(cdj, j) { + var tracei = cdj[0].trace; + if(subTypes.hasMarkers(tracei) && + tracei.marker.maxdisplayed > 0 && j < idx) { + tnum++; + } + }); - // if multiple traces use maxdisplayed, stagger which markers we - // display this formula offsets successive traces by 1/3 of the - // increment, adding an extra small amount after each triplet so - // it's not quite periodic - var i0 = Math.round(tnum * inc / 3 + Math.floor(tnum / 3) * inc / 7.1); - - // for error bars: save in cd which markers to show - // so we don't have to repeat this - d.forEach(function(v) { delete v.vis; }); - cd.forEach(function(v, i) { - if(Math.round((i + i0) % inc) === 0) v.vis = true; - }); + // if multiple traces use maxdisplayed, stagger which markers we + // display this formula offsets successive traces by 1/3 of the + // increment, adding an extra small amount after each triplet so + // it's not quite periodic + var i0 = Math.round(tnum * inc / 3 + Math.floor(tnum / 3) * inc / 7.1); + + // for error bars: save in cd which markers to show + // so we don't have to repeat this + cdscatter.forEach(function(v) { delete v.vis; }); + cd.forEach(function(v, i) { + if(Math.round((i + i0) % inc) === 0) v.vis = true; }); } diff --git a/src/traces/scatter/select.js b/src/traces/scatter/select.js index fbe0cd63a6f..03f5ed8f39a 100644 --- a/src/traces/scatter/select.js +++ b/src/traces/scatter/select.js @@ -45,7 +45,8 @@ module.exports = function selectPoints(searchInfo, polygon) { curveNumber: curveNumber, pointNumber: i, x: di.x, - y: di.y + y: di.y, + identifier: di.identifier }); di.dim = 0; } diff --git a/src/traces/scatter/subtypes.js b/src/traces/scatter/subtypes.js index 56814679824..b79b420a4f3 100644 --- a/src/traces/scatter/subtypes.js +++ b/src/traces/scatter/subtypes.js @@ -9,6 +9,8 @@ 'use strict'; +var Lib = require('../../lib'); + module.exports = { hasLines: function(trace) { return trace.visible && trace.mode && @@ -26,7 +28,7 @@ module.exports = { }, isBubble: function(trace) { - return (typeof trace.marker === 'object' && - Array.isArray(trace.marker.size)); + return Lib.isPlainObject(trace.marker) && + Array.isArray(trace.marker.size); } }; diff --git a/src/traces/scatter3d/attributes.js b/src/traces/scatter3d/attributes.js index 06b1cecc708..9a898a95b58 100644 --- a/src/traces/scatter3d/attributes.js +++ b/src/traces/scatter3d/attributes.js @@ -100,6 +100,7 @@ module.exports = { line: extendFlat({}, { width: scatterLineAttrs.width, dash: scatterLineAttrs.dash, + simplify: scatterLineAttrs.simplify, showscale: { valType: 'boolean', role: 'info', diff --git a/src/traces/scattergeo/attributes.js b/src/traces/scattergeo/attributes.js index 7ee7155fc1c..fc6e153856f 100644 --- a/src/traces/scattergeo/attributes.js +++ b/src/traces/scattergeo/attributes.js @@ -59,7 +59,8 @@ module.exports = { line: { color: scatterLineAttrs.color, width: scatterLineAttrs.width, - dash: scatterLineAttrs.dash + dash: scatterLineAttrs.dash, + simplify: scatterLineAttrs.simplify }, marker: extendFlat({}, { symbol: scatterMarkerAttrs.symbol, diff --git a/src/traces/scattergl/attributes.js b/src/traces/scattergl/attributes.js index 50a123dd66e..7e9311d8379 100644 --- a/src/traces/scattergl/attributes.js +++ b/src/traces/scattergl/attributes.js @@ -48,6 +48,7 @@ module.exports = { line: { color: scatterLineAttrs.color, width: scatterLineAttrs.width, + simplify: scatterLineAttrs.simplify, dash: { valType: 'enumerated', values: Object.keys(DASHES), diff --git a/src/traces/scatterternary/attributes.js b/src/traces/scatterternary/attributes.js index fb28327b348..b782eb3a112 100644 --- a/src/traces/scatterternary/attributes.js +++ b/src/traces/scatterternary/attributes.js @@ -76,6 +76,7 @@ module.exports = { color: scatterLineAttrs.color, width: scatterLineAttrs.width, dash: scatterLineAttrs.dash, + simplify: scatterLineAttrs.simplify, shape: extendFlat({}, scatterLineAttrs.shape, {values: ['linear', 'spline']}), smoothing: scatterLineAttrs.smoothing diff --git a/tasks/util/pull_css.js b/tasks/util/pull_css.js index 1f3cb6def53..ff5fefea671 100644 --- a/tasks/util/pull_css.js +++ b/tasks/util/pull_css.js @@ -38,15 +38,9 @@ module.exports = function pullCSS(data, pathOut) { var outStr = [ '\'use strict\';', '', - 'var Plotly = require(\'../src/plotly\');', 'var rules = ' + rulesStr + ';', '', - 'for(var selector in rules) {', - ' var fullSelector = selector.replace(/^,/,\' ,\')', - ' .replace(/X/g, \'.js-plotly-plot .plotly\')', - ' .replace(/Y/g, \'.plotly-notifier\');', - ' Plotly.Lib.addStyleRule(fullSelector, rules[selector]);', - '}', + 'module.exports = rules;', '' ].join('\n'); diff --git a/test/image/README.md b/test/image/README.md index b3a4d371caa..d8641559841 100644 --- a/test/image/README.md +++ b/test/image/README.md @@ -40,8 +40,8 @@ as listed on [hub.docker.com](https://hub.docker.com/r/plotly/testbed/tags/) and ### Step 2: Run the image tests -The image testing docker container allows plotly.js developers to ([A](#a-run-image-comparison-tests) run image -comparison tests, ([B](#b-run-image-export-tests) run image export tests and ([C](#c-generate-or-update-existing-baseline-image)) generate baseline +The image testing docker container allows plotly.js developers to ([A](#a-run-image-comparison-tests)) run image +comparison tests, ([B](#b-run-image-export-tests)) run image export tests and ([C](#c-generate-or-update-existing-baseline-image)) generate baseline images. **IMPORTANT:** the image tests scripts do **not** bundle the source files before diff --git a/test/image/mocks/ternary_simple.json b/test/image/mocks/ternary_simple.json index ea1d78ff2a3..62bc4574a66 100644 --- a/test/image/mocks/ternary_simple.json +++ b/test/image/mocks/ternary_simple.json @@ -16,8 +16,7 @@ 1, 2.12345 ], - "type": "scatterternary", - "uid": "412fa8" + "type": "scatterternary" } ], "layout": { diff --git a/test/jasmine/assets/fail_test.js b/test/jasmine/assets/fail_test.js index 12b591a35f7..468a7640c59 100644 --- a/test/jasmine/assets/fail_test.js +++ b/test/jasmine/assets/fail_test.js @@ -18,5 +18,9 @@ * See ./with_setup_teardown.js for a different example. */ module.exports = function failTest(error) { - expect(error).toBeUndefined(); + if(error === undefined) { + expect(error).not.toBeUndefined(); + } else { + expect(error).toBeUndefined(); + } }; diff --git a/test/jasmine/assets/transforms/filter.js b/test/jasmine/assets/transforms/filter.js index 95215fc50cf..5dedd82a5bf 100644 --- a/test/jasmine/assets/transforms/filter.js +++ b/test/jasmine/assets/transforms/filter.js @@ -16,16 +16,17 @@ exports.name = 'filter'; exports.attributes = { operation: { valType: 'enumerated', - values: ['=', '<', '>'], + values: ['=', '<', '>', 'in'], dflt: '=' }, value: { - valType: 'number', + valType: 'any', + arrayOk: true, dflt: 0 }, filtersrc: { valType: 'enumerated', - values: ['x', 'y'], + values: ['x', 'y', 'identifier'], dflt: 'x' } }; @@ -129,6 +130,8 @@ function getFilterFunc(opts) { return function(v) { return v < value; }; case '>': return function(v) { return v > value; }; + case 'in': + return function(v) { return value.indexOf(v) !== -1; }; } } diff --git a/test/jasmine/tests/animate_test.js b/test/jasmine/tests/animate_test.js new file mode 100644 index 00000000000..e8b57719f43 --- /dev/null +++ b/test/jasmine/tests/animate_test.js @@ -0,0 +1,145 @@ +var Plotly = require('@lib/index'); +var PlotlyInternal = require('@src/plotly'); +var Lib = require('@src/lib'); + +var createGraphDiv = require('../assets/create_graph_div'); +var destroyGraphDiv = require('../assets/destroy_graph_div'); +var fail = require('../assets/fail_test'); + +var mock = { + 'data': [ + { + 'x': [0, 1, 2], + 'y': [0, 2, 8], + 'type': 'scatter' + }, + { + 'x': [0, 1, 2], + 'y': [4, 2, 3], + 'type': 'scatter' + } + ], + 'layout': { + 'title': 'Animation test', + 'showlegend': true, + 'autosize': false, + 'xaxis': { + 'range': [0, 2], + 'domain': [0, 1] + }, + 'yaxis': { + 'range': [0, 10], + 'domain': [0, 1] + } + }, + 'frames': [{ + 'name': 'base', + 'data': [ + {'y': [0, 2, 8]}, + {'y': [4, 2, 3]} + ], + 'layout': { + 'xaxis': { + 'range': [0, 2] + }, + 'yaxis': { + 'range': [0, 10] + } + } + }, { + 'name': 'frame0', + 'data': [ + {'y': [0.5, 1.5, 7.5]}, + {'y': [4.25, 2.25, 3.05]} + ], + 'baseFrame': 'base', + 'traceIndices': [0, 1], + 'layout': { } + }, { + 'name': 'frame1', + 'data': [ + {'y': [2.1, 1, 7]}, + {'y': [4.5, 2.5, 3.1]} + ], + 'baseFrame': 'base', + 'traceIndices': [0, 1], + 'layout': { } + }, { + 'name': 'frame2', + 'data': [ + {'y': [3.5, 0.5, 6]}, + {'y': [5.7, 2.7, 3.9]} + ], + 'baseFrame': 'base', + 'traceIndices': [0, 1], + 'layout': { } + }, { + 'name': 'frame3', + 'data': [ + {'y': [5.1, 0.25, 5]}, + {'y': [7, 2.9, 6]} + ], + 'baseFrame': 'base', + 'traceIndices': [0, 1], + 'layout': { + 'xaxis': { + 'range': [-1, 4] + }, + 'yaxis': { + 'range': [-5, 15] + } + } + }] +}; + +describe('Test animate API', function() { + 'use strict'; + + var gd; + + beforeEach(function(done) { + gd = createGraphDiv(); + + //var mock = require('@mocks/animation'); + var mockCopy = Lib.extendDeep({}, mock); + + spyOn(PlotlyInternal, 'transition').and.callFake(function() { + return Promise.resolve(); + }); + + Plotly.plot(gd, mockCopy.data, mockCopy.layout).then(function() { + Plotly.addFrames(gd, mockCopy.frames); + }).then(done); + }); + + afterEach(function() { + destroyGraphDiv(); + }); + + it('rejects if the frame is not found', function(done) { + Plotly.animate(gd, 'foobar').then(fail).then(done, done); + }); + + it('animates to a frame', function(done) { + Plotly.animate(gd, 'frame0').then(function() { + expect(PlotlyInternal.transition).toHaveBeenCalled(); + + var args = PlotlyInternal.transition.calls.mostRecent().args; + + // was called with gd, data, layout, traceIndices, transitionConfig: + expect(args.length).toEqual(5); + + // data has two traces: + expect(args[1].length).toEqual(2); + + // layout + expect(args[2]).toEqual({ + xaxis: {range: [0, 2]}, + yaxis: {range: [0, 10]} + }); + + // traces are [0, 1]: + expect(args[3]).toEqual([0, 1]); + }).catch(fail).then(done); + }); +}); diff --git a/test/jasmine/tests/calcdata_test.js b/test/jasmine/tests/calcdata_test.js index fb9c3049994..931b151bed0 100644 --- a/test/jasmine/tests/calcdata_test.js +++ b/test/jasmine/tests/calcdata_test.js @@ -18,15 +18,15 @@ describe('calculated data and points', function() { it('should exclude null and undefined points when false', function() { Plotly.plot(gd, [{ x: [1, 2, 3, undefined, 5], y: [1, null, 3, 4, 5]}], {}); - expect(gd.calcdata[0][1]).toEqual({ x: false, y: false}); - expect(gd.calcdata[0][3]).toEqual({ x: false, y: false}); + expect(gd.calcdata[0][1]).toEqual(jasmine.objectContaining({x: false, y: false})); + expect(gd.calcdata[0][3]).toEqual(jasmine.objectContaining({x: false, y: false})); }); it('should exclude null and undefined points as categories when false', function() { Plotly.plot(gd, [{ x: [1, 2, 3, undefined, 5], y: [1, null, 3, 4, 5] }], { xaxis: { type: 'category' }}); - expect(gd.calcdata[0][1]).toEqual({ x: false, y: false}); - expect(gd.calcdata[0][3]).toEqual({ x: false, y: false}); + expect(gd.calcdata[0][1]).toEqual(jasmine.objectContaining({x: false, y: false})); + expect(gd.calcdata[0][3]).toEqual(jasmine.objectContaining({x: false, y: false})); }); }); @@ -180,9 +180,9 @@ describe('calculated data and points', function() { }}); expect(gd.calcdata[0][0]).toEqual(jasmine.objectContaining({x: 4, y: 15})); - expect(gd.calcdata[0][1]).toEqual({ x: false, y: false}); + expect(gd.calcdata[0][1]).toEqual(jasmine.objectContaining({x: false, y: false})); expect(gd.calcdata[0][2]).toEqual(jasmine.objectContaining({x: 3, y: 12})); - expect(gd.calcdata[0][3]).toEqual({ x: false, y: false}); + expect(gd.calcdata[0][3]).toEqual(jasmine.objectContaining({x: false, y: false})); expect(gd.calcdata[0][4]).toEqual(jasmine.objectContaining({x: 2, y: 14})); }); @@ -257,7 +257,7 @@ describe('calculated data and points', function() { }}); expect(gd.calcdata[0][0]).toEqual(jasmine.objectContaining({x: 6, y: 15})); - expect(gd.calcdata[0][1]).toEqual({x: false, y: false}); + expect(gd.calcdata[0][1]).toEqual(jasmine.objectContaining({x: false, y: false})); expect(gd.calcdata[0][2]).toEqual(jasmine.objectContaining({x: 5, y: 12})); expect(gd.calcdata[0][3]).toEqual(jasmine.objectContaining({x: 0, y: 13})); expect(gd.calcdata[0][4]).toEqual(jasmine.objectContaining({x: 3, y: 14})); diff --git a/test/jasmine/tests/cartesian_test.js b/test/jasmine/tests/cartesian_test.js index 7fbe66fbef5..9bc024d6acb 100644 --- a/test/jasmine/tests/cartesian_test.js +++ b/test/jasmine/tests/cartesian_test.js @@ -7,7 +7,6 @@ var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); var mouseEvent = require('../assets/mouse_event'); - describe('zoom box element', function() { var mock = require('@mocks/14.json'); @@ -50,6 +49,104 @@ describe('zoom box element', function() { }); }); +describe('restyle', function() { + describe('scatter traces', function() { + var gd; + + beforeEach(function() { + gd = createGraphDiv(); + }); + + afterEach(destroyGraphDiv); + + it('reuses SVG fills', function(done) { + var fills, firstToZero, secondToZero, firstToNext, secondToNext; + var mock = Lib.extendDeep({}, require('@mocks/basic_area.json')); + + Plotly.plot(gd, mock.data, mock.layout).then(function() { + // Assert there are two fills: + fills = d3.selectAll('g.trace.scatter .js-fill')[0]; + + // First is tozero, second is tonext: + expect(d3.selectAll('g.trace.scatter .js-fill').size()).toEqual(2); + expect(fills[0].classList.contains('js-tozero')).toBe(true); + expect(fills[0].classList.contains('js-tonext')).toBe(false); + expect(fills[1].classList.contains('js-tozero')).toBe(false); + expect(fills[1].classList.contains('js-tonext')).toBe(true); + + firstToZero = fills[0]; + firstToNext = fills[1]; + }).then(function() { + return Plotly.restyle(gd, {visible: [false]}, [1]); + }).then(function() { + // Trace 1 hidden leaves only trace zero's tozero fill: + expect(d3.selectAll('g.trace.scatter .js-fill').size()).toEqual(1); + expect(fills[0].classList.contains('js-tozero')).toBe(true); + expect(fills[0].classList.contains('js-tonext')).toBe(false); + }).then(function() { + return Plotly.restyle(gd, {visible: [true]}, [1]); + }).then(function() { + // Reshow means two fills again AND order is preserved: + fills = d3.selectAll('g.trace.scatter .js-fill')[0]; + + // First is tozero, second is tonext: + expect(d3.selectAll('g.trace.scatter .js-fill').size()).toEqual(2); + expect(fills[0].classList.contains('js-tozero')).toBe(true); + expect(fills[0].classList.contains('js-tonext')).toBe(false); + expect(fills[1].classList.contains('js-tozero')).toBe(false); + expect(fills[1].classList.contains('js-tonext')).toBe(true); + + secondToZero = fills[0]; + secondToNext = fills[1]; + + // The identity of the first is retained: + expect(firstToZero).toBe(secondToZero); + + // The second has been recreated so is different: + expect(firstToNext).not.toBe(secondToNext); + }).then(done); + }); + + it('reuses SVG lines', function(done) { + var lines, firstLine1, secondLine1, firstLine2, secondLine2; + var mock = Lib.extendDeep({}, require('@mocks/basic_line.json')); + + Plotly.plot(gd, mock.data, mock.layout).then(function() { + lines = d3.selectAll('g.scatter.trace .js-line'); + + firstLine1 = lines[0][0]; + firstLine2 = lines[0][1]; + + // One line for each trace: + expect(lines.size()).toEqual(2); + }).then(function() { + return Plotly.restyle(gd, {visible: [false]}, [0]); + }).then(function() { + lines = d3.selectAll('g.scatter.trace .js-line'); + + // Only one line now and it's equal to the second trace's line from above: + expect(lines.size()).toEqual(1); + expect(lines[0][0]).toBe(firstLine2); + }).then(function() { + return Plotly.restyle(gd, {visible: [true]}, [0]); + }).then(function() { + lines = d3.selectAll('g.scatter.trace .js-line'); + secondLine1 = lines[0][0]; + secondLine2 = lines[0][1]; + + // Two lines once again: + expect(lines.size()).toEqual(2); + + // First line has been removed and recreated: + expect(firstLine1).not.toBe(secondLine1); + + // Second line was persisted: + expect(firstLine2).toBe(secondLine2); + }).then(done); + }); + }); +}); + describe('relayout', function() { describe('axis category attributes', function() { diff --git a/test/jasmine/tests/compute_frame_test.js b/test/jasmine/tests/compute_frame_test.js new file mode 100644 index 00000000000..e2d6b026c96 --- /dev/null +++ b/test/jasmine/tests/compute_frame_test.js @@ -0,0 +1,236 @@ +var Plotly = require('@lib/index'); +var Lib = require('@src/lib'); + +var createGraphDiv = require('../assets/create_graph_div'); +var destroyGraphDiv = require('../assets/destroy_graph_div'); +var computeFrame = require('@src/plots/plots').computeFrame; + +function clone(obj) { + return Lib.extendDeep({}, obj); +} + +describe('Test mergeFrames', function() { + 'use strict'; + + var gd, mock; + + beforeEach(function(done) { + mock = [{x: [1, 2, 3], y: [2, 1, 3]}, {x: [1, 2, 3], y: [6, 4, 5]}]; + gd = createGraphDiv(); + Plotly.plot(gd, mock).then(done); + }); + + afterEach(destroyGraphDiv); + + describe('computing a single frame', function() { + var frame1, input; + + beforeEach(function(done) { + frame1 = { + name: 'frame1', + data: [{ + x: [1, 2, 3], + 'marker.size': 8, + marker: {color: 'red'} + }] + }; + + input = clone(frame1); + Plotly.addFrames(gd, [input]).then(done); + }); + + it('returns false if the frame does not exist', function() { + expect(computeFrame(gd, 'frame8')).toBe(false); + }); + + it('returns a new object', function() { + var result = computeFrame(gd, 'frame1'); + expect(result).not.toBe(input); + }); + + it('copies objects', function() { + var result = computeFrame(gd, 'frame1'); + expect(result.data).not.toBe(input.data); + expect(result.data[0].marker).not.toBe(input.data[0].marker); + }); + + it('does NOT copy arrays', function() { + var result = computeFrame(gd, 'frame1'); + expect(result.data[0].x).toBe(input.data[0].x); + }); + + it('computes a single frame', function() { + var computed = computeFrame(gd, 'frame1'); + var expected = {data: [{x: [1, 2, 3], marker: {size: 8, color: 'red'}}], traceIndices: [0]}; + expect(computed).toEqual(expected); + }); + + it('leaves the frame unaffected', function() { + computeFrame(gd, 'frame1'); + expect(gd._frameData._frameHash.frame1).toEqual(frame1); + }); + }); + + describe('circularly defined frames', function() { + var frames, results; + + beforeEach(function(done) { + frames = [ + {name: 'frame0', baseFrame: 'frame1', data: [{'marker.size': 0}]}, + {name: 'frame1', baseFrame: 'frame2', data: [{'marker.size': 1}]}, + {name: 'frame2', baseFrame: 'frame0', data: [{'marker.size': 2}]} + ]; + + results = [ + {traceIndices: [0], data: [{marker: {size: 0}}]}, + {traceIndices: [0], data: [{marker: {size: 1}}]}, + {traceIndices: [0], data: [{marker: {size: 2}}]} + ]; + + Plotly.addFrames(gd, frames).then(done); + }); + + function doTest(i) { + it('avoid infinite recursion (starting point = ' + i + ')', function() { + var result = computeFrame(gd, 'frame' + i); + expect(result).toEqual(results[i]); + }); + } + + for(var ii = 0; ii < 3; ii++) { + doTest(ii); + } + }); + + describe('computing trace data', function() { + var frames; + + beforeEach(function() { + frames = [{ + name: 'frame0', + data: [{'marker.size': 0}], + traceIndices: [2] + }, { + name: 'frame1', + data: [{'marker.size': 1}], + traceIndices: [8] + }, { + name: 'frame2', + data: [{'marker.size': 2}], + traceIndices: [2] + }, { + name: 'frame3', + data: [{'marker.size': 3}, {'marker.size': 4}], + traceIndices: [2, 8] + }, { + name: 'frame4', + data: [ + {'marker.size': 5}, + {'marker.size': 6}, + {'marker.size': 7} + ] + }]; + }); + + it('merges orthogonal traces', function() { + frames[0].baseFrame = frames[1].name; + + // This technically returns a promise, but it's not actually asynchronous so + // that we'll just keep this synchronous: + Plotly.addFrames(gd, frames.map(clone)); + + expect(computeFrame(gd, 'frame0')).toEqual({ + traceIndices: [8, 2], + data: [ + {marker: {size: 1}}, + {marker: {size: 0}} + ] + }); + + // Verify that the frames are untouched (by value, at least, but they should + // also be unmodified by identity too) by the computation: + expect(gd._frameData._frames).toEqual(frames); + }); + + it('merges overlapping traces', function() { + frames[0].baseFrame = frames[2].name; + + Plotly.addFrames(gd, frames.map(clone)); + + expect(computeFrame(gd, 'frame0')).toEqual({ + traceIndices: [2], + data: [{marker: {size: 0}}] + }); + + expect(gd._frameData._frames).toEqual(frames); + }); + + it('merges partially overlapping traces', function() { + frames[0].baseFrame = frames[1].name; + frames[1].baseFrame = frames[2].name; + frames[2].baseFrame = frames[3].name; + + Plotly.addFrames(gd, frames.map(clone)); + + expect(computeFrame(gd, 'frame0')).toEqual({ + traceIndices: [2, 8], + data: [ + {marker: {size: 0}}, + {marker: {size: 1}} + ] + }); + + expect(gd._frameData._frames).toEqual(frames); + }); + + it('assumes serial order without traceIndices specified', function() { + frames[4].baseFrame = frames[3].name; + + Plotly.addFrames(gd, frames.map(clone)); + + expect(computeFrame(gd, 'frame4')).toEqual({ + traceIndices: [2, 8, 0, 1], + data: [ + {marker: {size: 7}}, + {marker: {size: 4}}, + {marker: {size: 5}}, + {marker: {size: 6}} + ] + }); + + expect(gd._frameData._frames).toEqual(frames); + }); + }); + + describe('computing trace layout', function() { + var frames, frameCopies; + + beforeEach(function(done) { + frames = [{ + name: 'frame0', + layout: {'margin.l': 40} + }, { + name: 'frame1', + layout: {'margin.l': 80} + }]; + + frameCopies = frames.map(clone); + + Plotly.addFrames(gd, frames).then(done); + }); + + it('merges layouts', function() { + frames[0].baseFrame = frames[1].name; + var result = computeFrame(gd, 'frame0'); + + expect(result).toEqual({ + layout: {margin: {l: 40}} + }); + }); + + it('leaves the frame unaffected', function() { + computeFrame(gd, 'frame0'); + expect(gd._frameData._frames).toEqual(frameCopies); + }); + }); +}); diff --git a/test/jasmine/tests/dragelement_test.js b/test/jasmine/tests/dragelement_test.js index 924f7f3bcaf..ad6abd29eb1 100644 --- a/test/jasmine/tests/dragelement_test.js +++ b/test/jasmine/tests/dragelement_test.js @@ -15,6 +15,7 @@ describe('dragElement', function() { this.element = document.createElement('div'); this.gd.className = 'js-plotly-plot'; + this.gd._document = document; this.gd._fullLayout = { _hoverlayer: d3.select(this.hoverlayer) }; diff --git a/test/jasmine/tests/extend_test.js b/test/jasmine/tests/extend_test.js index 60737b5b1fd..0fc54661a5c 100644 --- a/test/jasmine/tests/extend_test.js +++ b/test/jasmine/tests/extend_test.js @@ -2,6 +2,7 @@ var extendModule = require('@src/lib/extend.js'); var extendFlat = extendModule.extendFlat; var extendDeep = extendModule.extendDeep; var extendDeepAll = extendModule.extendDeepAll; +var extendDeepNoArrays = extendModule.extendDeepNoArrays; var str = 'me a test', integer = 10, @@ -452,3 +453,23 @@ describe('extendDeepAll', function() { expect(ori.arr[2]).toBe(undefined); }); }); + +describe('extendDeepNoArrays', function() { + 'use strict'; + + it('does not copy arrays', function() { + var src = {foo: {bar: [1, 2, 3], baz: [5, 4, 3]}}; + var tar = {foo: {bar: [4, 5, 6], bop: [8, 2, 1]}}; + var ext = extendDeepNoArrays(tar, src); + + expect(ext).not.toBe(src); + expect(ext).toBe(tar); + + expect(ext.foo).not.toBe(src.foo); + expect(ext.foo).toBe(tar.foo); + + expect(ext.foo.bar).toBe(src.foo.bar); + expect(ext.foo.baz).toBe(src.foo.baz); + expect(ext.foo.bop).toBe(tar.foo.bop); + }); +}); diff --git a/test/jasmine/tests/frame_api_test.js b/test/jasmine/tests/frame_api_test.js new file mode 100644 index 00000000000..b105d63f1fb --- /dev/null +++ b/test/jasmine/tests/frame_api_test.js @@ -0,0 +1,207 @@ +var Plotly = require('@lib/index'); + +var createGraphDiv = require('../assets/create_graph_div'); +var destroyGraphDiv = require('../assets/destroy_graph_div'); +var fail = require('../assets/fail_test'); + +describe('Test frame api', function() { + 'use strict'; + + var gd, mock, f, h; + + beforeEach(function(done) { + mock = [{x: [1, 2, 3], y: [2, 1, 3]}, {x: [1, 2, 3], y: [6, 4, 5]}]; + gd = createGraphDiv(); + Plotly.plot(gd, mock).then(function() { + f = gd._frameData._frames; + h = gd._frameData._frameHash; + }).then(function() { + Plotly.setPlotConfig({ queueLength: 10 }); + }).then(done); + }); + + afterEach(function() { + destroyGraphDiv(); + Plotly.setPlotConfig({queueLength: 0}); + }); + + describe('gd initialization', function() { + it('creates an empty list for frames', function() { + expect(gd._frameData._frames).toEqual([]); + }); + + it('creates an empty lookup table for frames', function() { + expect(gd._frameData._counter).toEqual(0); + }); + }); + + describe('#addFrames', function() { + it('names an unnamed frame', function(done) { + Plotly.addFrames(gd, [{}]).then(function() { + expect(Object.keys(h)).toEqual(['frame 0']); + }).catch(fail).then(done); + }); + + it('creates multiple unnamed frames at the same time', function(done) { + Plotly.addFrames(gd, [{}, {}]).then(function() { + expect(f).toEqual([{name: 'frame 0'}, {name: 'frame 1'}]); + }).catch(fail).then(done); + }); + + it('creates multiple unnamed frames in series', function(done) { + Plotly.addFrames(gd, [{}]).then( + Plotly.addFrames(gd, [{}]) + ).then(function() { + expect(f).toEqual([{name: 'frame 0'}, {name: 'frame 1'}]); + }).catch(fail).then(done); + }); + + it('avoids name collisions', function(done) { + Plotly.addFrames(gd, [{name: 'frame 0'}, {name: 'frame 2'}]).then(function() { + expect(f).toEqual([{name: 'frame 0'}, {name: 'frame 2'}]); + + return Plotly.addFrames(gd, [{}, {name: 'foobar'}, {}]); + }).then(function() { + expect(f).toEqual([{name: 'frame 0'}, {name: 'frame 2'}, {name: 'frame 1'}, {name: 'foobar'}, {name: 'frame 3'}]); + }).catch(fail).then(done); + }); + + it('inserts frames at specific indices', function(done) { + var i; + var frames = []; + for(i = 0; i < 10; i++) { + frames.push({name: 'frame' + i}); + } + + function validate() { + for(i = 0; i < f.length; i++) { + expect(f[i].name).toEqual('frame' + i); + } + } + + Plotly.addFrames(gd, frames).then(validate).then(function() { + return Plotly.addFrames(gd, [{name: 'frame5', x: [1]}, {name: 'frame7', x: [2]}, {name: 'frame10', x: [3]}], [5, 7, undefined]); + }).then(function() { + expect(f[5]).toEqual({name: 'frame5', x: [1]}); + expect(f[7]).toEqual({name: 'frame7', x: [2]}); + expect(f[10]).toEqual({name: 'frame10', x: [3]}); + + return Plotly.Queue.undo(gd); + }).then(validate).catch(fail).then(done); + }); + + it('inserts frames at specific indices (reversed)', function(done) { + var i; + var frames = []; + for(i = 0; i < 10; i++) { + frames.push({name: 'frame' + i}); + } + + function validate() { + for(i = 0; i < f.length; i++) { + expect(f[i].name).toEqual('frame' + i); + } + } + + Plotly.addFrames(gd, frames).then(validate).then(function() { + return Plotly.addFrames(gd, [{name: 'frame10', x: [3]}, {name: 'frame7', x: [2]}, {name: 'frame5', x: [1]}], [undefined, 7, 5]); + }).then(function() { + expect(f[5]).toEqual({name: 'frame5', x: [1]}); + expect(f[7]).toEqual({name: 'frame7', x: [2]}); + expect(f[10]).toEqual({name: 'frame10', x: [3]}); + + return Plotly.Queue.undo(gd); + }).then(validate).catch(fail).then(done); + }); + + it('implements undo/redo', function(done) { + function validate() { + expect(f).toEqual([{name: 'frame 0'}, {name: 'frame 1'}]); + expect(h).toEqual({'frame 0': {name: 'frame 0'}, 'frame 1': {name: 'frame 1'}}); + } + + Plotly.addFrames(gd, [{name: 'frame 0'}, {name: 'frame 1'}]).then(validate).then(function() { + return Plotly.Queue.undo(gd); + }).then(function() { + expect(f).toEqual([]); + expect(h).toEqual({}); + + return Plotly.Queue.redo(gd); + }).then(validate).catch(fail).then(done); + }); + + it('overwrites frames', function(done) { + // The whole shebang. This hits insertion + replacements + deletion + undo + redo: + Plotly.addFrames(gd, [{name: 'test1', x: 'y'}, {name: 'test2'}]).then(function() { + expect(f).toEqual([{name: 'test1', x: 'y'}, {name: 'test2'}]); + expect(Object.keys(h)).toEqual(['test1', 'test2']); + + return Plotly.addFrames(gd, [{name: 'test1'}, {name: 'test3'}]); + }).then(function() { + expect(f).toEqual([{name: 'test1'}, {name: 'test2'}, {name: 'test3'}]); + expect(Object.keys(h)).toEqual(['test1', 'test2', 'test3']); + + return Plotly.Queue.undo(gd); + }).then(function() { + expect(f).toEqual([{name: 'test1', x: 'y'}, {name: 'test2'}]); + expect(Object.keys(h)).toEqual(['test1', 'test2']); + + return Plotly.Queue.redo(gd); + }).then(function() { + expect(f).toEqual([{name: 'test1'}, {name: 'test2'}, {name: 'test3'}]); + expect(Object.keys(h)).toEqual(['test1', 'test2', 'test3']); + }).catch(fail).then(done); + }); + }); + + describe('#deleteFrames', function() { + it('deletes a frame', function(done) { + Plotly.addFrames(gd, [{name: 'frame1'}]).then(function() { + expect(f).toEqual([{name: 'frame1'}]); + expect(Object.keys(h)).toEqual(['frame1']); + + return Plotly.deleteFrames(gd, [0]); + }).then(function() { + expect(f).toEqual([]); + expect(Object.keys(h)).toEqual([]); + + return Plotly.Queue.undo(gd); + }).then(function() { + expect(f).toEqual([{name: 'frame1'}]); + + return Plotly.Queue.redo(gd); + }).then(function() { + expect(f).toEqual([]); + expect(Object.keys(h)).toEqual([]); + }).catch(fail).then(done); + }); + + it('deletes multiple frames', function(done) { + var i; + var frames = []; + for(i = 0; i < 10; i++) { + frames.push({name: 'frame' + i}); + } + + function validate() { + var expected = ['frame0', 'frame1', 'frame3', 'frame5', 'frame7', 'frame9']; + expect(f.length).toEqual(expected.length); + for(i = 0; i < expected.length; i++) { + expect(f[i].name).toEqual(expected[i]); + } + } + + Plotly.addFrames(gd, frames).then(function() { + return Plotly.deleteFrames(gd, [2, 8, 4, 6]); + }).then(validate).then(function() { + return Plotly.Queue.undo(gd); + }).then(function() { + for(i = 0; i < 10; i++) { + expect(f[i]).toEqual({name: 'frame' + i}); + } + + return Plotly.Queue.redo(gd); + }).then(validate).catch(fail).then(done); + }); + }); +}); diff --git a/test/jasmine/tests/lib_test.js b/test/jasmine/tests/lib_test.js index b25821f9fd0..da5af8018c9 100644 --- a/test/jasmine/tests/lib_test.js +++ b/test/jasmine/tests/lib_test.js @@ -476,6 +476,69 @@ describe('Test lib.js:', function() { }); }); + describe('expandObjectPaths', function() { + it('returns the original object', function() { + var x = {}; + expect(Lib.expandObjectPaths(x)).toBe(x); + }); + + it('unpacks top-level paths', function() { + var input = {'marker.color': 'red', 'marker.size': [1, 2, 3]}; + var expected = {marker: {color: 'red', size: [1, 2, 3]}}; + expect(Lib.expandObjectPaths(input)).toEqual(expected); + }); + + it('unpacks recursively', function() { + var input = {'marker.color': {'red.certainty': 'definitely'}}; + var expected = {marker: {color: {red: {certainty: 'definitely'}}}}; + expect(Lib.expandObjectPaths(input)).toEqual(expected); + }); + + it('unpacks deep paths', function() { + var input = {'foo.bar.baz': 'red'}; + var expected = {foo: {bar: {baz: 'red'}}}; + expect(Lib.expandObjectPaths(input)).toEqual(expected); + }); + + it('unpacks non-top-level deep paths', function() { + var input = {color: {'foo.bar.baz': 'red'}}; + var expected = {color: {foo: {bar: {baz: 'red'}}}}; + expect(Lib.expandObjectPaths(input)).toEqual(expected); + }); + + it('merges dotted properties into objects', function() { + var input = {marker: {color: 'red'}, 'marker.size': 8}; + var expected = {marker: {color: 'red', size: 8}}; + expect(Lib.expandObjectPaths(input)).toEqual(expected); + }); + + it('merges objects into dotted properties', function() { + var input = {'marker.size': 8, marker: {color: 'red'}}; + var expected = {marker: {color: 'red', size: 8}}; + expect(Lib.expandObjectPaths(input)).toEqual(expected); + }); + + it('retains the identity of nested objects', function() { + var input = {marker: {size: 8}}; + var origNested = input.marker; + var expanded = Lib.expandObjectPaths(input); + var newNested = expanded.marker; + + expect(input).toBe(expanded); + expect(origNested).toBe(newNested); + }); + + it('retains the identity of nested arrays', function() { + var input = {'marker.size': [1, 2, 3]}; + var origArray = input['marker.size']; + var expanded = Lib.expandObjectPaths(input); + var newArray = expanded.marker.size; + + expect(input).toBe(expanded); + expect(origArray).toBe(newArray); + }); + }); + describe('coerce', function() { var coerce = Lib.coerce, out; diff --git a/test/jasmine/tests/mapbox_test.js b/test/jasmine/tests/mapbox_test.js index f8acf99dd69..1adad1f9f10 100644 --- a/test/jasmine/tests/mapbox_test.js +++ b/test/jasmine/tests/mapbox_test.js @@ -180,6 +180,8 @@ describe('mapbox credentials', function() { }); it('should throw error if token is invalid', function(done) { + var cnt = 0; + Plotly.plot(gd, [{ type: 'scattermapbox', lon: [10, 20, 30], @@ -187,11 +189,17 @@ describe('mapbox credentials', function() { }], {}, { mapboxAccessToken: dummyToken }).catch(function(err) { + cnt++; expect(err).toEqual(new Error(constants.mapOnErrorMsg)); - }).then(done); + }).then(function() { + expect(cnt).toEqual(1); + done(); + }); }); it('should use access token in mapbox layout options if present', function(done) { + var cnt = 0; + Plotly.plot(gd, [{ type: 'scattermapbox', lon: [10, 20, 30], @@ -202,7 +210,10 @@ describe('mapbox credentials', function() { } }, { mapboxAccessToken: dummyToken + }).catch(function() { + cnt++; }).then(function() { + expect(cnt).toEqual(0); expect(gd._fullLayout.mapbox.accesstoken).toEqual(MAPBOX_ACCESS_TOKEN); done(); }); @@ -493,21 +504,19 @@ describe('mapbox plots', function() { }); it('should be able to update the access token', function(done) { - var promise = Plotly.relayout(gd, 'mapbox.accesstoken', 'wont-work'); - - promise.catch(function(err) { + Plotly.relayout(gd, 'mapbox.accesstoken', 'wont-work').catch(function(err) { expect(gd._fullLayout.mapbox.accesstoken).toEqual('wont-work'); expect(err).toEqual(new Error(constants.mapOnErrorMsg)); - }); + expect(gd._promises.length).toEqual(1); - promise.then(function() { return Plotly.relayout(gd, 'mapbox.accesstoken', MAPBOX_ACCESS_TOKEN); }).then(function() { expect(gd._fullLayout.mapbox.accesstoken).toEqual(MAPBOX_ACCESS_TOKEN); - }).then(done); + expect(gd._promises.length).toEqual(0); + done(); + }); }); - it('should be able to update traces', function(done) { function assertDataPts(lengths) { var lines = getGeoJsonData(gd, 'lines'), diff --git a/test/jasmine/tests/plot_css_test.js b/test/jasmine/tests/plot_css_test.js new file mode 100644 index 00000000000..830a606c39f --- /dev/null +++ b/test/jasmine/tests/plot_css_test.js @@ -0,0 +1,152 @@ +var Plotly = require('@lib/index'); + +var createGraphDiv = require('../assets/create_graph_div'); +var destroyGraphDiv = require('../assets/destroy_graph_div'); + +describe('css injection', function() { + var plotcss_utils = require('@src/lib/plotcss_utils'); + var plotcss = require('@build/plotcss'); + + // create a graph div in a child window + function createGraphDivInChildWindow() { + var childWindow = window.open('about:blank', 'popoutWindow', ''); + + var gd = childWindow.document.createElement('div'); + gd.id = 'graph'; + childWindow.document.body.appendChild(gd); + + // force the graph to be at position 0,0 no matter what + gd.style.position = 'fixed'; + gd.style.left = 0; + gd.style.top = 0; + + return gd; + } + + // the most basic of basic plots + function plot(target) { + Plotly.plot(target, [{ + x: [1, 2, 3, 4, 5], + y: [1, 2, 4, 8, 16] + }], { + margin: { + t: 0 + } + }); + } + + // deletes all rules defined in plotcss + function deletePlotCSSRules(sourceDocument) { + for(var selector in plotcss) { + var fullSelector = plotcss_utils.buildFullSelector(selector); + + for(var i = 0; i < sourceDocument.styleSheets.length; i++) { + var styleSheet = sourceDocument.styleSheets[i]; + var selectors = []; + + for(var j = 0; j < styleSheet.cssRules.length; j++) { + var cssRule = styleSheet.cssRules[j]; + + selectors.push(cssRule.selectorText); + } + + var selectorIndex = selectors.indexOf(fullSelector); + + if(selectorIndex !== -1) { + styleSheet.deleteRule(selectorIndex); + break; + } + } + } + } + + it('inserts styles on initial plot', function() { + deletePlotCSSRules(document); // clear the rules + + // fix scope errors + var selector = null; + var fullSelector = null; + + // make sure the rules are cleared + var allSelectors = plotcss_utils.getAllRuleSelectors(document); + + for(selector in plotcss) { + fullSelector = plotcss_utils.buildFullSelector(selector); + + expect(allSelectors.indexOf(fullSelector)).toEqual(-1); + } + + // plot + var gd = createGraphDiv(); + plot(gd); + + // check for styles + allSelectors = plotcss_utils.getAllRuleSelectors(document); + + for(selector in plotcss) { + fullSelector = plotcss_utils.buildFullSelector(selector); + + expect(allSelectors.indexOf(fullSelector)).not.toEqual(-1); + } + + // clean up + destroyGraphDiv(); + }); + + it('inserts styles in a child window document', function() { + var gd = createGraphDivInChildWindow(); + var childWindow = gd.ownerDocument.defaultView; + + // plot + plot(gd); + + // check for styles + var allSelectors = plotcss_utils.getAllRuleSelectors(gd.ownerDocument); + + for(var selector in plotcss) { + var fullSelector = plotcss_utils.buildFullSelector(selector); + + expect(allSelectors.indexOf(fullSelector)).not.toEqual(-1); + } + + // clean up + childWindow.close(); + }); + + it('does not insert duplicate styles', function() { + deletePlotCSSRules(document); // clear the rules + + // fix scope errors + var selector = null; + var fullSelector = null; + + // make sure the rules are cleared + var allSelectors = plotcss_utils.getAllRuleSelectors(document); + + for(selector in plotcss) { + fullSelector = plotcss_utils.buildFullSelector(selector); + + expect(allSelectors.indexOf(fullSelector)).toEqual(-1); + } + + // plot + var gd = createGraphDiv(); + plot(gd); + plot(gd); // plot again so injectStyles gets called again + + // check for styles + allSelectors = plotcss_utils.getAllRuleSelectors(document); + + for(selector in plotcss) { + fullSelector = plotcss_utils.buildFullSelector(selector); + + var firstIndex = allSelectors.indexOf(fullSelector); + + // there should be no occurences after the initial one + expect(allSelectors.indexOf(fullSelector, firstIndex + 1)).toEqual(-1); + } + + // clean up + destroyGraphDiv(); + }); +}); diff --git a/test/jasmine/tests/plots_test.js b/test/jasmine/tests/plots_test.js index aa9f52d228f..cd410a58066 100644 --- a/test/jasmine/tests/plots_test.js +++ b/test/jasmine/tests/plots_test.js @@ -25,7 +25,7 @@ describe('Test Plots', function() { xaxis: { c2p: function() {} }, yaxis: { _m: 20 }, scene: { _scene: {} }, - annotations: [{ _min: 10, }, { _max: 20 }], + annotations: [{ _min: 10 }, { _max: 20 }], someFunc: function() {} }; diff --git a/test/jasmine/tests/range_selector_test.js b/test/jasmine/tests/range_selector_test.js index 4d56997b005..c48a3e36f0f 100644 --- a/test/jasmine/tests/range_selector_test.js +++ b/test/jasmine/tests/range_selector_test.js @@ -12,474 +12,510 @@ var getRectCenter = require('../assets/get_rect_center'); var mouseEvent = require('../assets/mouse_event'); -describe('[range selector suite]', function() { +describe('range selector defaults:', function() { 'use strict'; - describe('defaults:', function() { - var supplyLayoutDefaults = RangeSelector.supplyLayoutDefaults; + var supplyLayoutDefaults = RangeSelector.supplyLayoutDefaults; - function supply(containerIn, containerOut) { - containerOut.domain = [0, 1]; + function supply(containerIn, containerOut) { + containerOut.domain = [0, 1]; - var layout = { - yaxis: { domain: [0, 1] } - }; + var layout = { + yaxis: { domain: [0, 1] } + }; - var counterAxes = ['yaxis']; + var counterAxes = ['yaxis']; - supplyLayoutDefaults(containerIn, containerOut, layout, counterAxes); - } + supplyLayoutDefaults(containerIn, containerOut, layout, counterAxes); + } - it('should set \'visible\' to false when no buttons are present', function() { - var containerIn = {}; - var containerOut = {}; + it('should set \'visible\' to false when no buttons are present', function() { + var containerIn = {}; + var containerOut = {}; - supply(containerIn, containerOut); + supply(containerIn, containerOut); - expect(containerOut.rangeselector) - .toEqual({ - visible: false, - buttons: [] - }); - }); + expect(containerOut.rangeselector) + .toEqual({ + visible: false, + buttons: [] + }); + }); - it('should coerce an empty button object', function() { - var containerIn = { - rangeselector: { - buttons: [{}] - } - }; - var containerOut = {}; - - supply(containerIn, containerOut); - - expect(containerIn.rangeselector.buttons).toEqual([{}]); - expect(containerOut.rangeselector.buttons).toEqual([{ - step: 'month', - stepmode: 'backward', - count: 1 - }]); - }); + it('should coerce an empty button object', function() { + var containerIn = { + rangeselector: { + buttons: [{}] + } + }; + var containerOut = {}; - it('should coerce all buttons present', function() { - var containerIn = { - rangeselector: { - buttons: [{ - step: 'year', - count: 10 - }, { - count: 6 - }] - } - }; - var containerOut = {}; - - supply(containerIn, containerOut, {}, []); - - expect(containerOut.rangeselector.visible).toBe(true); - expect(containerOut.rangeselector.buttons).toEqual([ - { step: 'year', stepmode: 'backward', count: 10 }, - { step: 'month', stepmode: 'backward', count: 6 } - ]); - }); + supply(containerIn, containerOut); - it('should not coerce \'stepmode\' and \'count\', for \'step\' all buttons', function() { - var containerIn = { - rangeselector: { - buttons: [{ - step: 'all', - label: 'full range' - }] - } - }; - var containerOut = {}; - - supply(containerIn, containerOut, {}, []); - - expect(containerOut.rangeselector.buttons).toEqual([{ - step: 'all', - label: 'full range' - }]); - }); + expect(containerIn.rangeselector.buttons).toEqual([{}]); + expect(containerOut.rangeselector.buttons).toEqual([{ + step: 'month', + stepmode: 'backward', + count: 1 + }]); + }); - it('should use axis and counter axis to determine \'x\' and \'y\' defaults (case 1 y)', function() { - var containerIn = { - rangeselector: { buttons: [{}] } - }; - var containerOut = { - _id: 'x', - domain: [0, 0.5] - }; - var layout = { - xaxis: containerIn, - yaxis: { - anchor: 'x', - domain: [0, 0.45] - } - }; - var counterAxes = ['yaxis']; - - supplyLayoutDefaults(containerIn, containerOut, layout, counterAxes); - - expect(containerOut.rangeselector.x).toEqual(0); - expect(containerOut.rangeselector.y).toBeCloseTo(0.47); - }); + it('should skip over non-object buttons', function() { + var containerIn = { + rangeselector: { + buttons: [{ + label: 'button 0' + }, null, { + label: 'button 2' + }, 'remove', { + label: 'button 4' + }] + } + }; + var containerOut = {}; - it('should use axis and counter axis to determine \'x\' and \'y\' defaults (case multi y)', function() { - var containerIn = { - rangeselector: { buttons: [{}] } - }; - var containerOut = { - _id: 'x', - domain: [0.5, 1] - }; - var layout = { - xaxis: containerIn, - yaxis: { - anchor: 'x', - domain: [0, 0.25] - }, - yaxis2: { - anchor: 'x', - domain: [0.3, 0.55] - }, - yaxis3: { - anchor: 'x', - domain: [0.6, 0.85] - } - }; - var counterAxes = ['yaxis', 'yaxis2', 'yaxis3']; - - supplyLayoutDefaults(containerIn, containerOut, layout, counterAxes); - - expect(containerOut.rangeselector.x).toEqual(0.5); - expect(containerOut.rangeselector.y).toBeCloseTo(0.87); - }); + supply(containerIn, containerOut); + + expect(containerIn.rangeselector.buttons.length).toEqual(5); + expect(containerOut.rangeselector.buttons.length).toEqual(3); }); - describe('getUpdateObject:', function() { - var axisLayout = { - _name: 'xaxis', - range: [ - (new Date(1948, 0, 1)).getTime(), - (new Date(2015, 10, 30)).getTime() - ] + it('should coerce all buttons present', function() { + var containerIn = { + rangeselector: { + buttons: [{ + step: 'year', + count: 10 + }, { + count: 6 + }] + } }; + var containerOut = {}; - function assertRanges(update, range0, range1) { - expect(update['xaxis.range[0]']).toEqual(range0.getTime()); - expect(update['xaxis.range[1]']).toEqual(range1.getTime()); - } + supply(containerIn, containerOut, {}, []); - it('should return update object (1 month backward case)', function() { - var buttonLayout = { - step: 'month', - stepmode: 'backward', - count: 1 - }; + expect(containerOut.rangeselector.visible).toBe(true); + expect(containerOut.rangeselector.buttons).toEqual([ + { step: 'year', stepmode: 'backward', count: 10 }, + { step: 'month', stepmode: 'backward', count: 6 } + ]); + }); - var update = getUpdateObject(axisLayout, buttonLayout); + it('should not coerce \'stepmode\' and \'count\', for \'step\' all buttons', function() { + var containerIn = { + rangeselector: { + buttons: [{ + step: 'all', + label: 'full range' + }] + } + }; + var containerOut = {}; - assertRanges(update, new Date(2015, 9, 30), new Date(2015, 10, 30)); - }); + supply(containerIn, containerOut, {}, []); - it('should return update object (3 months backward case)', function() { - var buttonLayout = { - step: 'month', - stepmode: 'backward', - count: 3 - }; + expect(containerOut.rangeselector.buttons).toEqual([{ + step: 'all', + label: 'full range' + }]); + }); - var update = getUpdateObject(axisLayout, buttonLayout); + it('should use axis and counter axis to determine \'x\' and \'y\' defaults (case 1 y)', function() { + var containerIn = { + rangeselector: { buttons: [{}] } + }; + var containerOut = { + _id: 'x', + domain: [0, 0.5] + }; + var layout = { + xaxis: containerIn, + yaxis: { + anchor: 'x', + domain: [0, 0.45] + } + }; + var counterAxes = ['yaxis']; - assertRanges(update, new Date(2015, 7, 30), new Date(2015, 10, 30)); - }); + supplyLayoutDefaults(containerIn, containerOut, layout, counterAxes); - it('should return update object (6 months backward case)', function() { - var buttonLayout = { - step: 'month', - stepmode: 'backward', - count: 6 - }; + expect(containerOut.rangeselector.x).toEqual(0); + expect(containerOut.rangeselector.y).toBeCloseTo(0.47); + }); - var update = getUpdateObject(axisLayout, buttonLayout); + it('should use axis and counter axis to determine \'x\' and \'y\' defaults (case multi y)', function() { + var containerIn = { + rangeselector: { buttons: [{}] } + }; + var containerOut = { + _id: 'x', + domain: [0.5, 1] + }; + var layout = { + xaxis: containerIn, + yaxis: { + anchor: 'x', + domain: [0, 0.25] + }, + yaxis2: { + anchor: 'x', + domain: [0.3, 0.55] + }, + yaxis3: { + anchor: 'x', + domain: [0.6, 0.85] + } + }; + var counterAxes = ['yaxis', 'yaxis2', 'yaxis3']; - assertRanges(update, new Date(2015, 4, 30), new Date(2015, 10, 30)); - }); + supplyLayoutDefaults(containerIn, containerOut, layout, counterAxes); - it('should return update object (5 months to-date case)', function() { - var buttonLayout = { - step: 'month', - stepmode: 'todate', - count: 5 - }; + expect(containerOut.rangeselector.x).toEqual(0.5); + expect(containerOut.rangeselector.y).toBeCloseTo(0.87); + }); +}); - var update = getUpdateObject(axisLayout, buttonLayout); +describe('range selector getUpdateObject:', function() { + 'use strict'; - assertRanges(update, new Date(2015, 6, 1), new Date(2015, 10, 30)); - }); + var axisLayout = { + _name: 'xaxis', + range: [ + (new Date(1948, 0, 1)).getTime(), + (new Date(2015, 10, 30)).getTime() + ] + }; + + function assertRanges(update, range0, range1) { + expect(update['xaxis.range[0]']).toEqual(range0.getTime()); + expect(update['xaxis.range[1]']).toEqual(range1.getTime()); + } + + it('should return update object (1 month backward case)', function() { + var buttonLayout = { + step: 'month', + stepmode: 'backward', + count: 1 + }; - it('should return update object (1 year to-date case)', function() { - var buttonLayout = { - step: 'year', - stepmode: 'todate', - count: 1 - }; + var update = getUpdateObject(axisLayout, buttonLayout); - var update = getUpdateObject(axisLayout, buttonLayout); + assertRanges(update, new Date(2015, 9, 30), new Date(2015, 10, 30)); + }); - assertRanges(update, new Date(2015, 0, 1), new Date(2015, 10, 30)); - }); + it('should return update object (3 months backward case)', function() { + var buttonLayout = { + step: 'month', + stepmode: 'backward', + count: 3 + }; - it('should return update object (10 year to-date case)', function() { - var buttonLayout = { - step: 'year', - stepmode: 'todate', - count: 10 - }; + var update = getUpdateObject(axisLayout, buttonLayout); - var update = getUpdateObject(axisLayout, buttonLayout); + assertRanges(update, new Date(2015, 7, 30), new Date(2015, 10, 30)); + }); - assertRanges(update, new Date(2006, 0, 1), new Date(2015, 10, 30)); - }); + it('should return update object (6 months backward case)', function() { + var buttonLayout = { + step: 'month', + stepmode: 'backward', + count: 6 + }; - it('should return update object (1 year backward case)', function() { - var buttonLayout = { - step: 'year', - stepmode: 'backward', - count: 1 - }; + var update = getUpdateObject(axisLayout, buttonLayout); - var update = getUpdateObject(axisLayout, buttonLayout); + assertRanges(update, new Date(2015, 4, 30), new Date(2015, 10, 30)); + }); - assertRanges(update, new Date(2014, 10, 30), new Date(2015, 10, 30)); - }); + it('should return update object (5 months to-date case)', function() { + var buttonLayout = { + step: 'month', + stepmode: 'todate', + count: 5 + }; - it('should return update object (reset case)', function() { - var buttonLayout = { - step: 'all' - }; + var update = getUpdateObject(axisLayout, buttonLayout); - var update = getUpdateObject(axisLayout, buttonLayout); + assertRanges(update, new Date(2015, 6, 1), new Date(2015, 10, 30)); + }); - expect(update).toEqual({ 'xaxis.autorange': true }); - }); + it('should return update object (1 year to-date case)', function() { + var buttonLayout = { + step: 'year', + stepmode: 'todate', + count: 1 + }; - it('should return update object (10 day backward case)', function() { - var buttonLayout = { - step: 'day', - stepmode: 'backward', - count: 10 - }; + var update = getUpdateObject(axisLayout, buttonLayout); - var update = getUpdateObject(axisLayout, buttonLayout); + assertRanges(update, new Date(2015, 0, 1), new Date(2015, 10, 30)); + }); - assertRanges(update, new Date(2015, 10, 20), new Date(2015, 10, 30)); - }); + it('should return update object (10 year to-date case)', function() { + var buttonLayout = { + step: 'year', + stepmode: 'todate', + count: 10 + }; - it('should return update object (5 hour backward case)', function() { - var buttonLayout = { - step: 'hour', - stepmode: 'backward', - count: 5 - }; + var update = getUpdateObject(axisLayout, buttonLayout); - var update = getUpdateObject(axisLayout, buttonLayout); + assertRanges(update, new Date(2006, 0, 1), new Date(2015, 10, 30)); + }); - assertRanges(update, new Date(2015, 10, 29, 19), new Date(2015, 10, 30)); - }); + it('should return update object (1 year backward case)', function() { + var buttonLayout = { + step: 'year', + stepmode: 'backward', + count: 1 + }; - it('should return update object (15 minute backward case)', function() { - var buttonLayout = { - step: 'minute', - stepmode: 'backward', - count: 15 - }; + var update = getUpdateObject(axisLayout, buttonLayout); - var update = getUpdateObject(axisLayout, buttonLayout); + assertRanges(update, new Date(2014, 10, 30), new Date(2015, 10, 30)); + }); - assertRanges(update, new Date(2015, 10, 29, 23, 45), new Date(2015, 10, 30)); - }); + it('should return update object (reset case)', function() { + var buttonLayout = { + step: 'all' + }; - it('should return update object (10 second backward case)', function() { - var buttonLayout = { - step: 'second', - stepmode: 'backward', - count: 10 - }; + var update = getUpdateObject(axisLayout, buttonLayout); - var update = getUpdateObject(axisLayout, buttonLayout); + expect(update).toEqual({ 'xaxis.autorange': true }); + }); - assertRanges(update, new Date(2015, 10, 29, 23, 59, 50), new Date(2015, 10, 30)); - }); + it('should return update object (10 day backward case)', function() { + var buttonLayout = { + step: 'day', + stepmode: 'backward', + count: 10 + }; - it('should return update object (12 hour to-date case)', function() { - var buttonLayout = { - step: 'hour', - stepmode: 'todate', - count: 12 - }; + var update = getUpdateObject(axisLayout, buttonLayout); - axisLayout.range[1] = new Date(2015, 10, 30, 12).getTime(); + assertRanges(update, new Date(2015, 10, 20), new Date(2015, 10, 30)); + }); - var update = getUpdateObject(axisLayout, buttonLayout); + it('should return update object (5 hour backward case)', function() { + var buttonLayout = { + step: 'hour', + stepmode: 'backward', + count: 5 + }; - assertRanges(update, new Date(2015, 10, 30, 1), new Date(2015, 10, 30, 12)); - }); + var update = getUpdateObject(axisLayout, buttonLayout); - it('should return update object (15 minute backward case)', function() { - var buttonLayout = { - step: 'minute', - stepmode: 'todate', - count: 20 - }; + assertRanges(update, new Date(2015, 10, 29, 19), new Date(2015, 10, 30)); + }); - axisLayout.range[1] = new Date(2015, 10, 30, 12, 20).getTime(); + it('should return update object (15 minute backward case)', function() { + var buttonLayout = { + step: 'minute', + stepmode: 'backward', + count: 15 + }; - var update = getUpdateObject(axisLayout, buttonLayout); + var update = getUpdateObject(axisLayout, buttonLayout); - assertRanges(update, new Date(2015, 10, 30, 12, 1), new Date(2015, 10, 30, 12, 20)); - }); + assertRanges(update, new Date(2015, 10, 29, 23, 45), new Date(2015, 10, 30)); + }); - it('should return update object (2 second to-date case)', function() { - var buttonLayout = { - step: 'second', - stepmode: 'todate', - count: 2 - }; + it('should return update object (10 second backward case)', function() { + var buttonLayout = { + step: 'second', + stepmode: 'backward', + count: 10 + }; - axisLayout.range[1] = new Date(2015, 10, 30, 12, 20, 2).getTime(); + var update = getUpdateObject(axisLayout, buttonLayout); - var update = getUpdateObject(axisLayout, buttonLayout); + assertRanges(update, new Date(2015, 10, 29, 23, 59, 50), new Date(2015, 10, 30)); + }); - assertRanges(update, new Date(2015, 10, 30, 12, 20, 1), new Date(2015, 10, 30, 12, 20, 2)); - }); + it('should return update object (12 hour to-date case)', function() { + var buttonLayout = { + step: 'hour', + stepmode: 'todate', + count: 12 + }; - it('should return update object with correct axis names', function() { - var axisLayout = { - _name: 'xaxis5', - range: [ - (new Date(1948, 0, 1)).getTime(), - (new Date(2015, 10, 30)).getTime() - ] - }; - - var buttonLayout = { - step: 'month', - stepmode: 'backward', - count: 1 - }; - - var update = getUpdateObject(axisLayout, buttonLayout); - - expect(update).toEqual({ - 'xaxis5.range[0]': new Date(2015, 9, 30).getTime(), - 'xaxis5.range[1]': new Date(2015, 10, 30).getTime() - }); + axisLayout.range[1] = new Date(2015, 10, 30, 12).getTime(); - }); + var update = getUpdateObject(axisLayout, buttonLayout); + + assertRanges(update, new Date(2015, 10, 30, 1), new Date(2015, 10, 30, 12)); }); - describe('interactions:', function() { - var mock = require('@mocks/range_selector.json'); + it('should return update object (15 minute backward case)', function() { + var buttonLayout = { + step: 'minute', + stepmode: 'todate', + count: 20 + }; - var gd, mockCopy; + axisLayout.range[1] = new Date(2015, 10, 30, 12, 20).getTime(); - beforeEach(function(done) { - gd = createGraphDiv(); - mockCopy = Lib.extendDeep({}, mock); + var update = getUpdateObject(axisLayout, buttonLayout); - Plotly.plot(gd, mockCopy.data, mockCopy.layout).then(done); - }); + assertRanges(update, new Date(2015, 10, 30, 12, 1), new Date(2015, 10, 30, 12, 20)); + }); - afterEach(destroyGraphDiv); + it('should return update object (2 second to-date case)', function() { + var buttonLayout = { + step: 'second', + stepmode: 'todate', + count: 2 + }; - function assertNodeCount(query, cnt) { - expect(d3.selectAll(query).size()).toEqual(cnt); - } + axisLayout.range[1] = new Date(2015, 10, 30, 12, 20, 2).getTime(); - function checkActiveButton(activeIndex) { - d3.selectAll('.button').each(function(d, i) { - expect(d.isActive).toBe(activeIndex === i); - }); - } + var update = getUpdateObject(axisLayout, buttonLayout); - it('should display the correct nodes', function() { - assertNodeCount('.rangeselector', 1); - assertNodeCount('.button', mockCopy.layout.xaxis.rangeselector.buttons.length); - }); + assertRanges(update, new Date(2015, 10, 30, 12, 20, 1), new Date(2015, 10, 30, 12, 20, 2)); + }); - it('should be able to be removed by `relayout`', function(done) { - Plotly.relayout(gd, 'xaxis.rangeselector.visible', false).then(function() { - assertNodeCount('.rangeselector', 0); - assertNodeCount('.button', 0); - done(); - }); + it('should return update object with correct axis names', function() { + var axisLayout = { + _name: 'xaxis5', + range: [ + (new Date(1948, 0, 1)).getTime(), + (new Date(2015, 10, 30)).getTime() + ] + }; + + var buttonLayout = { + step: 'month', + stepmode: 'backward', + count: 1 + }; + + var update = getUpdateObject(axisLayout, buttonLayout); + expect(update).toEqual({ + 'xaxis5.range[0]': new Date(2015, 9, 30).getTime(), + 'xaxis5.range[1]': new Date(2015, 10, 30).getTime() }); - it('should update range and active button when clicked', function() { - var range0 = gd.layout.xaxis.range[0]; - var buttons = d3.selectAll('.button').select('rect'); + }); +}); - checkActiveButton(buttons.size() - 1); +describe('range selector interactions:', function() { + 'use strict'; - var pos0 = getRectCenter(buttons[0][0]); - var posReset = getRectCenter(buttons[0][buttons.size() - 1]); + var mock = require('@mocks/range_selector.json'); - mouseEvent('click', pos0[0], pos0[1]); - expect(gd.layout.xaxis.range[0]).toBeGreaterThan(range0); + var gd, mockCopy; - checkActiveButton(0); + beforeEach(function(done) { + gd = createGraphDiv(); + mockCopy = Lib.extendDeep({}, mock); + + Plotly.plot(gd, mockCopy.data, mockCopy.layout).then(done); + }); - mouseEvent('click', posReset[0], posReset[1]); - expect(gd.layout.xaxis.range[0]).toEqual(range0); + afterEach(destroyGraphDiv); - checkActiveButton(buttons.size() - 1); + function assertNodeCount(query, cnt) { + expect(d3.selectAll(query).size()).toEqual(cnt); + } + + function checkActiveButton(activeIndex) { + d3.selectAll('.button').each(function(d, i) { + expect(d.isActive).toBe(activeIndex === i); + }); + } + + it('should display the correct nodes', function() { + assertNodeCount('.rangeselector', 1); + assertNodeCount('.button', mockCopy.layout.xaxis.rangeselector.buttons.length); + }); + + it('should be able to be removed by `relayout`', function(done) { + Plotly.relayout(gd, 'xaxis.rangeselector.visible', false).then(function() { + assertNodeCount('.rangeselector', 0); + assertNodeCount('.button', 0); + done(); }); - it('should change color on mouse over', function() { - var button = d3.select('.button').select('rect'); - var pos = getRectCenter(button.node()); + }); + + it('should be able to remove button(s) on `relayout`', function(done) { + var len = mockCopy.layout.xaxis.rangeselector.buttons.length; - var fillColor = Color.rgb(gd._fullLayout.xaxis.rangeselector.bgcolor); - var activeColor = Color.rgb(constants.activeColor); + assertNodeCount('.button', len); - expect(button.style('fill')).toEqual(fillColor); + Plotly.relayout(gd, 'xaxis.rangeselector.buttons[0]', null).then(function() { + assertNodeCount('.button', len - 1); - mouseEvent('mouseover', pos[0], pos[1]); - expect(button.style('fill')).toEqual(activeColor); + return Plotly.relayout(gd, 'xaxis.rangeselector.buttons[1]', 'remove'); + }).then(function() { + assertNodeCount('.button', len - 2); - mouseEvent('mouseout', pos[0], pos[1]); - expect(button.style('fill')).toEqual(fillColor); + done(); }); + }); - it('should update is active relayout calls', function(done) { - var buttons = d3.selectAll('.button').select('rect'); + it('should update range and active button when clicked', function() { + var range0 = gd.layout.xaxis.range[0]; + var buttons = d3.selectAll('.button').select('rect'); - // 'all' should be active at first - checkActiveButton(buttons.size() - 1); + checkActiveButton(buttons.size() - 1); - var update = { - 'xaxis.range[0]': (new Date(2015, 9, 30)).getTime(), - 'xaxis.range[1]': (new Date(2015, 10, 30)).getTime() - }; + var pos0 = getRectCenter(buttons[0][0]); + var posReset = getRectCenter(buttons[0][buttons.size() - 1]); - Plotly.relayout(gd, update).then(function() { + mouseEvent('click', pos0[0], pos0[1]); + expect(gd.layout.xaxis.range[0]).toBeGreaterThan(range0); - // '1m' should be active after the relayout - checkActiveButton(0); + checkActiveButton(0); - return Plotly.relayout(gd, 'xaxis.autorange', true); - }).then(function() { + mouseEvent('click', posReset[0], posReset[1]); + expect(gd.layout.xaxis.range[0]).toEqual(range0); - // 'all' should be after an autoscale - checkActiveButton(buttons.size() - 1); + checkActiveButton(buttons.size() - 1); + }); - done(); - }); - }); + it('should change color on mouse over', function() { + var button = d3.select('.button').select('rect'); + var pos = getRectCenter(button.node()); + var fillColor = Color.rgb(gd._fullLayout.xaxis.rangeselector.bgcolor); + var activeColor = Color.rgb(constants.activeColor); + + expect(button.style('fill')).toEqual(fillColor); + + mouseEvent('mouseover', pos[0], pos[1]); + expect(button.style('fill')).toEqual(activeColor); + + mouseEvent('mouseout', pos[0], pos[1]); + expect(button.style('fill')).toEqual(fillColor); }); + it('should update is active relayout calls', function(done) { + var buttons = d3.selectAll('.button').select('rect'); + + // 'all' should be active at first + checkActiveButton(buttons.size() - 1); + + var update = { + 'xaxis.range[0]': (new Date(2015, 9, 30)).getTime(), + 'xaxis.range[1]': (new Date(2015, 10, 30)).getTime() + }; + + Plotly.relayout(gd, update).then(function() { + + // '1m' should be active after the relayout + checkActiveButton(0); + + return Plotly.relayout(gd, 'xaxis.autorange', true); + }).then(function() { + + // 'all' should be after an autoscale + checkActiveButton(buttons.size() - 1); + + done(); + }); + }); }); diff --git a/test/jasmine/tests/svg_text_utils_test.js b/test/jasmine/tests/svg_text_utils_test.js index 6d11560a105..be4601743c8 100644 --- a/test/jasmine/tests/svg_text_utils_test.js +++ b/test/jasmine/tests/svg_text_utils_test.js @@ -25,6 +25,11 @@ describe('svg+text utils', function() { expect(a.attr('xlink:show')).toBe(href === null ? null : 'new'); } + function assertTspanStyle(node, style) { + var tspan = node.select('tspan'); + expect(tspan.attr('style')).toBe(style); + } + function assertAnchorAttrs(node) { var a = node.select('a'); @@ -75,6 +80,16 @@ describe('svg+text utils', function() { assertAnchorLink(node, null); }); + it('whitelist relative hrefs (interpreted as http)', function() { + var node = mockTextSVGElement( + '
mylink' + ); + + expect(node.text()).toEqual('mylink'); + assertAnchorAttrs(node); + assertAnchorLink(node, '/mylink'); + }); + it('whitelist http hrefs', function() { var node = mockTextSVGElement( 'bl.ocks.org' @@ -134,5 +149,50 @@ describe('svg+text utils', function() { assertAnchorLink(node, 'https://abc.com/myFeature.jsp?name=abc&pwd=def'); }); }); + + it('allow basic spans', function() { + var node = mockTextSVGElement( + 'text' + ); + + expect(node.text()).toEqual('text'); + assertTspanStyle(node, null); + }); + + it('ignore unquoted styles in spans', function() { + var node = mockTextSVGElement( + 'text' + ); + + expect(node.text()).toEqual('text'); + assertTspanStyle(node, null); + }); + + it('allow quoted styles in spans', function() { + var node = mockTextSVGElement( + 'text' + ); + + expect(node.text()).toEqual('text'); + assertTspanStyle(node, 'quoted: yeah;'); + }); + + it('ignore extra stuff after span styles', function() { + var node = mockTextSVGElement( + 'text' + ); + + expect(node.text()).toEqual('text'); + assertTspanStyle(node, 'quoted: yeah;'); + }); + + it('escapes HTML entities in span styles', function() { + var node = mockTextSVGElement( + 'text' + ); + + expect(node.text()).toEqual('text'); + assertTspanStyle(node, 'quoted: yeah&\';;'); + }); }); }); diff --git a/test/jasmine/tests/transforms_test.js b/test/jasmine/tests/transforms_test.js index d95eb1319bd..5b832a9436c 100644 --- a/test/jasmine/tests/transforms_test.js +++ b/test/jasmine/tests/transforms_test.js @@ -66,7 +66,7 @@ describe('one-to-one transforms:', function() { it('supplyDataDefaults should apply the transform while', function() { var dataIn = [{ x: [-2, -2, 1, 2, 3], - y: [1, 2, 2, 3, 1], + y: [1, 2, 2, 3, 1] }, { x: [-2, -1, -2, 0, 1, 2, 3], y: [1, 2, 3, 1, 2, 3, 1], @@ -288,12 +288,12 @@ describe('one-to-many transforms:', function() { it('supplyDataDefaults should apply the transform while', function() { var dummyTrace0 = { x: [-2, -2, 1, 2, 3], - y: [1, 2, 2, 3, 1], + y: [1, 2, 2, 3, 1] }; var dummyTrace1 = { x: [-1, 2, 3], - y: [2, 3, 1], + y: [2, 3, 1] }; var dataIn = [ @@ -493,12 +493,12 @@ describe('multiple transforms:', function() { it('supplyDataDefaults should apply the transform while', function() { var dummyTrace0 = { x: [-2, -2, 1, 2, 3], - y: [1, 2, 2, 3, 1], + y: [1, 2, 2, 3, 1] }; var dummyTrace1 = { x: [-1, 2, 3], - y: [2, 3, 1], + y: [2, 3, 1] }; var dataIn = [ @@ -718,12 +718,12 @@ describe('multiple traces with transforms:', function() { it('supplyDataDefaults should apply the transform while', function() { var dummyTrace0 = { x: [-2, -2, 1, 2, 3], - y: [1, 2, 2, 3, 1], + y: [1, 2, 2, 3, 1] }; var dummyTrace1 = { x: [-1, 2, 3], - y: [2, 3, 1], + y: [2, 3, 1] }; var dataIn = [ diff --git a/test/jasmine/tests/validate_test.js b/test/jasmine/tests/validate_test.js index 5ce33eb738f..461d87c951b 100644 --- a/test/jasmine/tests/validate_test.js +++ b/test/jasmine/tests/validate_test.js @@ -258,7 +258,7 @@ describe('Plotly.validate', function() { it('should work with attributes in registered transforms', function() { var base = { x: [-2, -1, -2, 0, 1, 2, 3], - y: [1, 2, 3, 1, 2, 3, 1], + y: [1, 2, 3, 1, 2, 3, 1] }; var out = Plotly.validate([ @@ -286,7 +286,7 @@ describe('Plotly.validate', function() { transforms: [{ type: 'no gonna work' }] - }), + }) ], { title: 'my transformed graph' });