plotly.js
Version:
The open source javascript graphing library that powers plotly
1,488 lines (1,261 loc) • 144 kB
JavaScript
/**
* Copyright 2012-2020, 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 isNumeric = require('fast-isnumeric');
var hasHover = require('has-hover');
var Lib = require('../lib');
var nestedProperty = Lib.nestedProperty;
var Events = require('../lib/events');
var Queue = require('../lib/queue');
var Registry = require('../registry');
var PlotSchema = require('./plot_schema');
var Plots = require('../plots/plots');
var Polar = require('../plots/polar/legacy');
var Axes = require('../plots/cartesian/axes');
var Drawing = require('../components/drawing');
var Color = require('../components/color');
var initInteractions = require('../plots/cartesian/graph_interact').initInteractions;
var xmlnsNamespaces = require('../constants/xmlns_namespaces');
var svgTextUtils = require('../lib/svg_text_utils');
var clearSelect = require('../plots/cartesian/select').clearSelect;
var dfltConfig = require('./plot_config').dfltConfig;
var manageArrays = require('./manage_arrays');
var helpers = require('./helpers');
var subroutines = require('./subroutines');
var editTypes = require('./edit_types');
var AX_NAME_PATTERN = require('../plots/cartesian/constants').AX_NAME_PATTERN;
var numericNameWarningCount = 0;
var numericNameWarningCountLimit = 5;
/**
* Main plot-creation function
*
* @param {string id or DOM element} gd
* the id or DOM element of the graph container div
* @param {array of objects} data
* array of traces, containing the data and display information for each trace
* @param {object} layout
* object describing the overall display of the plot,
* all the stuff that doesn't pertain to any individual trace
* @param {object} config
* configuration options (see ./plot_config.js for more info)
*
* OR
*
* @param {string id or DOM element} gd
* the id or DOM element of the graph container div
* @param {object} figure
* object containing `data`, `layout`, `config`, and `frames` members
*
*/
function plot(gd, data, layout, config) {
var frames;
gd = Lib.getGraphDiv(gd);
// Events.init is idempotent and bails early if gd has already been init'd
Events.init(gd);
if(Lib.isPlainObject(data)) {
var obj = data;
data = obj.data;
layout = obj.layout;
config = obj.config;
frames = obj.frames;
}
var okToPlot = Events.triggerHandler(gd, 'plotly_beforeplot', [data, layout, config]);
if(okToPlot === false) return Promise.reject();
// if there's no data or layout, and this isn't yet a plotly plot
// container, log a warning to help plotly.js users debug
if(!data && !layout && !Lib.isPlotDiv(gd)) {
Lib.warn('Calling Plotly.plot as if redrawing ' +
'but this container doesn\'t yet have a plot.', gd);
}
function addFrames() {
if(frames) {
return exports.addFrames(gd, frames);
}
}
// transfer configuration options to gd until we move over to
// a more OO like model
setPlotContext(gd, config);
if(!layout) layout = {};
// hook class for plots main container (in case of plotly.js
// this won't be #embedded-graph or .js-tab-contents)
d3.select(gd).classed('js-plotly-plot', true);
// off-screen getBoundingClientRect testing space,
// in #js-plotly-tester (and stored as Drawing.tester)
// so we can share cached text across tabs
Drawing.makeTester();
// collect promises for any async actions during plotting
// any part of the plotting code can push to gd._promises, then
// before we move to the next step, we check that they're all
// complete, and empty out the promise list again.
if(!Array.isArray(gd._promises)) gd._promises = [];
var graphWasEmpty = ((gd.data || []).length === 0 && Array.isArray(data));
// if there is already data on the graph, append the new data
// if you only want to redraw, pass a non-array for data
if(Array.isArray(data)) {
helpers.cleanData(data);
if(graphWasEmpty) gd.data = data;
else gd.data.push.apply(gd.data, data);
// for routines outside graph_obj that want a clean tab
// (rather than appending to an existing one) gd.empty
// is used to determine whether to make a new tab
gd.empty = false;
}
if(!gd.layout || graphWasEmpty) {
gd.layout = helpers.cleanLayout(layout);
}
Plots.supplyDefaults(gd);
var fullLayout = gd._fullLayout;
var hasCartesian = fullLayout._has('cartesian');
// Legacy polar plots
if(!fullLayout._has('polar') && data && data[0] && data[0].r) {
Lib.log('Legacy polar charts are deprecated!');
return plotLegacyPolar(gd, data, layout);
}
// so we don't try to re-call Plotly.plot from inside
// legend and colorbar, if margins changed
fullLayout._replotting = true;
// make or remake the framework if we need to
if(graphWasEmpty || fullLayout._shouldCreateBgLayer) {
makePlotFramework(gd);
if(fullLayout._shouldCreateBgLayer) {
delete fullLayout._shouldCreateBgLayer;
}
}
// polar need a different framework
if(gd.framework !== makePlotFramework) {
gd.framework = makePlotFramework;
makePlotFramework(gd);
}
// clear gradient defs on each .plot call, because we know we'll loop through all traces
Drawing.initGradients(gd);
// save initial show spikes once per graph
if(graphWasEmpty) Axes.saveShowSpikeInitial(gd);
// prepare the data and find the autorange
// generate calcdata, if we need to
// to force redoing calcdata, just delete it before calling Plotly.plot
var recalc = !gd.calcdata || gd.calcdata.length !== (gd._fullData || []).length;
if(recalc) Plots.doCalcdata(gd);
// in case it has changed, attach fullData traces to calcdata
for(var i = 0; i < gd.calcdata.length; i++) {
gd.calcdata[i][0].trace = gd._fullData[i];
}
// make the figure responsive
if(gd._context.responsive) {
if(!gd._responsiveChartHandler) {
// Keep a reference to the resize handler to purge it down the road
gd._responsiveChartHandler = function() { if(!Lib.isHidden(gd)) Plots.resize(gd); };
// Listen to window resize
window.addEventListener('resize', gd._responsiveChartHandler);
}
} else {
Lib.clearResponsive(gd);
}
/*
* start async-friendly code - now we're actually drawing things
*/
var oldMargins = Lib.extendFlat({}, fullLayout._size);
// draw framework first so that margin-pushing
// components can position themselves correctly
var drawFrameworkCalls = 0;
function drawFramework() {
var basePlotModules = fullLayout._basePlotModules;
for(var i = 0; i < basePlotModules.length; i++) {
if(basePlotModules[i].drawFramework) {
basePlotModules[i].drawFramework(gd);
}
}
if(!fullLayout._glcanvas && fullLayout._has('gl')) {
fullLayout._glcanvas = fullLayout._glcontainer.selectAll('.gl-canvas').data([{
key: 'contextLayer',
context: true,
pick: false
}, {
key: 'focusLayer',
context: false,
pick: false
}, {
key: 'pickLayer',
context: false,
pick: true
}], function(d) { return d.key; });
fullLayout._glcanvas.enter().append('canvas')
.attr('class', function(d) {
return 'gl-canvas gl-canvas-' + d.key.replace('Layer', '');
})
.style({
position: 'absolute',
top: 0,
left: 0,
overflow: 'visible',
'pointer-events': 'none'
});
}
if(fullLayout._glcanvas) {
fullLayout._glcanvas
.attr('width', fullLayout.width)
.attr('height', fullLayout.height);
var regl = fullLayout._glcanvas.data()[0].regl;
if(regl) {
// Unfortunately, this can happen when relayouting to large
// width/height on some browsers.
if(Math.floor(fullLayout.width) !== regl._gl.drawingBufferWidth ||
Math.floor(fullLayout.height) !== regl._gl.drawingBufferHeight
) {
var msg = 'WebGL context buffer and canvas dimensions do not match due to browser/WebGL bug.';
if(drawFrameworkCalls) {
Lib.error(msg);
} else {
Lib.log(msg + ' Clearing graph and plotting again.');
Plots.cleanPlot([], {}, gd._fullData, fullLayout);
Plots.supplyDefaults(gd);
fullLayout = gd._fullLayout;
Plots.doCalcdata(gd);
drawFrameworkCalls++;
return drawFramework();
}
}
}
}
if(fullLayout.modebar.orientation === 'h') {
fullLayout._modebardiv
.style('height', null)
.style('width', '100%');
} else {
fullLayout._modebardiv
.style('width', null)
.style('height', fullLayout.height + 'px');
}
return Plots.previousPromises(gd);
}
// draw anything that can affect margins.
function marginPushers() {
// First reset the list of things that are allowed to change the margins
// So any deleted traces or components will be wiped out of the
// automargin calculation.
// This means *every* margin pusher must be listed here, even if it
// doesn't actually try to push the margins until later.
Plots.clearAutoMarginIds(gd);
subroutines.drawMarginPushers(gd);
Axes.allowAutoMargin(gd);
// TODO can this be moved elsewhere?
if(fullLayout._has('pie')) {
var fullData = gd._fullData;
for(var i = 0; i < fullData.length; i++) {
var trace = fullData[i];
if(trace.type === 'pie' && trace.automargin) {
Plots.allowAutoMargin(gd, 'pie.' + trace.uid + '.automargin');
}
}
}
Plots.doAutoMargin(gd);
return Plots.previousPromises(gd);
}
// in case the margins changed, draw margin pushers again
function marginPushersAgain() {
if(!Plots.didMarginChange(oldMargins, fullLayout._size)) return;
return Lib.syncOrAsync([
marginPushers,
subroutines.layoutStyles
], gd);
}
function positionAndAutorange() {
if(!recalc) {
doAutoRangeAndConstraints();
return;
}
// TODO: autosize extra for text markers and images
// see https://github.com/plotly/plotly.js/issues/1111
return Lib.syncOrAsync([
Registry.getComponentMethod('shapes', 'calcAutorange'),
Registry.getComponentMethod('annotations', 'calcAutorange'),
doAutoRangeAndConstraints
], gd);
}
function doAutoRangeAndConstraints() {
if(gd._transitioning) return;
subroutines.doAutoRangeAndConstraints(gd);
// store initial ranges *after* enforcing constraints, otherwise
// we will never look like we're at the initial ranges
if(graphWasEmpty) Axes.saveRangeInitial(gd);
// this one is different from shapes/annotations calcAutorange
// the others incorporate those components into ax._extremes,
// this one actually sets the ranges in rangesliders.
Registry.getComponentMethod('rangeslider', 'calcAutorange')(gd);
}
// draw ticks, titles, and calculate axis scaling (._b, ._m)
function drawAxes() {
return Axes.draw(gd, graphWasEmpty ? '' : 'redraw');
}
var seq = [
Plots.previousPromises,
addFrames,
drawFramework,
marginPushers,
marginPushersAgain
];
if(hasCartesian) seq.push(positionAndAutorange);
seq.push(subroutines.layoutStyles);
if(hasCartesian) {
seq.push(
drawAxes,
function insideTickLabelsAutorange(gd) {
if(gd._fullLayout._insideTickLabelsAutorange) {
relayout(gd, gd._fullLayout._insideTickLabelsAutorange).then(function() {
gd._fullLayout._insideTickLabelsAutorange = undefined;
});
}
}
);
}
seq.push(
subroutines.drawData,
subroutines.finalDraw,
initInteractions,
Plots.addLinks,
Plots.rehover,
Plots.redrag,
// TODO: doAutoMargin is only needed here for axis automargin, which
// happens outside of marginPushers where all the other automargins are
// calculated. Would be much better to separate margin calculations from
// component drawing - see https://github.com/plotly/plotly.js/issues/2704
Plots.doAutoMargin,
saveRangeInitialForInsideTickLabels,
Plots.previousPromises
);
function saveRangeInitialForInsideTickLabels(gd) {
if(gd._fullLayout._insideTickLabelsAutorange) {
if(graphWasEmpty) Axes.saveRangeInitial(gd, true);
}
}
// even if everything we did was synchronous, return a promise
// so that the caller doesn't care which route we took
var plotDone = Lib.syncOrAsync(seq, gd);
if(!plotDone || !plotDone.then) plotDone = Promise.resolve();
return plotDone.then(function() {
emitAfterPlot(gd);
return gd;
});
}
function emitAfterPlot(gd) {
var fullLayout = gd._fullLayout;
if(fullLayout._redrawFromAutoMarginCount) {
fullLayout._redrawFromAutoMarginCount--;
} else {
gd.emit('plotly_afterplot');
}
}
function setPlotConfig(obj) {
return Lib.extendFlat(dfltConfig, obj);
}
function setBackground(gd, bgColor) {
try {
gd._fullLayout._paper.style('background', bgColor);
} catch(e) {
Lib.error(e);
}
}
function opaqueSetBackground(gd, bgColor) {
var blend = Color.combine(bgColor, 'white');
setBackground(gd, blend);
}
function setPlotContext(gd, config) {
if(!gd._context) {
gd._context = Lib.extendDeep({}, dfltConfig);
// stash <base> href, used to make robust clipPath URLs
var base = d3.select('base');
gd._context._baseUrl = base.size() && base.attr('href') ?
window.location.href.split('#')[0] :
'';
}
var context = gd._context;
var i, keys, key;
if(config) {
keys = Object.keys(config);
for(i = 0; i < keys.length; i++) {
key = keys[i];
if(key === 'editable' || key === 'edits') continue;
if(key in context) {
if(key === 'setBackground' && config[key] === 'opaque') {
context[key] = opaqueSetBackground;
} else {
context[key] = config[key];
}
}
}
// map plot3dPixelRatio to plotGlPixelRatio for backward compatibility
if(config.plot3dPixelRatio && !context.plotGlPixelRatio) {
context.plotGlPixelRatio = context.plot3dPixelRatio;
}
// now deal with editable and edits - first editable overrides
// everything, then edits refines
var editable = config.editable;
if(editable !== undefined) {
// we're not going to *use* context.editable, we're only going to
// use context.edits... but keep it for the record
context.editable = editable;
keys = Object.keys(context.edits);
for(i = 0; i < keys.length; i++) {
context.edits[keys[i]] = editable;
}
}
if(config.edits) {
keys = Object.keys(config.edits);
for(i = 0; i < keys.length; i++) {
key = keys[i];
if(key in context.edits) {
context.edits[key] = config.edits[key];
}
}
}
// not part of the user-facing config options
context._exportedPlot = config._exportedPlot;
}
// staticPlot forces a bunch of others:
if(context.staticPlot) {
context.editable = false;
context.edits = {};
context.autosizable = false;
context.scrollZoom = false;
context.doubleClick = false;
context.showTips = false;
context.showLink = false;
context.displayModeBar = false;
}
// make sure hover-only devices have mode bar visible
if(context.displayModeBar === 'hover' && !hasHover) {
context.displayModeBar = true;
}
// default and fallback for setBackground
if(context.setBackground === 'transparent' || typeof context.setBackground !== 'function') {
context.setBackground = setBackground;
}
// Check if gd has a specified widht/height to begin with
context._hasZeroHeight = context._hasZeroHeight || gd.clientHeight === 0;
context._hasZeroWidth = context._hasZeroWidth || gd.clientWidth === 0;
// fill context._scrollZoom helper to help manage scrollZoom flaglist
var szIn = context.scrollZoom;
var szOut = context._scrollZoom = {};
if(szIn === true) {
szOut.cartesian = 1;
szOut.gl3d = 1;
szOut.geo = 1;
szOut.mapbox = 1;
} else if(typeof szIn === 'string') {
var parts = szIn.split('+');
for(i = 0; i < parts.length; i++) {
szOut[parts[i]] = 1;
}
} else if(szIn !== false) {
szOut.gl3d = 1;
szOut.geo = 1;
szOut.mapbox = 1;
}
}
function plotLegacyPolar(gd, data, layout) {
// build or reuse the container skeleton
var plotContainer = d3.select(gd).selectAll('.plot-container')
.data([0]);
plotContainer.enter()
.insert('div', ':first-child')
.classed('plot-container plotly', true);
var paperDiv = plotContainer.selectAll('.svg-container')
.data([0]);
paperDiv.enter().append('div')
.classed('svg-container', true)
.style('position', 'relative');
// empty it everytime for now
paperDiv.html('');
// fulfill gd requirements
if(data) gd.data = data;
if(layout) gd.layout = layout;
Polar.manager.fillLayout(gd);
// resize canvas
paperDiv.style({
width: gd._fullLayout.width + 'px',
height: gd._fullLayout.height + 'px'
});
// instantiate framework
gd.framework = Polar.manager.framework(gd);
// plot
gd.framework({data: gd.data, layout: gd.layout}, paperDiv.node());
// set undo point
gd.framework.setUndoPoint();
// get the resulting svg for extending it
var polarPlotSVG = gd.framework.svg();
// editable title
var opacity = 1;
var txt = gd._fullLayout.title ? gd._fullLayout.title.text : '';
if(txt === '' || !txt) opacity = 0;
var titleLayout = function() {
this.call(svgTextUtils.convertToTspans, gd);
// TODO: html/mathjax
// TODO: center title
};
var title = polarPlotSVG.select('.title-group text')
.call(titleLayout);
if(gd._context.edits.titleText) {
var placeholderText = Lib._(gd, 'Click to enter Plot title');
if(!txt || txt === placeholderText) {
opacity = 0.2;
// placeholder is not going through convertToTspans
// so needs explicit data-unformatted
title.attr({'data-unformatted': placeholderText})
.text(placeholderText)
.style({opacity: opacity})
.on('mouseover.opacity', function() {
d3.select(this).transition().duration(100)
.style('opacity', 1);
})
.on('mouseout.opacity', function() {
d3.select(this).transition().duration(1000)
.style('opacity', 0);
});
}
var setContenteditable = function() {
this.call(svgTextUtils.makeEditable, {gd: gd})
.on('edit', function(text) {
gd.framework({layout: {title: {text: text}}});
this.text(text)
.call(titleLayout);
this.call(setContenteditable);
})
.on('cancel', function() {
var txt = this.attr('data-unformatted');
this.text(txt).call(titleLayout);
});
};
title.call(setContenteditable);
}
gd._context.setBackground(gd, gd._fullLayout.paper_bgcolor);
Plots.addLinks(gd);
return Promise.resolve();
}
// convenience function to force a full redraw, mostly for use by plotly.js
function redraw(gd) {
gd = Lib.getGraphDiv(gd);
if(!Lib.isPlotDiv(gd)) {
throw new Error('This element is not a Plotly plot: ' + gd);
}
helpers.cleanData(gd.data);
helpers.cleanLayout(gd.layout);
gd.calcdata = undefined;
return exports.plot(gd).then(function() {
gd.emit('plotly_redraw');
return gd;
});
}
/**
* Convenience function to make idempotent plot option obvious to users.
*
* @param gd
* @param {Object[]} data
* @param {Object} layout
* @param {Object} config
*/
function newPlot(gd, data, layout, config) {
gd = Lib.getGraphDiv(gd);
// remove gl contexts
Plots.cleanPlot([], {}, gd._fullData || [], gd._fullLayout || {});
Plots.purge(gd);
return exports.plot(gd, data, layout, config);
}
/**
* Wrap negative indicies to their positive counterparts.
*
* @param {Number[]} indices An array of indices
* @param {Number} maxIndex The maximum index allowable (arr.length - 1)
*/
function positivifyIndices(indices, maxIndex) {
var parentLength = maxIndex + 1;
var positiveIndices = [];
var i;
var index;
for(i = 0; i < indices.length; i++) {
index = indices[i];
if(index < 0) {
positiveIndices.push(parentLength + index);
} else {
positiveIndices.push(index);
}
}
return positiveIndices;
}
/**
* Ensures that an index array for manipulating gd.data is valid.
*
* Intended for use with addTraces, deleteTraces, and moveTraces.
*
* @param gd
* @param indices
* @param arrayName
*/
function assertIndexArray(gd, indices, arrayName) {
var i,
index;
for(i = 0; i < indices.length; i++) {
index = indices[i];
// validate that indices are indeed integers
if(index !== parseInt(index, 10)) {
throw new Error('all values in ' + arrayName + ' must be integers');
}
// check that all indices are in bounds for given gd.data array length
if(index >= gd.data.length || index < -gd.data.length) {
throw new Error(arrayName + ' must be valid indices for gd.data.');
}
// check that indices aren't repeated
if(indices.indexOf(index, i + 1) > -1 ||
index >= 0 && indices.indexOf(-gd.data.length + index) > -1 ||
index < 0 && indices.indexOf(gd.data.length + index) > -1) {
throw new Error('each index in ' + arrayName + ' must be unique.');
}
}
}
/**
* Private function used by Plotly.moveTraces to check input args
*
* @param gd
* @param currentIndices
* @param newIndices
*/
function checkMoveTracesArgs(gd, currentIndices, newIndices) {
// check that gd has attribute 'data' and 'data' is array
if(!Array.isArray(gd.data)) {
throw new Error('gd.data must be an array.');
}
// validate currentIndices array
if(typeof currentIndices === 'undefined') {
throw new Error('currentIndices is a required argument.');
} else if(!Array.isArray(currentIndices)) {
currentIndices = [currentIndices];
}
assertIndexArray(gd, currentIndices, 'currentIndices');
// validate newIndices array if it exists
if(typeof newIndices !== 'undefined' && !Array.isArray(newIndices)) {
newIndices = [newIndices];
}
if(typeof newIndices !== 'undefined') {
assertIndexArray(gd, newIndices, 'newIndices');
}
// check currentIndices and newIndices are the same length if newIdices exists
if(typeof newIndices !== 'undefined' && currentIndices.length !== newIndices.length) {
throw new Error('current and new indices must be of equal length.');
}
}
/**
* A private function to reduce the type checking clutter in addTraces.
*
* @param gd
* @param traces
* @param newIndices
*/
function checkAddTracesArgs(gd, traces, newIndices) {
var i, value;
// check that gd has attribute 'data' and 'data' is array
if(!Array.isArray(gd.data)) {
throw new Error('gd.data must be an array.');
}
// make sure traces exists
if(typeof traces === 'undefined') {
throw new Error('traces must be defined.');
}
// make sure traces is an array
if(!Array.isArray(traces)) {
traces = [traces];
}
// make sure each value in traces is an object
for(i = 0; i < traces.length; i++) {
value = traces[i];
if(typeof value !== 'object' || (Array.isArray(value) || value === null)) {
throw new Error('all values in traces array must be non-array objects');
}
}
// make sure we have an index for each trace
if(typeof newIndices !== 'undefined' && !Array.isArray(newIndices)) {
newIndices = [newIndices];
}
if(typeof newIndices !== 'undefined' && newIndices.length !== traces.length) {
throw new Error(
'if indices is specified, traces.length must equal indices.length'
);
}
}
/**
* A private function to reduce the type checking clutter in spliceTraces.
* Get all update Properties from gd.data. Validate inputs and outputs.
* Used by prependTrace and extendTraces
*
* @param gd
* @param update
* @param indices
* @param maxPoints
*/
function assertExtendTracesArgs(gd, update, indices, maxPoints) {
var maxPointsIsObject = Lib.isPlainObject(maxPoints);
if(!Array.isArray(gd.data)) {
throw new Error('gd.data must be an array');
}
if(!Lib.isPlainObject(update)) {
throw new Error('update must be a key:value object');
}
if(typeof indices === 'undefined') {
throw new Error('indices must be an integer or array of integers');
}
assertIndexArray(gd, indices, 'indices');
for(var key in update) {
/*
* Verify that the attribute to be updated contains as many trace updates
* as indices. Failure must result in throw and no-op
*/
if(!Array.isArray(update[key]) || update[key].length !== indices.length) {
throw new Error('attribute ' + key + ' must be an array of length equal to indices array length');
}
/*
* if maxPoints is an object it must match keys and array lengths of 'update' 1:1
*/
if(maxPointsIsObject &&
(!(key in maxPoints) || !Array.isArray(maxPoints[key]) ||
maxPoints[key].length !== update[key].length)) {
throw new Error('when maxPoints is set as a key:value object it must contain a 1:1 ' +
'corrispondence with the keys and number of traces in the update object');
}
}
}
/**
* A private function to reduce the type checking clutter in spliceTraces.
*
* @param {Object|HTMLDivElement} gd
* @param {Object} update
* @param {Number[]} indices
* @param {Number||Object} maxPoints
* @return {Object[]}
*/
function getExtendProperties(gd, update, indices, maxPoints) {
var maxPointsIsObject = Lib.isPlainObject(maxPoints);
var updateProps = [];
var trace, target, prop, insert, maxp;
// allow scalar index to represent a single trace position
if(!Array.isArray(indices)) indices = [indices];
// negative indices are wrapped around to their positive value. Equivalent to python indexing.
indices = positivifyIndices(indices, gd.data.length - 1);
// loop through all update keys and traces and harvest validated data.
for(var key in update) {
for(var j = 0; j < indices.length; j++) {
/*
* Choose the trace indexed by the indices map argument and get the prop setter-getter
* instance that references the key and value for this particular trace.
*/
trace = gd.data[indices[j]];
prop = nestedProperty(trace, key);
/*
* Target is the existing gd.data.trace.dataArray value like "x" or "marker.size"
* Target must exist as an Array to allow the extend operation to be performed.
*/
target = prop.get();
insert = update[key][j];
if(!Lib.isArrayOrTypedArray(insert)) {
throw new Error('attribute: ' + key + ' index: ' + j + ' must be an array');
}
if(!Lib.isArrayOrTypedArray(target)) {
throw new Error('cannot extend missing or non-array attribute: ' + key);
}
if(target.constructor !== insert.constructor) {
throw new Error('cannot extend array with an array of a different type: ' + key);
}
/*
* maxPoints may be an object map or a scalar. If object select the key:value, else
* Use the scalar maxPoints for all key and trace combinations.
*/
maxp = maxPointsIsObject ? maxPoints[key][j] : maxPoints;
// could have chosen null here, -1 just tells us to not take a window
if(!isNumeric(maxp)) maxp = -1;
/*
* Wrap the nestedProperty in an object containing required data
* for lengthening and windowing this particular trace - key combination.
* Flooring maxp mirrors the behaviour of floats in the Array.slice JSnative function.
*/
updateProps.push({
prop: prop,
target: target,
insert: insert,
maxp: Math.floor(maxp)
});
}
}
// all target and insertion data now validated
return updateProps;
}
/**
* A private function to key Extend and Prepend traces DRY
*
* @param {Object|HTMLDivElement} gd
* @param {Object} update
* @param {Number[]} indices
* @param {Number||Object} maxPoints
* @param {Function} updateArray
* @return {Object}
*/
function spliceTraces(gd, update, indices, maxPoints, updateArray) {
assertExtendTracesArgs(gd, update, indices, maxPoints);
var updateProps = getExtendProperties(gd, update, indices, maxPoints);
var undoUpdate = {};
var undoPoints = {};
for(var i = 0; i < updateProps.length; i++) {
var prop = updateProps[i].prop;
var maxp = updateProps[i].maxp;
// return new array and remainder
var out = updateArray(updateProps[i].target, updateProps[i].insert, maxp);
prop.set(out[0]);
// build the inverse update object for the undo operation
if(!Array.isArray(undoUpdate[prop.astr])) undoUpdate[prop.astr] = [];
undoUpdate[prop.astr].push(out[1]);
// build the matching maxPoints undo object containing original trace lengths
if(!Array.isArray(undoPoints[prop.astr])) undoPoints[prop.astr] = [];
undoPoints[prop.astr].push(updateProps[i].target.length);
}
return {update: undoUpdate, maxPoints: undoPoints};
}
function concatTypedArray(arr0, arr1) {
var arr2 = new arr0.constructor(arr0.length + arr1.length);
arr2.set(arr0);
arr2.set(arr1, arr0.length);
return arr2;
}
/**
* extend && prepend traces at indices with update arrays, window trace lengths to maxPoints
*
* Extend and Prepend have identical APIs. Prepend inserts an array at the head while Extend
* inserts an array off the tail. Prepend truncates the tail of the array - counting maxPoints
* from the head, whereas Extend truncates the head of the array, counting backward maxPoints
* from the tail.
*
* If maxPoints is undefined, nonNumeric, negative or greater than extended trace length no
* truncation / windowing will be performed. If its zero, well the whole trace is truncated.
*
* @param {Object|HTMLDivElement} gd The graph div
* @param {Object} update The key:array map of target attributes to extend
* @param {Number|Number[]} indices The locations of traces to be extended
* @param {Number|Object} [maxPoints] Number of points for trace window after lengthening.
*
*/
function extendTraces(gd, update, indices, maxPoints) {
gd = Lib.getGraphDiv(gd);
function updateArray(target, insert, maxp) {
var newArray, remainder;
if(Lib.isTypedArray(target)) {
if(maxp < 0) {
var none = new target.constructor(0);
var both = concatTypedArray(target, insert);
if(maxp < 0) {
newArray = both;
remainder = none;
} else {
newArray = none;
remainder = both;
}
} else {
newArray = new target.constructor(maxp);
remainder = new target.constructor(target.length + insert.length - maxp);
if(maxp === insert.length) {
newArray.set(insert);
remainder.set(target);
} else if(maxp < insert.length) {
var numberOfItemsFromInsert = insert.length - maxp;
newArray.set(insert.subarray(numberOfItemsFromInsert));
remainder.set(target);
remainder.set(insert.subarray(0, numberOfItemsFromInsert), target.length);
} else {
var numberOfItemsFromTarget = maxp - insert.length;
var targetBegin = target.length - numberOfItemsFromTarget;
newArray.set(target.subarray(targetBegin));
newArray.set(insert, numberOfItemsFromTarget);
remainder.set(target.subarray(0, targetBegin));
}
}
} else {
newArray = target.concat(insert);
remainder = (maxp >= 0 && maxp < newArray.length) ?
newArray.splice(0, newArray.length - maxp) :
[];
}
return [newArray, remainder];
}
var undo = spliceTraces(gd, update, indices, maxPoints, updateArray);
var promise = exports.redraw(gd);
var undoArgs = [gd, undo.update, indices, undo.maxPoints];
Queue.add(gd, exports.prependTraces, undoArgs, extendTraces, arguments);
return promise;
}
function prependTraces(gd, update, indices, maxPoints) {
gd = Lib.getGraphDiv(gd);
function updateArray(target, insert, maxp) {
var newArray, remainder;
if(Lib.isTypedArray(target)) {
if(maxp <= 0) {
var none = new target.constructor(0);
var both = concatTypedArray(insert, target);
if(maxp < 0) {
newArray = both;
remainder = none;
} else {
newArray = none;
remainder = both;
}
} else {
newArray = new target.constructor(maxp);
remainder = new target.constructor(target.length + insert.length - maxp);
if(maxp === insert.length) {
newArray.set(insert);
remainder.set(target);
} else if(maxp < insert.length) {
var numberOfItemsFromInsert = insert.length - maxp;
newArray.set(insert.subarray(0, numberOfItemsFromInsert));
remainder.set(insert.subarray(numberOfItemsFromInsert));
remainder.set(target, numberOfItemsFromInsert);
} else {
var numberOfItemsFromTarget = maxp - insert.length;
newArray.set(insert);
newArray.set(target.subarray(0, numberOfItemsFromTarget), insert.length);
remainder.set(target.subarray(numberOfItemsFromTarget));
}
}
} else {
newArray = insert.concat(target);
remainder = (maxp >= 0 && maxp < newArray.length) ?
newArray.splice(maxp, newArray.length) :
[];
}
return [newArray, remainder];
}
var undo = spliceTraces(gd, update, indices, maxPoints, updateArray);
var promise = exports.redraw(gd);
var undoArgs = [gd, undo.update, indices, undo.maxPoints];
Queue.add(gd, exports.extendTraces, undoArgs, prependTraces, arguments);
return promise;
}
/**
* Add data traces to an existing graph div.
*
* @param {Object|HTMLDivElement} gd The graph div
* @param {Object[]} gd.data The array of traces we're adding to
* @param {Object[]|Object} traces The object or array of objects to add
* @param {Number[]|Number} [newIndices=[gd.data.length]] Locations to add traces
*
*/
function addTraces(gd, traces, newIndices) {
gd = Lib.getGraphDiv(gd);
var currentIndices = [];
var undoFunc = exports.deleteTraces;
var redoFunc = addTraces;
var undoArgs = [gd, currentIndices];
var redoArgs = [gd, traces]; // no newIndices here
var i;
var promise;
// all validation is done elsewhere to remove clutter here
checkAddTracesArgs(gd, traces, newIndices);
// make sure traces is an array
if(!Array.isArray(traces)) {
traces = [traces];
}
// make sure traces do not repeat existing ones
traces = traces.map(function(trace) {
return Lib.extendFlat({}, trace);
});
helpers.cleanData(traces);
// add the traces to gd.data (no redrawing yet!)
for(i = 0; i < traces.length; i++) {
gd.data.push(traces[i]);
}
// to continue, we need to call moveTraces which requires currentIndices
for(i = 0; i < traces.length; i++) {
currentIndices.push(-traces.length + i);
}
// if the user didn't define newIndices, they just want the traces appended
// i.e., we can simply redraw and be done
if(typeof newIndices === 'undefined') {
promise = exports.redraw(gd);
Queue.add(gd, undoFunc, undoArgs, redoFunc, redoArgs);
return promise;
}
// make sure indices is property defined
if(!Array.isArray(newIndices)) {
newIndices = [newIndices];
}
try {
// this is redundant, but necessary to not catch later possible errors!
checkMoveTracesArgs(gd, currentIndices, newIndices);
} catch(error) {
// something went wrong, reset gd to be safe and rethrow error
gd.data.splice(gd.data.length - traces.length, traces.length);
throw error;
}
// if we're here, the user has defined specific places to place the new traces
// this requires some extra work that moveTraces will do
Queue.startSequence(gd);
Queue.add(gd, undoFunc, undoArgs, redoFunc, redoArgs);
promise = exports.moveTraces(gd, currentIndices, newIndices);
Queue.stopSequence(gd);
return promise;
}
/**
* Delete traces at `indices` from gd.data array.
*
* @param {Object|HTMLDivElement} gd The graph div
* @param {Object[]} gd.data The array of traces we're removing from
* @param {Number|Number[]} indices The indices
*/
function deleteTraces(gd, indices) {
gd = Lib.getGraphDiv(gd);
var traces = [];
var undoFunc = exports.addTraces;
var redoFunc = deleteTraces;
var undoArgs = [gd, traces, indices];
var redoArgs = [gd, indices];
var i;
var deletedTrace;
// make sure indices are defined
if(typeof indices === 'undefined') {
throw new Error('indices must be an integer or array of integers.');
} else if(!Array.isArray(indices)) {
indices = [indices];
}
assertIndexArray(gd, indices, 'indices');
// convert negative indices to positive indices
indices = positivifyIndices(indices, gd.data.length - 1);
// we want descending here so that splicing later doesn't affect indexing
indices.sort(Lib.sorterDes);
for(i = 0; i < indices.length; i += 1) {
deletedTrace = gd.data.splice(indices[i], 1)[0];
traces.push(deletedTrace);
}
var promise = exports.redraw(gd);
Queue.add(gd, undoFunc, undoArgs, redoFunc, redoArgs);
return promise;
}
/**
* Move traces at currentIndices array to locations in newIndices array.
*
* If newIndices is omitted, currentIndices will be moved to the end. E.g.,
* these are equivalent:
*
* Plotly.moveTraces(gd, [1, 2, 3], [-3, -2, -1])
* Plotly.moveTraces(gd, [1, 2, 3])
*
* @param {Object|HTMLDivElement} gd The graph div
* @param {Object[]} gd.data The array of traces we're removing from
* @param {Number|Number[]} currentIndices The locations of traces to be moved
* @param {Number|Number[]} [newIndices] The locations to move traces to
*
* Example calls:
*
* // move trace i to location x
* Plotly.moveTraces(gd, i, x)
*
* // move trace i to end of array
* Plotly.moveTraces(gd, i)
*
* // move traces i, j, k to end of array (i != j != k)
* Plotly.moveTraces(gd, [i, j, k])
*
* // move traces [i, j, k] to [x, y, z] (i != j != k) (x != y != z)
* Plotly.moveTraces(gd, [i, j, k], [x, y, z])
*
* // reorder all traces (assume there are 5--a, b, c, d, e)
* Plotly.moveTraces(gd, [b, d, e, a, c]) // same as 'move to end'
*/
function moveTraces(gd, currentIndices, newIndices) {
gd = Lib.getGraphDiv(gd);
var newData = [];
var movingTraceMap = [];
var undoFunc = moveTraces;
var redoFunc = moveTraces;
var undoArgs = [gd, newIndices, currentIndices];
var redoArgs = [gd, currentIndices, newIndices];
var i;
// to reduce complexity here, check args elsewhere
// this throws errors where appropriate
checkMoveTracesArgs(gd, currentIndices, newIndices);
// make sure currentIndices is an array
currentIndices = Array.isArray(currentIndices) ? currentIndices : [currentIndices];
// if undefined, define newIndices to point to the end of gd.data array
if(typeof newIndices === 'undefined') {
newIndices = [];
for(i = 0; i < currentIndices.length; i++) {
newIndices.push(-currentIndices.length + i);
}
}
// make sure newIndices is an array if it's user-defined
newIndices = Array.isArray(newIndices) ? newIndices : [newIndices];
// convert negative indices to positive indices (they're the same length)
currentIndices = positivifyIndices(currentIndices, gd.data.length - 1);
newIndices = positivifyIndices(newIndices, gd.data.length - 1);
// at this point, we've coerced the index arrays into predictable forms
// get the traces that aren't being moved around
for(i = 0; i < gd.data.length; i++) {
// if index isn't in currentIndices, include it in ignored!
if(currentIndices.indexOf(i) === -1) {
newData.push(gd.data[i]);
}
}
// get a mapping of indices to moving traces
for(i = 0; i < currentIndices.length; i++) {
movingTraceMap.push({newIndex: newIndices[i], trace: gd.data[currentIndices[i]]});
}
// reorder this mapping by newIndex, ascending
movingTraceMap.sort(function(a, b) {
return a.newIndex - b.newIndex;
});
// now, add the moving traces back in, in order!
for(i = 0; i < movingTraceMap.length; i += 1) {
newData.splice(movingTraceMap[i].newIndex, 0, movingTraceMap[i].trace);
}
gd.data = newData;
var promise = exports.redraw(gd);
Queue.add(gd, undoFunc, undoArgs, redoFunc, redoArgs);
return promise;
}
/**
* restyle: update trace attributes of an existing plot
*
* Can be called two ways.
*
* Signature 1:
* @param {String | HTMLDivElement} gd
* the id or DOM element of the graph container div
* @param {String} astr
* attribute string (like `'marker.symbol'`) to update
* @param {*} val
* value to give this attribute
* @param {Number[] | Number} [traces]
* integer or array of integers for the traces to alter (all if omitted)
*
* Signature 2:
* @param {String | HTMLDivElement} gd
* (as in signature 1)
* @param {Object} aobj
* attribute object `{astr1: val1, astr2: val2 ...}`
* allows setting multiple attributes simultaneously
* @param {Number[] | Number} [traces]
* (as in signature 1)
*
* `val` (or `val1`, `val2` ... in the object form) can be an array,
* to apply different values to each trace.
*
* If the array is too short, it will wrap around (useful for
* style files that want to specify cyclical default values).
*/
function restyle(gd, astr, val, _traces) {
gd = Lib.getGraphDiv(gd);
helpers.clearPromiseQueue(gd);
var aobj = {};
if(typeof astr === 'string') aobj[astr] = val;
else if(Lib.isPlainObject(astr)) {
// the 3-arg form
aobj = Lib.extendFlat({}, astr);
if(_traces === undefined) _traces = val;
} else {
Lib.warn('Restyle fail.', astr, val, _traces);
return Promise.reject();
}
if(Object.keys(aobj).length) gd.changed = true;
var traces = helpers.coerceTraceIndices(gd, _traces);
var specs = _restyle(gd, aobj, traces);
var flags = specs.flags;
// clear calcdata and/or axis types if required so they get regenerated
if(flags.calc) gd.calcdata = undefined;
if(flags.clearAxisTypes) helpers.clearAxisTypes(gd, traces, {});
// fill in redraw sequence
var seq = [];
if(flags.fullReplot) {
seq.push(exports.plot);
} else {
seq.push(Plots.previousPromises);
// maybe only call Plots.supplyDataDefaults in the splom case,
// to skip over long and slow axes defaults
Plots.supplyDefaults(gd);
if(flags.markerSize) {
Plots.doCalcdata(gd);
addAxRangeSequence(seq);
// TODO
// if all axes have autorange:false, then
// proceed to subroutines.doTraceStyle(),
// otherwise we must go through addAxRangeSequence,
// which in general must redraws 'all' axes
}
if(flags.style) seq.push(subroutines.doTraceStyle);
if(flags.colorbars) seq.push(subroutines.doColorBars);
seq.push(emitAfterPlot);
}
seq.push(Plots.rehover, Plots.redrag);
Queue.add(gd,
restyle, [gd, specs.undoit, specs.traces],
restyle, [gd, specs.redoit, specs.traces]
);
var plotDone = Lib.syncOrAsync(seq, gd);
if(!plotDone || !plotDone.then) plotDone = Promise.resolve();
return plotDone.then(function() {
gd.emit('plotly_restyle', specs.eventData);
return gd;
});
}
// for undo: undefined initial vals must be turned into nulls
// so that we unset rather than ignore them
function undefinedToNull(val) {
if(val === undefined) return null;
return val;
}
/**
* Factory function to wrap nestedProperty with GUI edits if necessary
* with GUI edits we add an optional prefix to the nestedProperty constructor
* to prepend to the attribute string in the preGUI store.
*/
function makeNP(preGUI, guiEditFlag) {
if(!guiEditFlag) return nestedProperty;
return function(container, attr, prefix) {
var np = nestedProperty(container, attr);
var npSet = np.set;
np.set = function(val) {
var fullAttr = (prefix || '') + attr;
storeCurrent(fullAttr, np.get(), val, preGUI);
npSet(val);
};
return np;
};
}
function storeCurrent(attr, val, newVal, preGUI) {
if(Array.isArray(val) || Array.isArray(newVal)) {
var arrayVal = Array.isArray(val) ? val : [];
var arrayNew = Array.isArray(newVal) ? newVal : [];
var maxLen = Math.max(arrayVal.length, arrayNew.length);
for(var i = 0; i < maxLen; i++) {
storeCurrent(attr + '[' + i + ']', arrayVal[i], arrayNew[i], preGUI);
}
} else if(Lib.isPlainObject(val) || Lib.isPlainObject(newVal)) {
var objVal = Lib.isPlainObject(val) ? val : {};
var objNew = Lib.isPlainObject(newVal) ? newVal : {};
var objBoth = Lib.extendFlat({}, objVal, objNew);
for(var key in objBoth) {
storeCurrent(attr + '.' + key, objVal[key], objNew[key], preGUI);
}
} else if(preGUI[attr] === undefined) {
preGUI[attr] = undefinedToNull(val);
}
}
/**
* storeDirectGUIEdit: for routines that skip restyle/relayout and mock it
* by emitting a plotly_restyle or plotly_relayout event, this routine
* keeps track of the initial state in _preGUI for use by uirevision
* Does *not* apply these changes to data/layout - that's the responsibility
* of the calling routine.
*
* @param {object} container: the input attributes container (eg `layout` or a `trace`)
* @param {object} preGUI: where original values should be stored, either
* `layout._preGUI` or `layout._tracePreGUI[uid]`
* @param {object} edits: the {attr: val} object as normally passed to `relayout` etc
*/
function _storeDirectGUIEdit(contai