UNPKG

plotly.js

Version:

The open source javascript graphing library that powers plotly

733 lines (629 loc) 26.5 kB
/** * 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 tinycolor = require('tinycolor2'); var Plots = require('../../plots/plots'); var Registry = require('../../registry'); var Axes = require('../../plots/cartesian/axes'); var dragElement = require('../dragelement'); var Lib = require('../../lib'); var strTranslate = Lib.strTranslate; var extendFlat = require('../../lib/extend').extendFlat; var setCursor = require('../../lib/setcursor'); var Drawing = require('../drawing'); var Color = require('../color'); var Titles = require('../titles'); var svgTextUtils = require('../../lib/svg_text_utils'); var flipScale = require('../colorscale/helpers').flipScale; var handleAxisDefaults = require('../../plots/cartesian/axis_defaults'); var handleAxisPositionDefaults = require('../../plots/cartesian/position_defaults'); var axisLayoutAttrs = require('../../plots/cartesian/layout_attributes'); var alignmentConstants = require('../../constants/alignment'); var LINE_SPACING = alignmentConstants.LINE_SPACING; var FROM_TL = alignmentConstants.FROM_TL; var FROM_BR = alignmentConstants.FROM_BR; var cn = require('./constants').cn; function draw(gd) { var fullLayout = gd._fullLayout; var colorBars = fullLayout._infolayer .selectAll('g.' + cn.colorbar) .data(makeColorBarData(gd), function(opts) { return opts._id; }); colorBars.enter().append('g') .attr('class', function(opts) { return opts._id; }) .classed(cn.colorbar, true); colorBars.each(function(opts) { var g = d3.select(this); Lib.ensureSingle(g, 'rect', cn.cbbg); Lib.ensureSingle(g, 'g', cn.cbfills); Lib.ensureSingle(g, 'g', cn.cblines); Lib.ensureSingle(g, 'g', cn.cbaxis, function(s) { s.classed(cn.crisp, true); }); Lib.ensureSingle(g, 'g', cn.cbtitleunshift, function(s) { s.append('g').classed(cn.cbtitle, true); }); Lib.ensureSingle(g, 'rect', cn.cboutline); var done = drawColorBar(g, opts, gd); if(done && done.then) (gd._promises || []).push(done); if(gd._context.edits.colorbarPosition) { makeEditable(g, opts, gd); } }); colorBars.exit() .each(function(opts) { Plots.autoMargin(gd, opts._id); }) .remove(); colorBars.order(); } function makeColorBarData(gd) { var fullLayout = gd._fullLayout; var calcdata = gd.calcdata; var out = []; // single out item var opts; // colorbar attr parent container var cont; // trace attr container var trace; // colorbar options var cbOpt; function initOpts(opts) { return extendFlat(opts, { // fillcolor can be a d3 scale, domain is z values, range is colors // or leave it out for no fill, // or set to a string constant for single-color fill _fillcolor: null, // line.color has the same options as fillcolor _line: {color: null, width: null, dash: null}, // levels of lines to draw. // note that this DOES NOT determine the extent of the bar // that's given by the domain of fillcolor // (or line.color if no fillcolor domain) _levels: {start: null, end: null, size: null}, // separate fill levels (for example, heatmap coloring of a // contour map) if this is omitted, fillcolors will be // evaluated halfway between levels _filllevels: null, // for continuous colorscales: fill with a gradient instead of explicit levels // value should be the colorscale [[0, c0], [v1, c1], ..., [1, cEnd]] _fillgradient: null, // when using a gradient, we need the data range specified separately _zrange: null }); } function calcOpts() { if(typeof cbOpt.calc === 'function') { cbOpt.calc(gd, trace, opts); } else { opts._fillgradient = cont.reversescale ? flipScale(cont.colorscale) : cont.colorscale; opts._zrange = [cont[cbOpt.min], cont[cbOpt.max]]; } } for(var i = 0; i < calcdata.length; i++) { var cd = calcdata[i]; trace = cd[0].trace; var moduleOpts = trace._module.colorbar; if(trace.visible === true && moduleOpts) { var allowsMultiplotCbs = Array.isArray(moduleOpts); var cbOpts = allowsMultiplotCbs ? moduleOpts : [moduleOpts]; for(var j = 0; j < cbOpts.length; j++) { cbOpt = cbOpts[j]; var contName = cbOpt.container; cont = contName ? trace[contName] : trace; if(cont && cont.showscale) { opts = initOpts(cont.colorbar); opts._id = 'cb' + trace.uid + (allowsMultiplotCbs && contName ? '-' + contName : ''); opts._traceIndex = trace.index; opts._propPrefix = (contName ? contName + '.' : '') + 'colorbar.'; opts._meta = trace._meta; calcOpts(); out.push(opts); } } } } for(var k in fullLayout._colorAxes) { cont = fullLayout[k]; if(cont.showscale) { var colorAxOpts = fullLayout._colorAxes[k]; opts = initOpts(cont.colorbar); opts._id = 'cb' + k; opts._propPrefix = k + '.colorbar.'; opts._meta = fullLayout._meta; cbOpt = {min: 'cmin', max: 'cmax'}; if(colorAxOpts[0] !== 'heatmap') { trace = colorAxOpts[1]; cbOpt.calc = trace._module.colorbar.calc; } calcOpts(); out.push(opts); } } return out; } function drawColorBar(g, opts, gd) { var fullLayout = gd._fullLayout; var gs = fullLayout._size; var fillColor = opts._fillcolor; var line = opts._line; var title = opts.title; var titleSide = title.side; var zrange = opts._zrange || d3.extent((typeof fillColor === 'function' ? fillColor : line.color).domain()); var lineColormap = typeof line.color === 'function' ? line.color : function() { return line.color; }; var fillColormap = typeof fillColor === 'function' ? fillColor : function() { return fillColor; }; var levelsIn = opts._levels; var levelsOut = calcLevels(gd, opts, zrange); var fillLevels = levelsOut.fill; var lineLevels = levelsOut.line; // we calculate pixel sizes based on the specified graph size, // not the actual (in case something pushed the margins around) // which is a little odd but avoids an odd iterative effect // when the colorbar itself is pushing the margins. // but then the fractional size is calculated based on the // actual graph size, so that the axes will size correctly. var thickPx = Math.round(opts.thickness * (opts.thicknessmode === 'fraction' ? gs.w : 1)); var thickFrac = thickPx / gs.w; var lenPx = Math.round(opts.len * (opts.lenmode === 'fraction' ? gs.h : 1)); var lenFrac = lenPx / gs.h; var xpadFrac = opts.xpad / gs.w; var yExtraPx = (opts.borderwidth + opts.outlinewidth) / 2; var ypadFrac = opts.ypad / gs.h; // x positioning: do it initially just for left anchor, // then fix at the end (since we don't know the width yet) var xLeft = Math.round(opts.x * gs.w + opts.xpad); // for dragging... this is getting a little muddled... var xLeftFrac = opts.x - thickFrac * ({middle: 0.5, right: 1}[opts.xanchor] || 0); // y positioning we can do correctly from the start var yBottomFrac = opts.y + lenFrac * (({top: -0.5, bottom: 0.5}[opts.yanchor] || 0) - 0.5); var yBottomPx = Math.round(gs.h * (1 - yBottomFrac)); var yTopPx = yBottomPx - lenPx; // stash a few things for makeEditable opts._lenFrac = lenFrac; opts._thickFrac = thickFrac; opts._xLeftFrac = xLeftFrac; opts._yBottomFrac = yBottomFrac; // stash mocked axis for contour label formatting var ax = opts._axis = mockColorBarAxis(gd, opts, zrange); // position can't go in through supplyDefaults // because that restricts it to [0,1] ax.position = opts.x + xpadFrac + thickFrac; if(['top', 'bottom'].indexOf(titleSide) !== -1) { ax.title.side = titleSide; ax.titlex = opts.x + xpadFrac; ax.titley = yBottomFrac + (title.side === 'top' ? lenFrac - ypadFrac : ypadFrac); } if(line.color && opts.tickmode === 'auto') { ax.tickmode = 'linear'; ax.tick0 = levelsIn.start; var dtick = levelsIn.size; // expand if too many contours, so we don't get too many ticks var autoNtick = Lib.constrain((yBottomPx - yTopPx) / 50, 4, 15) + 1; var dtFactor = (zrange[1] - zrange[0]) / ((opts.nticks || autoNtick) * dtick); if(dtFactor > 1) { var dtexp = Math.pow(10, Math.floor(Math.log(dtFactor) / Math.LN10)); dtick *= dtexp * Lib.roundUp(dtFactor / dtexp, [2, 5, 10]); // if the contours are at round multiples, reset tick0 // so they're still at round multiples. Otherwise, // keep the first label on the first contour level if((Math.abs(levelsIn.start) / levelsIn.size + 1e-6) % 1 < 2e-6) { ax.tick0 = 0; } } ax.dtick = dtick; } // set domain after init, because we may want to // allow it outside [0,1] ax.domain = [ yBottomFrac + ypadFrac, yBottomFrac + lenFrac - ypadFrac ]; ax.setScale(); g.attr('transform', strTranslate(Math.round(gs.l), Math.round(gs.t))); var titleCont = g.select('.' + cn.cbtitleunshift) .attr('transform', strTranslate(-Math.round(gs.l), -Math.round(gs.t))); var axLayer = g.select('.' + cn.cbaxis); var titleEl; var titleHeight = 0; function drawTitle(titleClass, titleOpts) { var dfltTitleOpts = { propContainer: ax, propName: opts._propPrefix + 'title', traceIndex: opts._traceIndex, _meta: opts._meta, placeholder: fullLayout._dfltTitle.colorbar, containerGroup: g.select('.' + cn.cbtitle) }; // this class-to-rotate thing with convertToTspans is // getting hackier and hackier... delete groups with the // wrong class (in case earlier the colorbar was drawn on // a different side, I think?) var otherClass = titleClass.charAt(0) === 'h' ? titleClass.substr(1) : 'h' + titleClass; g.selectAll('.' + otherClass + ',.' + otherClass + '-math-group').remove(); Titles.draw(gd, titleClass, extendFlat(dfltTitleOpts, titleOpts || {})); } function drawDummyTitle() { if(['top', 'bottom'].indexOf(titleSide) !== -1) { // draw the title so we know how much room it needs // when we squish the axis. This one only applies to // top or bottom titles, not right side. var x = gs.l + (opts.x + xpadFrac) * gs.w; var fontSize = ax.title.font.size; var y; if(titleSide === 'top') { y = (1 - (yBottomFrac + lenFrac - ypadFrac)) * gs.h + gs.t + 3 + fontSize * 0.75; } else { y = (1 - (yBottomFrac + ypadFrac)) * gs.h + gs.t - 3 - fontSize * 0.25; } drawTitle(ax._id + 'title', { attributes: {x: x, y: y, 'text-anchor': 'start'} }); } } function drawCbTitle() { if(['top', 'bottom'].indexOf(titleSide) === -1) { var fontSize = ax.title.font.size; var y = ax._offset + ax._length / 2; var x = gs.l + (ax.position || 0) * gs.w + ((ax.side === 'right') ? 10 + fontSize * ((ax.showticklabels ? 1 : 0.5)) : -10 - fontSize * ((ax.showticklabels ? 0.5 : 0))); // the 'h' + is a hack to get around the fact that // convertToTspans rotates any 'y...' class by 90 degrees. // TODO: find a better way to control this. drawTitle('h' + ax._id + 'title', { avoid: { selection: d3.select(gd).selectAll('g.' + ax._id + 'tick'), side: titleSide, offsetLeft: gs.l, offsetTop: 0, maxShift: fullLayout.width }, attributes: {x: x, y: y, 'text-anchor': 'middle'}, transform: {rotate: '-90', offset: 0} }); } } function drawAxis() { if(['top', 'bottom'].indexOf(titleSide) !== -1) { // squish the axis top to make room for the title var titleGroup = g.select('.' + cn.cbtitle); var titleText = titleGroup.select('text'); var titleTrans = [-opts.outlinewidth / 2, opts.outlinewidth / 2]; var mathJaxNode = titleGroup .select('.h' + ax._id + 'title-math-group') .node(); var lineSize = 15.6; if(titleText.node()) { lineSize = parseInt(titleText.node().style.fontSize, 10) * LINE_SPACING; } if(mathJaxNode) { titleHeight = Drawing.bBox(mathJaxNode).height; if(titleHeight > lineSize) { // not entirely sure how mathjax is doing // vertical alignment, but this seems to work. titleTrans[1] -= (titleHeight - lineSize) / 2; } } else if(titleText.node() && !titleText.classed(cn.jsPlaceholder)) { titleHeight = Drawing.bBox(titleText.node()).height; } if(titleHeight) { // buffer btwn colorbar and title // TODO: configurable titleHeight += 5; if(titleSide === 'top') { ax.domain[1] -= titleHeight / gs.h; titleTrans[1] *= -1; } else { ax.domain[0] += titleHeight / gs.h; var nlines = svgTextUtils.lineCount(titleText); titleTrans[1] += (1 - nlines) * lineSize; } titleGroup.attr('transform', strTranslate(titleTrans[0], titleTrans[1])); ax.setScale(); } } g.selectAll('.' + cn.cbfills + ',.' + cn.cblines) .attr('transform', strTranslate(0, Math.round(gs.h * (1 - ax.domain[1])))); axLayer.attr('transform', strTranslate(0, Math.round(-gs.t))); var fills = g.select('.' + cn.cbfills) .selectAll('rect.' + cn.cbfill) .attr('style', '') .data(fillLevels); fills.enter().append('rect') .classed(cn.cbfill, true) .style('stroke', 'none'); fills.exit().remove(); var zBounds = zrange .map(ax.c2p) .map(Math.round) .sort(function(a, b) { return a - b; }); fills.each(function(d, i) { var z = [ (i === 0) ? zrange[0] : (fillLevels[i] + fillLevels[i - 1]) / 2, (i === fillLevels.length - 1) ? zrange[1] : (fillLevels[i] + fillLevels[i + 1]) / 2 ] .map(ax.c2p) .map(Math.round); // offset the side adjoining the next rectangle so they // overlap, to prevent antialiasing gaps z[1] = Lib.constrain(z[1] + (z[1] > z[0]) ? 1 : -1, zBounds[0], zBounds[1]); // Colorbar cannot currently support opacities so we // use an opaque fill even when alpha channels present var fillEl = d3.select(this).attr({ x: xLeft, width: Math.max(thickPx, 2), y: d3.min(z), height: Math.max(d3.max(z) - d3.min(z), 2), }); if(opts._fillgradient) { Drawing.gradient(fillEl, gd, opts._id, 'vertical', opts._fillgradient, 'fill'); } else { // tinycolor can't handle exponents and // at this scale, removing it makes no difference. var colorString = fillColormap(d).replace('e-', ''); fillEl.attr('fill', tinycolor(colorString).toHexString()); } }); var lines = g.select('.' + cn.cblines) .selectAll('path.' + cn.cbline) .data(line.color && line.width ? lineLevels : []); lines.enter().append('path') .classed(cn.cbline, true); lines.exit().remove(); lines.each(function(d) { d3.select(this) .attr('d', 'M' + xLeft + ',' + (Math.round(ax.c2p(d)) + (line.width / 2) % 1) + 'h' + thickPx) .call(Drawing.lineGroupStyle, line.width, lineColormap(d), line.dash); }); // force full redraw of labels and ticks axLayer.selectAll('g.' + ax._id + 'tick,path').remove(); var shift = xLeft + thickPx + (opts.outlinewidth || 0) / 2 - (opts.ticks === 'outside' ? 1 : 0); var vals = Axes.calcTicks(ax); var tickSign = Axes.getTickSigns(ax)[2]; Axes.drawTicks(gd, ax, { vals: ax.ticks === 'inside' ? Axes.clipEnds(ax, vals) : vals, layer: axLayer, path: Axes.makeTickPath(ax, shift, tickSign), transFn: Axes.makeTransTickFn(ax) }); return Axes.drawLabels(gd, ax, { vals: vals, layer: axLayer, transFn: Axes.makeTransTickLabelFn(ax), labelFns: Axes.makeLabelFns(ax, shift) }); } // wait for the axis & title to finish rendering before // continuing positioning // TODO: why are we redrawing multiple times now with this? // I guess autoMargin doesn't like being post-promise? function positionCB() { var innerWidth = thickPx + opts.outlinewidth / 2; if(ax.ticklabelposition.indexOf('inside') === -1) { innerWidth += Drawing.bBox(axLayer.node()).width; } titleEl = titleCont.select('text'); if(titleEl.node() && !titleEl.classed(cn.jsPlaceholder)) { var mathJaxNode = titleCont.select('.h' + ax._id + 'title-math-group').node(); var titleWidth; if(mathJaxNode && ['top', 'bottom'].indexOf(titleSide) !== -1) { titleWidth = Drawing.bBox(mathJaxNode).width; } else { // note: the formula below works for all title sides, // (except for top/bottom mathjax, above) // but the weird gs.l is because the titleunshift // transform gets removed by Drawing.bBox titleWidth = Drawing.bBox(titleCont.node()).right - xLeft - gs.l; } innerWidth = Math.max(innerWidth, titleWidth); } var outerwidth = 2 * opts.xpad + innerWidth + opts.borderwidth + opts.outlinewidth / 2; var outerheight = yBottomPx - yTopPx; g.select('.' + cn.cbbg).attr({ x: xLeft - opts.xpad - (opts.borderwidth + opts.outlinewidth) / 2, y: yTopPx - yExtraPx, width: Math.max(outerwidth, 2), height: Math.max(outerheight + 2 * yExtraPx, 2) }) .call(Color.fill, opts.bgcolor) .call(Color.stroke, opts.bordercolor) .style('stroke-width', opts.borderwidth); g.selectAll('.' + cn.cboutline).attr({ x: xLeft, y: yTopPx + opts.ypad + (titleSide === 'top' ? titleHeight : 0), width: Math.max(thickPx, 2), height: Math.max(outerheight - 2 * opts.ypad - titleHeight, 2) }) .call(Color.stroke, opts.outlinecolor) .style({ fill: 'none', 'stroke-width': opts.outlinewidth }); // fix positioning for xanchor!='left' var xoffset = ({center: 0.5, right: 1}[opts.xanchor] || 0) * outerwidth; g.attr('transform', strTranslate(gs.l - xoffset, gs.t)); // auto margin adjustment var marginOpts = {}; var tFrac = FROM_TL[opts.yanchor]; var bFrac = FROM_BR[opts.yanchor]; if(opts.lenmode === 'pixels') { marginOpts.y = opts.y; marginOpts.t = outerheight * tFrac; marginOpts.b = outerheight * bFrac; } else { marginOpts.t = marginOpts.b = 0; marginOpts.yt = opts.y + opts.len * tFrac; marginOpts.yb = opts.y - opts.len * bFrac; } var lFrac = FROM_TL[opts.xanchor]; var rFrac = FROM_BR[opts.xanchor]; if(opts.thicknessmode === 'pixels') { marginOpts.x = opts.x; marginOpts.l = outerwidth * lFrac; marginOpts.r = outerwidth * rFrac; } else { var extraThickness = outerwidth - thickPx; marginOpts.l = extraThickness * lFrac; marginOpts.r = extraThickness * rFrac; marginOpts.xl = opts.x - opts.thickness * lFrac; marginOpts.xr = opts.x + opts.thickness * rFrac; } Plots.autoMargin(gd, opts._id, marginOpts); } return Lib.syncOrAsync([ Plots.previousPromises, drawDummyTitle, drawAxis, drawCbTitle, Plots.previousPromises, positionCB ], gd); } function makeEditable(g, opts, gd) { var fullLayout = gd._fullLayout; var gs = fullLayout._size; var t0, xf, yf; dragElement.init({ element: g.node(), gd: gd, prepFn: function() { t0 = g.attr('transform'); setCursor(g); }, moveFn: function(dx, dy) { g.attr('transform', t0 + strTranslate(dx, dy)); xf = dragElement.align(opts._xLeftFrac + (dx / gs.w), opts._thickFrac, 0, 1, opts.xanchor); yf = dragElement.align(opts._yBottomFrac - (dy / gs.h), opts._lenFrac, 0, 1, opts.yanchor); var csr = dragElement.getCursor(xf, yf, opts.xanchor, opts.yanchor); setCursor(g, csr); }, doneFn: function() { setCursor(g); if(xf !== undefined && yf !== undefined) { var update = {}; update[opts._propPrefix + 'x'] = xf; update[opts._propPrefix + 'y'] = yf; if(opts._traceIndex !== undefined) { Registry.call('_guiRestyle', gd, update, opts._traceIndex); } else { Registry.call('_guiRelayout', gd, update); } } } }); } function calcLevels(gd, opts, zrange) { var levelsIn = opts._levels; var lineLevels = []; var fillLevels = []; var l; var i; var l0 = levelsIn.end + levelsIn.size / 100; var ls = levelsIn.size; var zr0 = (1.001 * zrange[0] - 0.001 * zrange[1]); var zr1 = (1.001 * zrange[1] - 0.001 * zrange[0]); for(i = 0; i < 1e5; i++) { l = levelsIn.start + i * ls; if(ls > 0 ? (l >= l0) : (l <= l0)) break; if(l > zr0 && l < zr1) lineLevels.push(l); } if(opts._fillgradient) { fillLevels = [0]; } else if(typeof opts._fillcolor === 'function') { var fillLevelsIn = opts._filllevels; if(fillLevelsIn) { l0 = fillLevelsIn.end + fillLevelsIn.size / 100; ls = fillLevelsIn.size; for(i = 0; i < 1e5; i++) { l = fillLevelsIn.start + i * ls; if(ls > 0 ? (l >= l0) : (l <= l0)) break; if(l > zrange[0] && l < zrange[1]) fillLevels.push(l); } } else { fillLevels = lineLevels.map(function(v) { return v - levelsIn.size / 2; }); fillLevels.push(fillLevels[fillLevels.length - 1] + levelsIn.size); } } else if(opts._fillcolor && typeof opts._fillcolor === 'string') { // doesn't matter what this value is, with a single value // we'll make a single fill rect covering the whole bar fillLevels = [0]; } if(levelsIn.size < 0) { lineLevels.reverse(); fillLevels.reverse(); } return {line: lineLevels, fill: fillLevels}; } function mockColorBarAxis(gd, opts, zrange) { var fullLayout = gd._fullLayout; var cbAxisIn = { type: 'linear', range: zrange, tickmode: opts.tickmode, nticks: opts.nticks, tick0: opts.tick0, dtick: opts.dtick, tickvals: opts.tickvals, ticktext: opts.ticktext, ticks: opts.ticks, ticklen: opts.ticklen, tickwidth: opts.tickwidth, tickcolor: opts.tickcolor, showticklabels: opts.showticklabels, ticklabelposition: opts.ticklabelposition, tickfont: opts.tickfont, tickangle: opts.tickangle, tickformat: opts.tickformat, exponentformat: opts.exponentformat, minexponent: opts.minexponent, separatethousands: opts.separatethousands, showexponent: opts.showexponent, showtickprefix: opts.showtickprefix, tickprefix: opts.tickprefix, showticksuffix: opts.showticksuffix, ticksuffix: opts.ticksuffix, title: opts.title, showline: true, anchor: 'free', side: 'right', position: 1 }; var cbAxisOut = { type: 'linear', _id: 'y' + opts._id }; var axisOptions = { letter: 'y', font: fullLayout.font, noHover: true, noTickson: true, noTicklabelmode: true, calendar: fullLayout.calendar // not really necessary (yet?) }; function coerce(attr, dflt) { return Lib.coerce(cbAxisIn, cbAxisOut, axisLayoutAttrs, attr, dflt); } handleAxisDefaults(cbAxisIn, cbAxisOut, coerce, axisOptions, fullLayout); handleAxisPositionDefaults(cbAxisIn, cbAxisOut, coerce, axisOptions); return cbAxisOut; } module.exports = { draw: draw };