'use strict';
var d3 = require('@plotly/d3');
var Plots = require('../../plots/plots');
var Color = require('../color');
var Drawing = require('../drawing');
var Lib = require('../../lib');
var svgTextUtils = require('../../lib/svg_text_utils');
var arrayEditor = require('../../plot_api/plot_template').arrayEditor;
var LINE_SPACING = require('../../constants/alignment').LINE_SPACING;
var constants = require('./constants');
var ScrollBox = require('./scrollbox');
module.exports = function draw(gd) {
var fullLayout = gd._fullLayout;
var menuData = Lib.filterVisible(fullLayout[constants.name]);
/* Update menu data is bound to the header-group.
* The items in the header group are always present.
*
* Upon clicking on a header its corresponding button
* data is bound to the button-group.
*
* We draw all headers in one group before all buttons
* so that the buttons *always* appear above the headers.
*
* Note that only one set of buttons are visible at once.
*
*
*
*
*
*
*
*
*
* ...
*
*
*
*
* ...
*/
function clearAutoMargin(menuOpts) {
Plots.autoMargin(gd, autoMarginId(menuOpts));
}
// draw update menu container
var menus = fullLayout._menulayer
.selectAll('g.' + constants.containerClassName)
.data(menuData.length > 0 ? [0] : []);
menus.enter().append('g')
.classed(constants.containerClassName, true)
.style('cursor', 'pointer');
menus.exit().each(function() {
// Most components don't need to explicitly remove autoMargin, because
// marginPushers does this - but updatemenu updates don't go through
// a full replot so we need to explicitly remove it.
// This is for removing *all* updatemenus, removing individuals is
// handled below, in headerGroups.exit
d3.select(this).selectAll('g.' + constants.headerGroupClassName)
.each(clearAutoMargin);
}).remove();
// return early if no update menus are visible
if(menuData.length === 0) return;
// join header group
var headerGroups = menus.selectAll('g.' + constants.headerGroupClassName)
.data(menuData, keyFunction);
headerGroups.enter().append('g')
.classed(constants.headerGroupClassName, true);
// draw dropdown button container
var gButton = Lib.ensureSingle(menus, 'g', constants.dropdownButtonGroupClassName, function(s) {
s.style('pointer-events', 'all');
});
// find dimensions before plotting anything (this mutates menuOpts)
for(var i = 0; i < menuData.length; i++) {
var menuOpts = menuData[i];
findDimensions(gd, menuOpts);
}
// setup scrollbox
var scrollBoxId = 'updatemenus' + fullLayout._uid;
var scrollBox = new ScrollBox(gd, gButton, scrollBoxId);
// remove exiting header, remove dropped buttons and reset margins
if(headerGroups.enter().size()) {
// make sure gButton is on top of all headers
gButton.node().parentNode.appendChild(gButton.node());
gButton.call(removeAllButtons);
}
headerGroups.exit().each(function(menuOpts) {
gButton.call(removeAllButtons);
clearAutoMargin(menuOpts);
}).remove();
// draw headers!
headerGroups.each(function(menuOpts) {
var gHeader = d3.select(this);
var _gButton = menuOpts.type === 'dropdown' ? gButton : null;
Plots.manageCommandObserver(gd, menuOpts, menuOpts.buttons, function(data) {
setActive(gd, menuOpts, menuOpts.buttons[data.index], gHeader, _gButton, scrollBox, data.index, true);
});
if(menuOpts.type === 'dropdown') {
drawHeader(gd, gHeader, gButton, scrollBox, menuOpts);
// if this menu is active, update the dropdown container
if(isActive(gButton, menuOpts)) {
drawButtons(gd, gHeader, gButton, scrollBox, menuOpts);
}
} else {
drawButtons(gd, gHeader, null, null, menuOpts);
}
});
};
// Note that '_index' is set at the default step,
// it corresponds to the menu index in the user layout update menu container.
// Because a menu can be set invisible,
// this is a more 'consistent' field than the index in the menuData.
function keyFunction(menuOpts) {
return menuOpts._index;
}
function isFolded(gButton) {
return +gButton.attr(constants.menuIndexAttrName) === -1;
}
function isActive(gButton, menuOpts) {
return +gButton.attr(constants.menuIndexAttrName) === menuOpts._index;
}
function setActive(gd, menuOpts, buttonOpts, gHeader, gButton, scrollBox, buttonIndex, isSilentUpdate) {
// update 'active' attribute in menuOpts
menuOpts.active = buttonIndex;
// due to templating, it's possible this slider doesn't even exist yet
arrayEditor(gd.layout, constants.name, menuOpts)
.applyUpdate('active', buttonIndex);
if(menuOpts.type === 'buttons') {
drawButtons(gd, gHeader, null, null, menuOpts);
} else if(menuOpts.type === 'dropdown') {
// fold up buttons and redraw header
gButton.attr(constants.menuIndexAttrName, '-1');
drawHeader(gd, gHeader, gButton, scrollBox, menuOpts);
if(!isSilentUpdate) {
drawButtons(gd, gHeader, gButton, scrollBox, menuOpts);
}
}
}
function drawHeader(gd, gHeader, gButton, scrollBox, menuOpts) {
var header = Lib.ensureSingle(gHeader, 'g', constants.headerClassName, function(s) {
s.style('pointer-events', 'all');
});
var dims = menuOpts._dims;
var active = menuOpts.active;
var headerOpts = menuOpts.buttons[active] || constants.blankHeaderOpts;
var posOpts = { y: menuOpts.pad.t, yPad: 0, x: menuOpts.pad.l, xPad: 0, index: 0 };
var positionOverrides = {
width: dims.headerWidth,
height: dims.headerHeight
};
header
.call(drawItem, menuOpts, headerOpts, gd)
.call(setItemPosition, menuOpts, posOpts, positionOverrides);
// draw drop arrow at the right edge
var arrow = Lib.ensureSingle(gHeader, 'text', constants.headerArrowClassName, function(s) {
s.attr('text-anchor', 'end')
.call(Drawing.font, menuOpts.font)
.text(constants.arrowSymbol[menuOpts.direction]);
});
arrow.attr({
x: dims.headerWidth - constants.arrowOffsetX + menuOpts.pad.l,
y: dims.headerHeight / 2 + constants.textOffsetY + menuOpts.pad.t
});
header.on('click', function() {
gButton.call(removeAllButtons,
String(isActive(gButton, menuOpts) ? -1 : menuOpts._index)
);
drawButtons(gd, gHeader, gButton, scrollBox, menuOpts);
});
header.on('mouseover', function() {
header.call(styleOnMouseOver);
});
header.on('mouseout', function() {
header.call(styleOnMouseOut, menuOpts);
});
// translate header group
Drawing.setTranslate(gHeader, dims.lx, dims.ly);
}
function drawButtons(gd, gHeader, gButton, scrollBox, menuOpts) {
// If this is a set of buttons, set pointer events = all since we play
// some minor games with which container is which in order to simplify
// the drawing of *either* buttons or menus
if(!gButton) {
gButton = gHeader;
gButton.attr('pointer-events', 'all');
}
var buttonData = (!isFolded(gButton) || menuOpts.type === 'buttons') ?
menuOpts.buttons :
[];
var klass = menuOpts.type === 'dropdown' ? constants.dropdownButtonClassName : constants.buttonClassName;
var buttons = gButton.selectAll('g.' + klass)
.data(Lib.filterVisible(buttonData));
var enter = buttons.enter().append('g')
.classed(klass, true);
var exit = buttons.exit();
if(menuOpts.type === 'dropdown') {
enter.attr('opacity', '0')
.transition()
.attr('opacity', '1');
exit.transition()
.attr('opacity', '0')
.remove();
} else {
exit.remove();
}
var x0 = 0;
var y0 = 0;
var dims = menuOpts._dims;
var isVertical = ['up', 'down'].indexOf(menuOpts.direction) !== -1;
if(menuOpts.type === 'dropdown') {
if(isVertical) {
y0 = dims.headerHeight + constants.gapButtonHeader;
} else {
x0 = dims.headerWidth + constants.gapButtonHeader;
}
}
if(menuOpts.type === 'dropdown' && menuOpts.direction === 'up') {
y0 = -constants.gapButtonHeader + constants.gapButton - dims.openHeight;
}
if(menuOpts.type === 'dropdown' && menuOpts.direction === 'left') {
x0 = -constants.gapButtonHeader + constants.gapButton - dims.openWidth;
}
var posOpts = {
x: dims.lx + x0 + menuOpts.pad.l,
y: dims.ly + y0 + menuOpts.pad.t,
yPad: constants.gapButton,
xPad: constants.gapButton,
index: 0,
};
var scrollBoxPosition = {
l: posOpts.x + menuOpts.borderwidth,
t: posOpts.y + menuOpts.borderwidth
};
buttons.each(function(buttonOpts, buttonIndex) {
var button = d3.select(this);
button
.call(drawItem, menuOpts, buttonOpts, gd)
.call(setItemPosition, menuOpts, posOpts);
button.on('click', function() {
// skip `dragend` events
if(d3.event.defaultPrevented) return;
if(buttonOpts.execute) {
if(buttonOpts.args2 && menuOpts.active === buttonIndex) {
setActive(gd, menuOpts, buttonOpts, gHeader, gButton, scrollBox, -1);
Plots.executeAPICommand(gd, buttonOpts.method, buttonOpts.args2);
} else {
setActive(gd, menuOpts, buttonOpts, gHeader, gButton, scrollBox, buttonIndex);
Plots.executeAPICommand(gd, buttonOpts.method, buttonOpts.args);
}
}
gd.emit('plotly_buttonclicked', {menu: menuOpts, button: buttonOpts, active: menuOpts.active});
});
button.on('mouseover', function() {
button.call(styleOnMouseOver);
});
button.on('mouseout', function() {
button.call(styleOnMouseOut, menuOpts);
buttons.call(styleButtons, menuOpts);
});
});
buttons.call(styleButtons, menuOpts);
if(isVertical) {
scrollBoxPosition.w = Math.max(dims.openWidth, dims.headerWidth);
scrollBoxPosition.h = posOpts.y - scrollBoxPosition.t;
} else {
scrollBoxPosition.w = posOpts.x - scrollBoxPosition.l;
scrollBoxPosition.h = Math.max(dims.openHeight, dims.headerHeight);
}
scrollBoxPosition.direction = menuOpts.direction;
if(scrollBox) {
if(buttons.size()) {
drawScrollBox(gd, gHeader, gButton, scrollBox, menuOpts, scrollBoxPosition);
} else {
hideScrollBox(scrollBox);
}
}
}
function drawScrollBox(gd, gHeader, gButton, scrollBox, menuOpts, position) {
// enable the scrollbox
var direction = menuOpts.direction;
var isVertical = (direction === 'up' || direction === 'down');
var dims = menuOpts._dims;
var active = menuOpts.active;
var translateX, translateY;
var i;
if(isVertical) {
translateY = 0;
for(i = 0; i < active; i++) {
translateY += dims.heights[i] + constants.gapButton;
}
} else {
translateX = 0;
for(i = 0; i < active; i++) {
translateX += dims.widths[i] + constants.gapButton;
}
}
scrollBox.enable(position, translateX, translateY);
if(scrollBox.hbar) {
scrollBox.hbar
.attr('opacity', '0')
.transition()
.attr('opacity', '1');
}
if(scrollBox.vbar) {
scrollBox.vbar
.attr('opacity', '0')
.transition()
.attr('opacity', '1');
}
}
function hideScrollBox(scrollBox) {
var hasHBar = !!scrollBox.hbar;
var hasVBar = !!scrollBox.vbar;
if(hasHBar) {
scrollBox.hbar
.transition()
.attr('opacity', '0')
.each('end', function() {
hasHBar = false;
if(!hasVBar) scrollBox.disable();
});
}
if(hasVBar) {
scrollBox.vbar
.transition()
.attr('opacity', '0')
.each('end', function() {
hasVBar = false;
if(!hasHBar) scrollBox.disable();
});
}
}
function drawItem(item, menuOpts, itemOpts, gd) {
item.call(drawItemRect, menuOpts)
.call(drawItemText, menuOpts, itemOpts, gd);
}
function drawItemRect(item, menuOpts) {
var rect = Lib.ensureSingle(item, 'rect', constants.itemRectClassName, function(s) {
s.attr({
rx: constants.rx,
ry: constants.ry,
'shape-rendering': 'crispEdges'
});
});
rect.call(Color.stroke, menuOpts.bordercolor)
.call(Color.fill, menuOpts.bgcolor)
.style('stroke-width', menuOpts.borderwidth + 'px');
}
function drawItemText(item, menuOpts, itemOpts, gd) {
var text = Lib.ensureSingle(item, 'text', constants.itemTextClassName, function(s) {
s.attr({
'text-anchor': 'start',
'data-notex': 1
});
});
var tx = itemOpts.label;
var _meta = gd._fullLayout._meta;
if(_meta) tx = Lib.templateString(tx, _meta);
text.call(Drawing.font, menuOpts.font)
.text(tx)
.call(svgTextUtils.convertToTspans, gd);
}
function styleButtons(buttons, menuOpts) {
var active = menuOpts.active;
buttons.each(function(buttonOpts, i) {
var button = d3.select(this);
if(i === active && menuOpts.showactive) {
button.select('rect.' + constants.itemRectClassName)
.call(Color.fill, constants.activeColor);
}
});
}
function styleOnMouseOver(item) {
item.select('rect.' + constants.itemRectClassName)
.call(Color.fill, constants.hoverColor);
}
function styleOnMouseOut(item, menuOpts) {
item.select('rect.' + constants.itemRectClassName)
.call(Color.fill, menuOpts.bgcolor);
}
// find item dimensions (this mutates menuOpts)
function findDimensions(gd, menuOpts) {
var dims = menuOpts._dims = {
width1: 0,
height1: 0,
heights: [],
widths: [],
totalWidth: 0,
totalHeight: 0,
openWidth: 0,
openHeight: 0,
lx: 0,
ly: 0
};
var fakeButtons = Drawing.tester.selectAll('g.' + constants.dropdownButtonClassName)
.data(Lib.filterVisible(menuOpts.buttons));
fakeButtons.enter().append('g')
.classed(constants.dropdownButtonClassName, true);
var isVertical = ['up', 'down'].indexOf(menuOpts.direction) !== -1;
// loop over fake buttons to find width / height
fakeButtons.each(function(buttonOpts, i) {
var button = d3.select(this);
button.call(drawItem, menuOpts, buttonOpts, gd);
var text = button.select('.' + constants.itemTextClassName);
// width is given by max width of all buttons
var tWidth = text.node() && Drawing.bBox(text.node()).width;
var wEff = Math.max(tWidth + constants.textPadX, constants.minWidth);
// height is determined by item text
var tHeight = menuOpts.font.size * LINE_SPACING;
var tLines = svgTextUtils.lineCount(text);
var hEff = Math.max(tHeight * tLines, constants.minHeight) + constants.textOffsetY;
hEff = Math.ceil(hEff);
wEff = Math.ceil(wEff);
// Store per-item sizes since a row of horizontal buttons, for example,
// don't all need to be the same width:
dims.widths[i] = wEff;
dims.heights[i] = hEff;
// Height and width of individual element:
dims.height1 = Math.max(dims.height1, hEff);
dims.width1 = Math.max(dims.width1, wEff);
if(isVertical) {
dims.totalWidth = Math.max(dims.totalWidth, wEff);
dims.openWidth = dims.totalWidth;
dims.totalHeight += hEff + constants.gapButton;
dims.openHeight += hEff + constants.gapButton;
} else {
dims.totalWidth += wEff + constants.gapButton;
dims.openWidth += wEff + constants.gapButton;
dims.totalHeight = Math.max(dims.totalHeight, hEff);
dims.openHeight = dims.totalHeight;
}
});
if(isVertical) {
dims.totalHeight -= constants.gapButton;
} else {
dims.totalWidth -= constants.gapButton;
}
dims.headerWidth = dims.width1 + constants.arrowPadX;
dims.headerHeight = dims.height1;
if(menuOpts.type === 'dropdown') {
if(isVertical) {
dims.width1 += constants.arrowPadX;
dims.totalHeight = dims.height1;
} else {
dims.totalWidth = dims.width1;
}
dims.totalWidth += constants.arrowPadX;
}
fakeButtons.remove();
var paddedWidth = dims.totalWidth + menuOpts.pad.l + menuOpts.pad.r;
var paddedHeight = dims.totalHeight + menuOpts.pad.t + menuOpts.pad.b;
var graphSize = gd._fullLayout._size;
dims.lx = graphSize.l + graphSize.w * menuOpts.x;
dims.ly = graphSize.t + graphSize.h * (1 - menuOpts.y);
var xanchor = 'left';
if(Lib.isRightAnchor(menuOpts)) {
dims.lx -= paddedWidth;
xanchor = 'right';
}
if(Lib.isCenterAnchor(menuOpts)) {
dims.lx -= paddedWidth / 2;
xanchor = 'center';
}
var yanchor = 'top';
if(Lib.isBottomAnchor(menuOpts)) {
dims.ly -= paddedHeight;
yanchor = 'bottom';
}
if(Lib.isMiddleAnchor(menuOpts)) {
dims.ly -= paddedHeight / 2;
yanchor = 'middle';
}
dims.totalWidth = Math.ceil(dims.totalWidth);
dims.totalHeight = Math.ceil(dims.totalHeight);
dims.lx = Math.round(dims.lx);
dims.ly = Math.round(dims.ly);
Plots.autoMargin(gd, autoMarginId(menuOpts), {
x: menuOpts.x,
y: menuOpts.y,
l: paddedWidth * ({right: 1, center: 0.5}[xanchor] || 0),
r: paddedWidth * ({left: 1, center: 0.5}[xanchor] || 0),
b: paddedHeight * ({top: 1, middle: 0.5}[yanchor] || 0),
t: paddedHeight * ({bottom: 1, middle: 0.5}[yanchor] || 0)
});
}
function autoMarginId(menuOpts) {
return constants.autoMarginIdRoot + menuOpts._index;
}
// set item positions (mutates posOpts)
function setItemPosition(item, menuOpts, posOpts, overrideOpts) {
overrideOpts = overrideOpts || {};
var rect = item.select('.' + constants.itemRectClassName);
var text = item.select('.' + constants.itemTextClassName);
var borderWidth = menuOpts.borderwidth;
var index = posOpts.index;
var dims = menuOpts._dims;
Drawing.setTranslate(item, borderWidth + posOpts.x, borderWidth + posOpts.y);
var isVertical = ['up', 'down'].indexOf(menuOpts.direction) !== -1;
var finalHeight = overrideOpts.height || (isVertical ? dims.heights[index] : dims.height1);
rect.attr({
x: 0,
y: 0,
width: overrideOpts.width || (isVertical ? dims.width1 : dims.widths[index]),
height: finalHeight
});
var tHeight = menuOpts.font.size * LINE_SPACING;
var tLines = svgTextUtils.lineCount(text);
var spanOffset = ((tLines - 1) * tHeight / 2);
svgTextUtils.positionText(text, constants.textOffsetX,
finalHeight / 2 - spanOffset + constants.textOffsetY);
if(isVertical) {
posOpts.y += dims.heights[index] + posOpts.yPad;
} else {
posOpts.x += dims.widths[index] + posOpts.xPad;
}
posOpts.index++;
}
function removeAllButtons(gButton, newMenuIndexAttr) {
gButton
.attr(constants.menuIndexAttrName, newMenuIndexAttr || '-1')
.selectAll('g.' + constants.dropdownButtonClassName).remove();
}