diff --git a/src/components/colorbar/attributes.js b/src/components/colorbar/attributes.js index eb1efe65552..8a5b68018ea 100644 --- a/src/components/colorbar/attributes.js +++ b/src/components/colorbar/attributes.js @@ -175,21 +175,56 @@ module.exports = overrideAll({ exponentformat: axesAttrs.exponentformat, showexponent: axesAttrs.showexponent, title: { - valType: 'string', - role: 'info', - description: 'Sets the title of the color bar.' + text: { + valType: 'string', + role: 'info', + description: [ + 'Sets the title of the color bar.', + 'Note that before the existence of `title.text`, the title\'s', + 'contents used to be defined as the `title` attribute itself.', + 'This behavior has been deprecated.' + ].join(' ') + }, + font: fontAttrs({ + description: [ + 'Sets this color bar\'s title font.', + 'Note that the title\'s font used to be set', + 'by the now deprecated `titlefont` attribute.' + ].join(' ') + }), + side: { + valType: 'enumerated', + values: ['right', 'top', 'bottom'], + role: 'style', + dflt: 'top', + description: [ + 'Determines the location of color bar\'s title', + 'with respect to the color bar.', + 'Note that the title\'s location used to be set', + 'by the now deprecated `titleside` attribute.' + ].join(' ') + } }, - titlefont: fontAttrs({ - description: 'Sets this color bar\'s title font.' - }), - titleside: { - valType: 'enumerated', - values: ['right', 'top', 'bottom'], - role: 'style', - dflt: 'top', - description: [ - 'Determines the location of the colorbar title', - 'with respect to the color bar.' - ].join(' ') + + _deprecated: { + title: { + valType: 'string', + role: 'info', + description: [ + 'Deprecated in favor of color bar\'s `title.text`.', + 'Note that value of color bar\'s `title` is no longer a simple', + '*string* but a set of sub-attributes.' + ].join(' ') + }, + titlefont: fontAttrs({ + description: 'Deprecated in favor of color bar\'s `title.font`.' + }), + titleside: { + valType: 'enumerated', + values: ['right', 'top', 'bottom'], + role: 'style', + dflt: 'top', + description: 'Deprecated in favor of color bar\'s `title.side`.' + } } }, 'colorbars', 'from-root'); diff --git a/src/components/colorbar/defaults.js b/src/components/colorbar/defaults.js index 6ad1e3d5cbb..8225f2d0801 100644 --- a/src/components/colorbar/defaults.js +++ b/src/components/colorbar/defaults.js @@ -59,7 +59,7 @@ module.exports = function colorbarDefaults(containerIn, containerOut, layout) { handleTickLabelDefaults(colorbarIn, colorbarOut, coerce, 'linear', opts); handleTickMarkDefaults(colorbarIn, colorbarOut, coerce, 'linear', opts); - coerce('title', layout._dfltTitle.colorbar); - Lib.coerceFont(coerce, 'titlefont', layout.font); - coerce('titleside'); + coerce('title.text', layout._dfltTitle.colorbar); + Lib.coerceFont(coerce, 'title.font', layout.font); + coerce('title.side'); }; diff --git a/src/components/colorbar/draw.js b/src/components/colorbar/draw.js index a87982e3628..c6ccf28d21f 100644 --- a/src/components/colorbar/draw.js +++ b/src/components/colorbar/draw.js @@ -184,7 +184,6 @@ module.exports = function draw(gd, id) { showticksuffix: opts.showticksuffix, ticksuffix: opts.ticksuffix, title: opts.title, - titlefont: opts.titlefont, showline: true, anchor: 'free', side: 'right', @@ -218,11 +217,11 @@ module.exports = function draw(gd, id) { // save for other callers to access this axis component.axis = cbAxisOut; - if(['top', 'bottom'].indexOf(opts.titleside) !== -1) { - cbAxisOut.titleside = opts.titleside; + if(['top', 'bottom'].indexOf(opts.title.side) !== -1) { + cbAxisOut.title.side = opts.title.side; cbAxisOut.titlex = opts.x + xpadFrac; cbAxisOut.titley = yBottomFrac + - (opts.titleside === 'top' ? lenFrac - ypadFrac : ypadFrac); + (opts.title.side === 'top' ? lenFrac - ypadFrac : ypadFrac); } if(opts.line.color && opts.tickmode === 'auto') { @@ -285,15 +284,15 @@ module.exports = function draw(gd, id) { var axisLayer = container.select('.cbaxis'); var titleHeight = 0; - if(['top', 'bottom'].indexOf(opts.titleside) !== -1) { + if(['top', 'bottom'].indexOf(opts.title.side) !== -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, - fontSize = cbAxisOut.titlefont.size, + fontSize = cbAxisOut.title.font.size, y; - if(opts.titleside === 'top') { + if(opts.title.side === 'top') { y = (1 - (yBottomFrac + lenFrac - ypadFrac)) * gs.h + gs.t + 3 + fontSize * 0.75; } @@ -307,7 +306,7 @@ module.exports = function draw(gd, id) { } function drawAxis() { - if(['top', 'bottom'].indexOf(opts.titleside) !== -1) { + if(['top', 'bottom'].indexOf(opts.title.side) !== -1) { // squish the axis top to make room for the title var titleGroup = container.select('.cbtitle'), titleText = titleGroup.select('text'), @@ -338,7 +337,7 @@ module.exports = function draw(gd, id) { // TODO: configurable titleHeight += 5; - if(opts.titleside === 'top') { + if(opts.title.side === 'top') { cbAxisOut.domain[1] -= titleHeight / gs.h; titleTrans[1] *= -1; } @@ -459,8 +458,8 @@ module.exports = function draw(gd, id) { }); }, function() { - if(['top', 'bottom'].indexOf(opts.titleside) === -1) { - var fontSize = cbAxisOut.titlefont.size, + if(['top', 'bottom'].indexOf(opts.title.side) === -1) { + var fontSize = cbAxisOut.title.font.size, y = cbAxisOut._offset + cbAxisOut._length / 2, x = gs.l + (cbAxisOut.position || 0) * gs.w + ((cbAxisOut.side === 'right') ? 10 + fontSize * ((cbAxisOut.showticklabels ? 1 : 0.5)) : @@ -472,7 +471,7 @@ module.exports = function draw(gd, id) { drawTitle('h' + cbAxisOut._id + 'title', { avoid: { selection: d3.select(gd).selectAll('g.' + cbAxisOut._id + 'tick'), - side: opts.titleside, + side: opts.title.side, offsetLeft: gs.l, offsetTop: 0, maxShift: fullLayout.width @@ -525,11 +524,11 @@ module.exports = function draw(gd, id) { .node(), titleWidth; if(mathJaxNode && - ['top', 'bottom'].indexOf(opts.titleside) !== -1) { + ['top', 'bottom'].indexOf(opts.title.side) !== -1) { titleWidth = Drawing.bBox(mathJaxNode).width; } else { - // note: the formula below works for all titlesides, + // 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 @@ -558,7 +557,7 @@ module.exports = function draw(gd, id) { container.selectAll('.cboutline').attr({ x: xLeft, y: yTopPx + opts.ypad + - (opts.titleside === 'top' ? titleHeight : 0), + (opts.title.side === 'top' ? titleHeight : 0), width: Math.max(thickPx, 2), height: Math.max(outerheight - 2 * opts.ypad - titleHeight, 2) }) diff --git a/src/components/legend/draw.js b/src/components/legend/draw.js index b9ab966ea10..e9b69b620fe 100644 --- a/src/components/legend/draw.js +++ b/src/components/legend/draw.js @@ -30,7 +30,6 @@ var FROM_BR = alignmentConstants.FROM_BR; var getLegendData = require('./get_legend_data'); var style = require('./style'); var helpers = require('./helpers'); -var anchorUtils = require('./anchor_utils'); var DBLCLICKDELAY = interactConstants.DBLCLICKDELAY; @@ -154,17 +153,17 @@ module.exports = function draw(gd) { lx = gs.l + gs.w * opts.x, ly = gs.t + gs.h * (1 - opts.y); - if(anchorUtils.isRightAnchor(opts)) { + if(Lib.isRightAnchor(opts)) { lx -= opts._width; } - else if(anchorUtils.isCenterAnchor(opts)) { + else if(Lib.isCenterAnchor(opts)) { lx -= opts._width / 2; } - if(anchorUtils.isBottomAnchor(opts)) { + if(Lib.isBottomAnchor(opts)) { ly -= opts._height; } - else if(anchorUtils.isMiddleAnchor(opts)) { + else if(Lib.isMiddleAnchor(opts)) { ly -= opts._height / 2; } @@ -700,18 +699,18 @@ function expandMargin(gd) { opts = fullLayout.legend; var xanchor = 'left'; - if(anchorUtils.isRightAnchor(opts)) { + if(Lib.isRightAnchor(opts)) { xanchor = 'right'; } - else if(anchorUtils.isCenterAnchor(opts)) { + else if(Lib.isCenterAnchor(opts)) { xanchor = 'center'; } var yanchor = 'top'; - if(anchorUtils.isBottomAnchor(opts)) { + if(Lib.isBottomAnchor(opts)) { yanchor = 'bottom'; } - else if(anchorUtils.isMiddleAnchor(opts)) { + else if(Lib.isMiddleAnchor(opts)) { yanchor = 'middle'; } @@ -731,10 +730,10 @@ function expandHorizontalMargin(gd) { opts = fullLayout.legend; var xanchor = 'left'; - if(anchorUtils.isRightAnchor(opts)) { + if(Lib.isRightAnchor(opts)) { xanchor = 'right'; } - else if(anchorUtils.isCenterAnchor(opts)) { + else if(Lib.isCenterAnchor(opts)) { xanchor = 'center'; } diff --git a/src/components/rangeselector/draw.js b/src/components/rangeselector/draw.js index aa717d663b5..65209412e70 100644 --- a/src/components/rangeselector/draw.js +++ b/src/components/rangeselector/draw.js @@ -17,7 +17,6 @@ var Drawing = require('../drawing'); var Lib = require('../../lib'); var svgTextUtils = require('../../lib/svg_text_utils'); var axisIds = require('../../plots/cartesian/axis_ids'); -var anchorUtils = require('../legend/anchor_utils'); var alignmentConstants = require('../../constants/alignment'); var LINE_SPACING = alignmentConstants.LINE_SPACING; @@ -218,21 +217,21 @@ function reposition(gd, buttons, opts, axName, selector) { var ly = graphSize.t + graphSize.h * (1 - opts.y); var xanchor = 'left'; - if(anchorUtils.isRightAnchor(opts)) { + if(Lib.isRightAnchor(opts)) { lx -= width; xanchor = 'right'; } - if(anchorUtils.isCenterAnchor(opts)) { + if(Lib.isCenterAnchor(opts)) { lx -= width / 2; xanchor = 'center'; } var yanchor = 'top'; - if(anchorUtils.isBottomAnchor(opts)) { + if(Lib.isBottomAnchor(opts)) { ly -= height; yanchor = 'bottom'; } - if(anchorUtils.isMiddleAnchor(opts)) { + if(Lib.isMiddleAnchor(opts)) { ly -= height / 2; yanchor = 'middle'; } diff --git a/src/components/rangeslider/draw.js b/src/components/rangeslider/draw.js index 25e1cee95b5..c885e1e9c65 100644 --- a/src/components/rangeslider/draw.js +++ b/src/components/rangeslider/draw.js @@ -172,7 +172,7 @@ module.exports = function(gd) { placeholder: fullLayout._dfltTitle.x, attributes: { x: axisOpts._offset + axisOpts._length / 2, - y: y + opts._height + opts._offsetShift + 10 + 1.5 * axisOpts.titlefont.size, + y: y + opts._height + opts._offsetShift + 10 + 1.5 * axisOpts.title.font.size, 'text-anchor': 'middle' } }); diff --git a/src/components/sliders/attributes.js b/src/components/sliders/attributes.js index 574dee98e9d..e0188bb0db2 100644 --- a/src/components/sliders/attributes.js +++ b/src/components/sliders/attributes.js @@ -133,7 +133,7 @@ module.exports = overrideAll(templatedArray('slider', { role: 'style', description: 'Sets the x position (in normalized coordinates) of the slider.' }, - pad: extendDeepAll({}, padAttrs, { + pad: extendDeepAll(padAttrs({editType: 'arraydraw'}), { description: 'Set the padding of the slider component along each side.' }, {t: {dflt: 20}}), xanchor: { diff --git a/src/components/sliders/draw.js b/src/components/sliders/draw.js index 62557f92ded..b224d940176 100644 --- a/src/components/sliders/draw.js +++ b/src/components/sliders/draw.js @@ -15,7 +15,6 @@ var Color = require('../color'); var Drawing = require('../drawing'); var Lib = require('../../lib'); var svgTextUtils = require('../../lib/svg_text_utils'); -var anchorUtils = require('../legend/anchor_utils'); var arrayEditor = require('../../plot_api/plot_template').arrayEditor; var constants = require('./constants'); @@ -207,21 +206,21 @@ function findDimensions(gd, sliderOpts) { dims.height = dims.currentValueTotalHeight + constants.tickOffset + sliderOpts.ticklen + constants.labelOffset + dims.labelHeight + sliderOpts.pad.t + sliderOpts.pad.b; var xanchor = 'left'; - if(anchorUtils.isRightAnchor(sliderOpts)) { + if(Lib.isRightAnchor(sliderOpts)) { dims.lx -= dims.outerLength; xanchor = 'right'; } - if(anchorUtils.isCenterAnchor(sliderOpts)) { + if(Lib.isCenterAnchor(sliderOpts)) { dims.lx -= dims.outerLength / 2; xanchor = 'center'; } var yanchor = 'top'; - if(anchorUtils.isBottomAnchor(sliderOpts)) { + if(Lib.isBottomAnchor(sliderOpts)) { dims.ly -= dims.height; yanchor = 'bottom'; } - if(anchorUtils.isMiddleAnchor(sliderOpts)) { + if(Lib.isMiddleAnchor(sliderOpts)) { dims.ly -= dims.height / 2; yanchor = 'middle'; } diff --git a/src/components/titles/index.js b/src/components/titles/index.js index e2211dd88df..8b3f9c81fa3 100644 --- a/src/components/titles/index.js +++ b/src/components/titles/index.js @@ -67,19 +67,21 @@ function draw(gd, titleClass, options) { var group = options.containerGroup; var fullLayout = gd._fullLayout; - var titlefont = cont.titlefont || {}; - var font = titlefont.family; - var fontSize = titlefont.size; - var fontColor = titlefont.color; var opacity = 1; var isplaceholder = false; - var txt = (cont.title || '').trim(); + var title = cont.title; + var txt = (title && title.text ? title.text : '').trim(); + + var font = title && title.font ? title.font : {}; + var fontFamily = font.family; + var fontSize = font.size; + var fontColor = font.color; // only make this title editable if we positively identify its property // as one that has editing enabled. var editAttr; - if(prop === 'title') editAttr = 'titleText'; + if(prop === 'title.text') editAttr = 'titleText'; else if(prop.indexOf('axis') !== -1) editAttr = 'axisTitleText'; else if(prop.indexOf('colorbar' !== -1)) editAttr = 'colorbarTitleText'; var editable = gd._context.edits[editAttr]; @@ -137,7 +139,7 @@ function draw(gd, titleClass, options) { titleEl.attr('transform', transformVal); titleEl.style({ - 'font-family': font, + 'font-family': fontFamily, 'font-size': d3.round(fontSize, 2) + 'px', fill: Color.rgb(fontColor), opacity: opacity * Color.opacity(fontColor), diff --git a/src/components/updatemenus/attributes.js b/src/components/updatemenus/attributes.js index 6224392ed02..e6cf24e8d5b 100644 --- a/src/components/updatemenus/attributes.js +++ b/src/components/updatemenus/attributes.js @@ -162,7 +162,7 @@ module.exports = overrideAll(templatedArray('updatemenu', { ].join(' ') }, - pad: extendFlat({}, padAttrs, { + pad: extendFlat(padAttrs({editType: 'arraydraw'}), { description: 'Sets the padding around the buttons or dropdown menu.' }), diff --git a/src/components/updatemenus/draw.js b/src/components/updatemenus/draw.js index f1e5a9adaad..778e01b685e 100644 --- a/src/components/updatemenus/draw.js +++ b/src/components/updatemenus/draw.js @@ -16,7 +16,6 @@ var Color = require('../color'); var Drawing = require('../drawing'); var Lib = require('../../lib'); var svgTextUtils = require('../../lib/svg_text_utils'); -var anchorUtils = require('../legend/anchor_utils'); var arrayEditor = require('../../plot_api/plot_template').arrayEditor; var LINE_SPACING = require('../../constants/alignment').LINE_SPACING; @@ -566,21 +565,21 @@ function findDimensions(gd, menuOpts) { dims.ly = graphSize.t + graphSize.h * (1 - menuOpts.y); var xanchor = 'left'; - if(anchorUtils.isRightAnchor(menuOpts)) { + if(Lib.isRightAnchor(menuOpts)) { dims.lx -= paddedWidth; xanchor = 'right'; } - if(anchorUtils.isCenterAnchor(menuOpts)) { + if(Lib.isCenterAnchor(menuOpts)) { dims.lx -= paddedWidth / 2; xanchor = 'center'; } var yanchor = 'top'; - if(anchorUtils.isBottomAnchor(menuOpts)) { + if(Lib.isBottomAnchor(menuOpts)) { dims.ly -= paddedHeight; yanchor = 'bottom'; } - if(anchorUtils.isMiddleAnchor(menuOpts)) { + if(Lib.isMiddleAnchor(menuOpts)) { dims.ly -= paddedHeight / 2; yanchor = 'middle'; } diff --git a/src/constants/alignment.js b/src/constants/alignment.js index a63cc18266d..120e9dc2361 100644 --- a/src/constants/alignment.js +++ b/src/constants/alignment.js @@ -41,12 +41,17 @@ module.exports = { // multiple of fontSize to get the vertical offset between lines LINE_SPACING: 1.3, - // multiple of fontSize to shift from the baseline to the midline + // multiple of fontSize to shift from the baseline + // to the cap (captical letter) line // (to use when we don't calculate this shift from Drawing.bBox) - // To be precise this should be half the cap height (capital letter) - // of the font, and according to wikipedia: + // This is an approximation since in reality cap height can differ + // from font to font. However, according to Wikipedia // an "average" font might have a cap height of 70% of the em // https://en.wikipedia.org/wiki/Em_(typography)#History + CAP_SHIFT: 0.70, + + // half the cap height (distance between baseline and cap line) + // of an "average" font (for more info see above). MID_SHIFT: 0.35, OPPOSITE_SIDE: { diff --git a/src/lib/anchor_utils.js b/src/lib/anchor_utils.js new file mode 100644 index 00000000000..0c61fc36efc --- /dev/null +++ b/src/lib/anchor_utils.js @@ -0,0 +1,62 @@ +/** +* Copyright 2012-2018, 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'; + + +/** + * Determine the position anchor property of x/y xanchor/yanchor components. + * + * - values < 1/3 align the low side at that fraction, + * - values [1/3, 2/3] align the center at that fraction, + * - values > 2/3 align the right at that fraction. + */ + + +exports.isLeftAnchor = function isLeftAnchor(opts) { + return ( + opts.xanchor === 'left' || + (opts.xanchor === 'auto' && opts.x <= 1 / 3) + ); +}; + +exports.isCenterAnchor = function isCenterAnchor(opts) { + return ( + opts.xanchor === 'center' || + (opts.xanchor === 'auto' && opts.x > 1 / 3 && opts.x < 2 / 3) + ); +}; + +exports.isRightAnchor = function isRightAnchor(opts) { + return ( + opts.xanchor === 'right' || + (opts.xanchor === 'auto' && opts.x >= 2 / 3) + ); +}; + +exports.isTopAnchor = function isTopAnchor(opts) { + return ( + opts.yanchor === 'top' || + (opts.yanchor === 'auto' && opts.y >= 2 / 3) + ); +}; + +exports.isMiddleAnchor = function isMiddleAnchor(opts) { + return ( + opts.yanchor === 'middle' || + (opts.yanchor === 'auto' && opts.y > 1 / 3 && opts.y < 2 / 3) + ); +}; + +exports.isBottomAnchor = function isBottomAnchor(opts) { + return ( + opts.yanchor === 'bottom' || + (opts.yanchor === 'auto' && opts.y <= 1 / 3) + ); +}; diff --git a/src/lib/index.js b/src/lib/index.js index 5b329d5c223..2d9de4b1ac1 100644 --- a/src/lib/index.js +++ b/src/lib/index.js @@ -100,6 +100,14 @@ lib.pathArc = anglesModule.pathArc; lib.pathSector = anglesModule.pathSector; lib.pathAnnulus = anglesModule.pathAnnulus; +var anchorUtils = require('./anchor_utils'); +lib.isLeftAnchor = anchorUtils.isLeftAnchor; +lib.isCenterAnchor = anchorUtils.isCenterAnchor; +lib.isRightAnchor = anchorUtils.isRightAnchor; +lib.isTopAnchor = anchorUtils.isTopAnchor; +lib.isMiddleAnchor = anchorUtils.isMiddleAnchor; +lib.isBottomAnchor = anchorUtils.isBottomAnchor; + var geom2dModule = require('./geometry2d'); lib.segmentsIntersect = geom2dModule.segmentsIntersect; lib.segmentDistance = geom2dModule.segmentDistance; diff --git a/src/lib/regex.js b/src/lib/regex.js index acfaff9d41b..ded7c4dfe40 100644 --- a/src/lib/regex.js +++ b/src/lib/regex.js @@ -16,11 +16,13 @@ * @param {Optional(string)} tail: a fixed piece after the id * eg counterRegex('scene', '.annotations') for scene2.annotations etc. * @param {boolean} openEnded: if true, the string may continue past the match. + * @param {boolean} matchBeginning: if false, the string may start before the match. */ -exports.counter = function(head, tail, openEnded) { +exports.counter = function(head, tail, openEnded, matchBeginning) { var fullTail = (tail || '') + (openEnded ? '' : '$'); + var startWithPrefix = matchBeginning === false ? '' : '^'; if(head === 'xy') { - return new RegExp('^x([2-9]|[1-9][0-9]+)?y([2-9]|[1-9][0-9]+)?' + fullTail); + return new RegExp(startWithPrefix + 'x([2-9]|[1-9][0-9]+)?y([2-9]|[1-9][0-9]+)?' + fullTail); } - return new RegExp('^' + head + '([2-9]|[1-9][0-9]+)?' + fullTail); + return new RegExp(startWithPrefix + head + '([2-9]|[1-9][0-9]+)?' + fullTail); }; diff --git a/src/plot_api/helpers.js b/src/plot_api/helpers.js index cdb8177c60c..e2c244cbfd7 100644 --- a/src/plot_api/helpers.js +++ b/src/plot_api/helpers.js @@ -53,6 +53,8 @@ exports.cleanLayout = function(layout) { } var axisAttrRegex = (Plots.subplotsRegistry.cartesian || {}).attrRegex; + var polarAttrRegex = (Plots.subplotsRegistry.polar || {}).attrRegex; + var ternaryAttrRegex = (Plots.subplotsRegistry.ternary || {}).attrRegex; var sceneAttrRegex = (Plots.subplotsRegistry.gl3d || {}).attrRegex; var keys = Object.keys(layout); @@ -91,6 +93,24 @@ exports.cleanLayout = function(layout) { } delete ax.autotick; } + + cleanTitle(ax); + } + + // modifications for polar + else if(polarAttrRegex && polarAttrRegex.test(key)) { + var polar = layout[key]; + + cleanTitle(polar.radialaxis); + } + + // modifications for ternary + else if(ternaryAttrRegex && ternaryAttrRegex.test(key)) { + var ternary = layout[key]; + + cleanTitle(ternary.aaxis); + cleanTitle(ternary.baxis); + cleanTitle(ternary.caxis); } // modifications for 3D scenes @@ -119,6 +139,11 @@ exports.cleanLayout = function(layout) { delete scene.cameraposition; } + + // clean axis titles + cleanTitle(scene.xaxis); + cleanTitle(scene.yaxis); + cleanTitle(scene.zaxis); } } @@ -176,6 +201,9 @@ exports.cleanLayout = function(layout) { } } + // clean plot title + cleanTitle(layout); + /* * Moved from rotate -> orbit for dragmode */ @@ -185,6 +213,11 @@ exports.cleanLayout = function(layout) { // supported, but new tinycolor does not because they're not valid css Color.clean(layout); + // clean the layout container in layout.template + if(layout.template && layout.template.layout) { + exports.cleanLayout(layout.template.layout); + } + return layout; }; @@ -196,6 +229,46 @@ function cleanAxRef(container, attr) { } } +/** + * Cleans up old title attribute structure (flat) in favor of the new one (nested). + * + * @param {Object} titleContainer - an object potentially including deprecated title attributes + */ +function cleanTitle(titleContainer) { + if(titleContainer) { + + // title -> title.text + // (although title used to be a string attribute, + // numbers are accepted as well) + if(typeof titleContainer.title === 'string' || typeof titleContainer.title === 'number') { + titleContainer.title = { + text: titleContainer.title + }; + } + + rewireAttr('titlefont', 'font'); + rewireAttr('titleposition', 'position'); + rewireAttr('titleside', 'side'); + rewireAttr('titleoffset', 'offset'); + } + + function rewireAttr(oldAttrName, newAttrName) { + var oldAttrSet = titleContainer[oldAttrName]; + var newAttrSet = titleContainer.title && titleContainer.title[newAttrName]; + + if(oldAttrSet && !newAttrSet) { + + // Ensure title object exists + if(!titleContainer.title) { + titleContainer.title = {}; + } + + titleContainer.title[newAttrName] = titleContainer[oldAttrName]; + delete titleContainer[oldAttrName]; + } + } +} + /* * cleanData: Make a few changes to the data for backward compatibility * before it gets used for anything. Modifies the data traces users provide. @@ -399,6 +472,13 @@ exports.cleanData = function(data) { delete trace.autobiny; delete trace.ybins; } + + cleanTitle(trace); + if(trace.colorbar) cleanTitle(trace.colorbar); + if(trace.marker && trace.marker.colorbar) cleanTitle(trace.marker.colorbar); + if(trace.line && trace.line.colorbar) cleanTitle(trace.line.colorbar); + if(trace.aaxis) cleanTitle(trace.aaxis); + if(trace.baxis) cleanTitle(trace.baxis); } }; diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index b9f955a324c..15c4c4d0b0d 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -154,7 +154,7 @@ exports.plot = function(gd, data, layout, config) { // Legacy polar plots if(!fullLayout._has('polar') && data && data[0] && data[0].r) { Lib.log('Legacy polar charts are deprecated!'); - return plotPolar(gd, data, layout); + return plotLegacyPolar(gd, data, layout); } // so we don't try to re-call Plotly.plot from inside @@ -503,7 +503,7 @@ function setPlotContext(gd, config) { context._hasZeroWidth = context._hasZeroWidth || gd.clientWidth === 0; } -function plotPolar(gd, data, layout) { +function plotLegacyPolar(gd, data, layout) { // build or reuse the container skeleton var plotContainer = d3.select(gd).selectAll('.plot-container') .data([0]); @@ -544,7 +544,7 @@ function plotPolar(gd, data, layout) { // editable title var opacity = 1; - var txt = gd._fullLayout.title; + var txt = gd._fullLayout.title ? gd._fullLayout.title.text : ''; if(txt === '' || !txt) opacity = 0; var titleLayout = function() { @@ -578,7 +578,7 @@ function plotPolar(gd, data, layout) { var setContenteditable = function() { this.call(svgTextUtils.makeEditable, {gd: gd}) .on('edit', function(text) { - gd.framework({layout: {title: text}}); + gd.framework({layout: {title: {text: text}}}); this.text(text) .call(titleLayout); this.call(setContenteditable); @@ -1405,8 +1405,11 @@ function _restyle(gd, aobj, traces) { var fullLayout = gd._fullLayout, fullData = gd._fullData, data = gd.data, + eventData = Lib.extendDeepAll({}, aobj), i; + cleanDeprecatedAttributeKeys(aobj); + // initialize flags var flags = editTypes.traceFlags(); @@ -1691,10 +1694,53 @@ function _restyle(gd, aobj, traces) { undoit: undoit, redoit: redoit, traces: traces, - eventData: Lib.extendDeepNoArrays([], [redoit, traces]) + eventData: Lib.extendDeepNoArrays([], [eventData, traces]) }; } +/** + * Converts deprecated attribute keys to + * the current API to ensure backwards compatibility. + * + * This is needed for the update mechanism to determine which + * subroutines to run based on the actual attribute + * definitions (that don't include the deprecated ones). + * + * E.g. Maps {'xaxis.title': 'A chart'} to {'xaxis.title.text': 'A chart'} + * and {titlefont: {...}} to {'title.font': {...}}. + * + * @param aobj + */ +function cleanDeprecatedAttributeKeys(aobj) { + var oldAxisTitleRegex = Lib.counterRegex('axis', '\.title', false, false); + var colorbarRegex = /colorbar\.title$/; + var keys = Object.keys(aobj); + var i, key, value; + + for(i = 0; i < keys.length; i++) { + key = keys[i]; + value = aobj[key]; + + if((key === 'title' || oldAxisTitleRegex.test(key) || colorbarRegex.test(key)) && + (typeof value === 'string' || typeof value === 'number')) { + replace(key, key.replace('title', 'title.text')); + } else if(key.indexOf('titlefont') > -1) { + replace(key, key.replace('titlefont', 'title.font')); + } else if(key.indexOf('titleposition') > -1) { + replace(key, key.replace('titleposition', 'title.position')); + } else if(key.indexOf('titleside') > -1) { + replace(key, key.replace('titleside', 'title.side')); + } else if(key.indexOf('titleoffset') > -1) { + replace(key, key.replace('titleoffset', 'title.offset')); + } + } + + function replace(oldAttrStr, newAttrStr) { + aobj[newAttrStr] = aobj[oldAttrStr]; + delete aobj[oldAttrStr]; + } +} + /** * relayout: update layout attributes of an existing plot * @@ -1825,13 +1871,17 @@ var AX_DOMAIN_RE = /^[xyz]axis[0-9]*\.domain(\[[0|1]\])?$/; function _relayout(gd, aobj) { var layout = gd.layout, fullLayout = gd._fullLayout, - keys = Object.keys(aobj), axes = Axes.list(gd), + eventData = Lib.extendDeepAll({}, aobj), arrayEdits = {}, + keys, arrayStr, i, j; + cleanDeprecatedAttributeKeys(aobj); + keys = Object.keys(aobj); + // look for 'allaxes', split out into all axes // in case of 3D the axis are nested within a scene which is held in _id for(i = 0; i < keys.length; i++) { @@ -2158,7 +2208,7 @@ function _relayout(gd, aobj) { rangesAltered: rangesAltered, undoit: undoit, redoit: redoit, - eventData: Lib.extendDeep({}, redoit) + eventData: eventData }; } diff --git a/src/plot_api/subroutines.js b/src/plot_api/subroutines.js index 100b5c1aec9..68874a4eae7 100644 --- a/src/plot_api/subroutines.js +++ b/src/plot_api/subroutines.js @@ -27,6 +27,10 @@ var enforceAxisConstraints = axisConstraints.enforce; var cleanAxisConstraints = axisConstraints.clean; var doAutoRange = require('../plots/cartesian/autorange').doAutoRange; +var SVG_TEXT_ANCHOR_START = 'start'; +var SVG_TEXT_ANCHOR_MIDDLE = 'middle'; +var SVG_TEXT_ANCHOR_END = 'end'; + exports.layoutStyles = function(gd) { return Lib.syncOrAsync([Plots.doAutoMargin, lsInner], gd); }; @@ -450,18 +454,92 @@ function findCounterAxisLineWidth(ax, side, counterAx, axList) { exports.drawMainTitle = function(gd) { var fullLayout = gd._fullLayout; + var textAnchor = getMainTitleTextAnchor(fullLayout); + var dy = getMainTitleDy(fullLayout); + Titles.draw(gd, 'gtitle', { propContainer: fullLayout, - propName: 'title', + propName: 'title.text', placeholder: fullLayout._dfltTitle.plot, attributes: { - x: fullLayout.width / 2, - y: fullLayout._size.t / 2, - 'text-anchor': 'middle' + x: getMainTitleX(fullLayout, textAnchor), + y: getMainTitleY(fullLayout, dy), + 'text-anchor': textAnchor, + dy: dy } }); }; +function getMainTitleX(fullLayout, textAnchor) { + var title = fullLayout.title; + var gs = fullLayout._size; + var hPadShift = 0; + + if(textAnchor === SVG_TEXT_ANCHOR_START) { + hPadShift = title.pad.l; + } else if(textAnchor === SVG_TEXT_ANCHOR_END) { + hPadShift = -title.pad.r; + } + + switch(title.xref) { + case 'paper': + return gs.l + gs.w * title.x + hPadShift; + case 'container': + default: + return fullLayout.width * title.x + hPadShift; + } +} + +function getMainTitleY(fullLayout, dy) { + var title = fullLayout.title; + var gs = fullLayout._size; + var vPadShift = 0; + + if(dy === '0em' || !dy) { + vPadShift = -title.pad.b; + } else if(dy === alignmentConstants.CAP_SHIFT + 'em') { + vPadShift = title.pad.t; + } + + if(title.y === 'auto') { + return gs.t / 2; + } else { + switch(title.yref) { + case 'paper': + return gs.t + gs.h - gs.h * title.y + vPadShift; + case 'container': + default: + return fullLayout.height - fullLayout.height * title.y + vPadShift; + } + } +} + +function getMainTitleTextAnchor(fullLayout) { + var title = fullLayout.title; + + var textAnchor = SVG_TEXT_ANCHOR_MIDDLE; + if(Lib.isRightAnchor(title)) { + textAnchor = SVG_TEXT_ANCHOR_END; + } else if(Lib.isLeftAnchor(title)) { + textAnchor = SVG_TEXT_ANCHOR_START; + } + + return textAnchor; +} + +function getMainTitleDy(fullLayout) { + var title = fullLayout.title; + + var dy = '0em'; + if(Lib.isTopAnchor(title)) { + dy = alignmentConstants.CAP_SHIFT + 'em'; + } else if(Lib.isMiddleAnchor(title)) { + dy = alignmentConstants.MID_SHIFT + 'em'; + } + + return dy; +} + exports.doTraceStyle = function(gd) { var calcdata = gd.calcdata; var editStyleCalls = []; diff --git a/src/plots/cartesian/axes.js b/src/plots/cartesian/axes.js index 3f1464aa9fb..a69b715caf4 100644 --- a/src/plots/cartesian/axes.js +++ b/src/plots/cartesian/axes.js @@ -1825,8 +1825,8 @@ axes.drawOne = function(gd, ax, opts) { push[s] += ax._boundingBox.width; } - if(ax.title !== fullLayout._dfltTitle[axLetter]) { - push[s] += ax.titlefont.size; + if(ax.title.text !== fullLayout._dfltTitle[axLetter]) { + push[s] += ax.title.font.size; } Plots.autoMargin(gd, pushKey, push); @@ -2337,7 +2337,7 @@ axes.drawTitle = function(gd, ax) { var axLetter = axId.charAt(0); var offsetBase = 1.5; var gs = fullLayout._size; - var fontSize = ax.titlefont.size; + var fontSize = ax.title.font.size; var transform, counterAxis, x, y; @@ -2387,7 +2387,7 @@ axes.drawTitle = function(gd, ax) { Titles.draw(gd, axId + 'title', { propContainer: ax, - propName: ax._name + '.title', + propName: ax._name + '.title.text', placeholder: fullLayout._dfltTitle[axLetter], avoid: avoid, transform: transform, @@ -2640,11 +2640,11 @@ function swapAxisAttrs(layout, key, xFullAxes, yFullAxes, dfltTitle) { i; if(key === 'title') { // special handling of placeholder titles - if(xVal === dfltTitle.x) { - xVal = dfltTitle.y; + if(xVal && xVal.text === dfltTitle.x) { + xVal.text = dfltTitle.y; } - if(yVal === dfltTitle.y) { - yVal = dfltTitle.x; + if(yVal && yVal.text === dfltTitle.y) { + yVal.text = dfltTitle.x; } } diff --git a/src/plots/cartesian/axis_defaults.js b/src/plots/cartesian/axis_defaults.js index f3691bf8003..dacf3dff14d 100644 --- a/src/plots/cartesian/axis_defaults.js +++ b/src/plots/cartesian/axis_defaults.js @@ -68,8 +68,8 @@ module.exports = function handleAxisDefaults(containerIn, containerOut, coerce, // try to get default title from splom trace, fallback to graph-wide value var dfltTitle = splomStash.label || layoutOut._dfltTitle[letter]; - coerce('title', dfltTitle); - Lib.coerceFont(coerce, 'titlefont', { + coerce('title.text', dfltTitle); + Lib.coerceFont(coerce, 'title.font', { family: font.family, size: Math.round(font.size * 1.2), color: dfltFontColor diff --git a/src/plots/cartesian/layout_attributes.js b/src/plots/cartesian/layout_attributes.js index 404f023eb71..1f9b064ab7e 100644 --- a/src/plots/cartesian/layout_attributes.js +++ b/src/plots/cartesian/layout_attributes.js @@ -41,17 +41,27 @@ module.exports = { ].join(' ') }, title: { - valType: 'string', - role: 'info', - editType: 'ticks', - description: 'Sets the title of this axis.' + text: { + valType: 'string', + role: 'info', + editType: 'ticks', + description: [ + 'Sets the title of this axis.', + 'Note that before the existence of `title.text`, the title\'s', + 'contents used to be defined as the `title` attribute itself.', + 'This behavior has been deprecated.' + ].join(' ') + }, + font: fontAttrs({ + editType: 'ticks', + description: [ + 'Sets this axis\' title font.', + 'Note that the title\'s font used to be customized', + 'by the now deprecated `titlefont` attribute.' + ].join(' ') + }), + editType: 'ticks' }, - titlefont: fontAttrs({ - editType: 'ticks', - description: [ - 'Sets this axis\' title font.' - ].join(' ') - }), type: { valType: 'enumerated', // '-' means we haven't yet run autotype or couldn't find any data @@ -778,6 +788,22 @@ module.exports = { 'Set `tickmode` to *auto* for old `autotick` *true* behavior.', 'Set `tickmode` to *linear* for `autotick` *false*.' ].join(' ') - } + }, + title: { + valType: 'string', + role: 'info', + editType: 'ticks', + description: [ + 'Value of `title` is no longer a simple *string* but a set of sub-attributes.', + 'To set the axis\' title, please use `title.text` now.' + ].join(' ') + }, + titlefont: fontAttrs({ + editType: 'ticks', + description: [ + 'Former `titlefont` is now the sub-attribute `font` of `title`.', + 'To customize title font properties, please use `title.font` now.' + ].join(' ') + }) } }; diff --git a/src/plots/gl2d/convert.js b/src/plots/gl2d/convert.js index 43ab172a845..e8ba2054732 100644 --- a/src/plots/gl2d/convert.js +++ b/src/plots/gl2d/convert.js @@ -113,14 +113,14 @@ proto.merge = function(options) { // '_name' is e.g. xaxis, xaxis2, yaxis, yaxis4 ... ax = options[this.scene[axisName]._name]; - axTitle = ax.title === this.scene.fullLayout._dfltTitle[axisLetter] ? '' : ax.title; + axTitle = ax.title.text === this.scene.fullLayout._dfltTitle[axisLetter] ? '' : ax.title.text; for(j = 0; j <= 2; j += 2) { this.labelEnable[i + j] = false; this.labels[i + j] = axTitle; - this.labelColor[i + j] = str2RGBArray(ax.titlefont.color); - this.labelFont[i + j] = ax.titlefont.family; - this.labelSize[i + j] = ax.titlefont.size; + this.labelColor[i + j] = str2RGBArray(ax.title.font.color); + this.labelFont[i + j] = ax.title.font.family; + this.labelSize[i + j] = ax.title.font.size; this.labelPad[i + j] = this.getLabelPad(axisName, ax); this.tickEnable[i + j] = false; @@ -208,7 +208,7 @@ proto.hasAxisInAltrPos = function(axisName, ax) { proto.getLabelPad = function(axisName, ax) { var offsetBase = 1.5, - fontSize = ax.titlefont.size, + fontSize = ax.title.font.size, showticklabels = ax.showticklabels; if(axisName === 'xaxis') { diff --git a/src/plots/gl3d/layout/axis_attributes.js b/src/plots/gl3d/layout/axis_attributes.js index 78309a1deb5..e2c2c9482bc 100644 --- a/src/plots/gl3d/layout/axis_attributes.js +++ b/src/plots/gl3d/layout/axis_attributes.js @@ -73,7 +73,6 @@ module.exports = overrideAll({ categoryorder: axesAttrs.categoryorder, categoryarray: axesAttrs.categoryarray, title: axesAttrs.title, - titlefont: axesAttrs.titlefont, type: axesAttrs.type, autorange: axesAttrs.autorange, rangemode: axesAttrs.rangemode, @@ -113,5 +112,9 @@ module.exports = overrideAll({ gridwidth: axesAttrs.gridwidth, zeroline: axesAttrs.zeroline, zerolinecolor: axesAttrs.zerolinecolor, - zerolinewidth: axesAttrs.zerolinewidth + zerolinewidth: axesAttrs.zerolinewidth, + _deprecated: { + title: axesAttrs._deprecated.title, + titlefont: axesAttrs._deprecated.titlefont + } }, 'plot', 'from-root'); diff --git a/src/plots/gl3d/layout/axis_defaults.js b/src/plots/gl3d/layout/axis_defaults.js index e28b1a74de4..3c18a06ab69 100644 --- a/src/plots/gl3d/layout/axis_defaults.js +++ b/src/plots/gl3d/layout/axis_defaults.js @@ -56,7 +56,7 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, options) { options.fullLayout); coerce('gridcolor', colorMix(containerOut.color, options.bgColor, gridLightness).toRgbString()); - coerce('title', axName[0]); // shouldn't this be on-par with 2D? + coerce('title.text', axName[0]); // shouldn't this be on-par with 2D? containerOut.setScale = Lib.noop; diff --git a/src/plots/gl3d/layout/convert.js b/src/plots/gl3d/layout/convert.js index 8aded799e13..d5b7b0fecab 100644 --- a/src/plots/gl3d/layout/convert.js +++ b/src/plots/gl3d/layout/convert.js @@ -83,11 +83,11 @@ proto.merge = function(sceneLayout) { } // Axes labels - opts.labels[i] = axes.title; - if('titlefont' in axes) { - if(axes.titlefont.color) opts.labelColor[i] = str2RgbaArray(axes.titlefont.color); - if(axes.titlefont.family) opts.labelFont[i] = axes.titlefont.family; - if(axes.titlefont.size) opts.labelSize[i] = axes.titlefont.size; + opts.labels[i] = axes.title.text; + if('font' in axes.title) { + if(axes.title.font.color) opts.labelColor[i] = str2RgbaArray(axes.title.font.color); + if(axes.title.font.family) opts.labelFont[i] = axes.title.font.family; + if(axes.title.font.size) opts.labelSize[i] = axes.title.font.size; } // Lines diff --git a/src/plots/layout_attributes.js b/src/plots/layout_attributes.js index d8428800dba..356ac5f8161 100644 --- a/src/plots/layout_attributes.js +++ b/src/plots/layout_attributes.js @@ -11,6 +11,8 @@ var fontAttrs = require('./font_attributes'); var colorAttrs = require('../components/color/attributes'); var colorscaleAttrs = require('../components/colorscale/layout_attributes'); +var padAttrs = require('./pad_attributes'); +var extendFlat = require('../lib/extend').extendFlat; var globalFont = fontAttrs({ editType: 'calc', @@ -27,17 +29,117 @@ globalFont.color.dflt = colorAttrs.defaultLine; module.exports = { font: globalFont, title: { - valType: 'string', - role: 'info', - editType: 'layoutstyle', - description: [ - 'Sets the plot\'s title.' - ].join(' ') + text: { + valType: 'string', + role: 'info', + editType: 'layoutstyle', + description: [ + 'Sets the plot\'s title.', + 'Note that before the existence of `title.text`, the title\'s', + 'contents used to be defined as the `title` attribute itself.', + 'This behavior has been deprecated.' + ].join(' ') + }, + font: fontAttrs({ + editType: 'layoutstyle', + description: [ + 'Sets the title font.', + 'Note that the title\'s font used to be customized', + 'by the now deprecated `titlefont` attribute.' + ].join(' ') + }), + xref: { + valType: 'enumerated', + dflt: 'container', + values: ['container', 'paper'], + role: 'info', + editType: 'layoutstyle', + description: [ + 'Sets the container `x` refers to.', + '*container* spans the entire `width` of the plot.', + '*paper* refers to the width of the plotting area only.' + ].join(' ') + }, + yref: { + valType: 'enumerated', + dflt: 'container', + values: ['container', 'paper'], + role: 'info', + editType: 'layoutstyle', + description: [ + 'Sets the container `y` refers to.', + '*container* spans the entire `height` of the plot.', + '*paper* refers to the height of the plotting area only.' + ].join(' ') + }, + x: { + valType: 'number', + min: 0, + max: 1, + dflt: 0.5, + role: 'style', + editType: 'layoutstyle', + description: [ + 'Sets the x position with respect to `xref` in normalized', + 'coordinates from *0* (left) to *1* (right).' + ].join(' ') + }, + y: { + valType: 'number', + min: 0, + max: 1, + dflt: 'auto', + role: 'style', + editType: 'layoutstyle', + description: [ + 'Sets the y position with respect to `yref` in normalized', + 'coordinates from *0* (bottom) to *1* (top).', + '*auto* places the baseline of the title onto the', + 'vertical center of the top margin.' + ].join(' ') + }, + xanchor: { + valType: 'enumerated', + dflt: 'auto', + values: ['auto', 'left', 'center', 'right'], + role: 'info', + editType: 'layoutstyle', + description: [ + 'Sets the title\'s horizontal alignment with respect to its x position.', + '*left* means that the title starts at x,', + '*right* means that the title ends at x', + 'and *center* means that the title\'s center is at x.', + '*auto* divides `xref` by three and calculates the `xanchor`', + 'value automatically based on the value of `x`.' + ].join(' ') + }, + yanchor: { + valType: 'enumerated', + dflt: 'auto', + values: ['auto', 'top', 'middle', 'bottom'], + role: 'info', + editType: 'layoutstyle', + description: [ + 'Sets the title\'s vertical alignment with respect to its y position.', + '*top* means that the title\'s cap line is at y,', + '*bottom* means that the title\'s baseline is at y', + 'and *middle* means that the title\'s midline is at y.', + '*auto* divides `yref` by three and calculates the `yanchor`', + 'value automatically based on the value of `y`.' + ].join(' ') + }, + pad: extendFlat(padAttrs({editType: 'layoutstyle'}), { + description: [ + 'Sets the padding of the title.', + 'Each padding value only applies when the corresponding', + '`xanchor`/`yanchor` value is set accordingly. E.g. for left', + 'padding to take effect, `xanchor` must be set to *left*.', + 'The same rule applies if `xanchor`/`yanchor` is determined automatically.', + 'Padding is muted if the respective anchor value is *middle*/*center*.' + ].join(' ') + }), + editType: 'layoutstyle' }, - titlefont: fontAttrs({ - editType: 'layoutstyle', - description: 'Sets the title font.' - }), autosize: { valType: 'boolean', role: 'info', @@ -255,5 +357,23 @@ module.exports = { description: 'Sets the color of the active or hovered on icons in the modebar.' }, editType: 'modebar' + }, + _deprecated: { + title: { + valType: 'string', + role: 'info', + editType: 'layoutstyle', + description: [ + 'Value of `title` is no longer a simple *string* but a set of sub-attributes.', + 'To set the contents of the title, please use `title.text` now.' + ].join(' ') + }, + titlefont: fontAttrs({ + editType: 'layoutstyle', + description: [ + 'Former `titlefont` is now the sub-attribute `font` of `title`.', + 'To customize title font properties, please use `title.font` now.' + ].join(' ') + }) } }; diff --git a/src/plots/pad_attributes.js b/src/plots/pad_attributes.js index 539f2daee6e..47264860c50 100644 --- a/src/plots/pad_attributes.js +++ b/src/plots/pad_attributes.js @@ -8,37 +8,46 @@ 'use strict'; -// This is used exclusively by components inside component arrays, -// hence the 'arraydraw' editType. If this ever gets used elsewhere -// we could generalize it as a function ala font_attributes -module.exports = { - t: { - valType: 'number', - dflt: 0, - role: 'style', - editType: 'arraydraw', - description: 'The amount of padding (in px) along the top of the component.' - }, - r: { - valType: 'number', - dflt: 0, - role: 'style', - editType: 'arraydraw', - description: 'The amount of padding (in px) on the right side of the component.' - }, - b: { - valType: 'number', - dflt: 0, - role: 'style', - editType: 'arraydraw', - description: 'The amount of padding (in px) along the bottom of the component.' - }, - l: { - valType: 'number', - dflt: 0, - role: 'style', - editType: 'arraydraw', - description: 'The amount of padding (in px) on the left side of the component.' - }, - editType: 'arraydraw' +/** + * Creates a set of padding attributes. + * + * @param {object} opts + * @param {string} editType: + * the editType for all pieces of this padding definition + * + * @return {object} attributes object containing {t, r, b, l} as specified + */ +module.exports = function(opts) { + var editType = opts.editType; + return { + t: { + valType: 'number', + dflt: 0, + role: 'style', + editType: editType, + description: 'The amount of padding (in px) along the top of the component.' + }, + r: { + valType: 'number', + dflt: 0, + role: 'style', + editType: editType, + description: 'The amount of padding (in px) on the right side of the component.' + }, + b: { + valType: 'number', + dflt: 0, + role: 'style', + editType: editType, + description: 'The amount of padding (in px) along the bottom of the component.' + }, + l: { + valType: 'number', + dflt: 0, + role: 'style', + editType: editType, + description: 'The amount of padding (in px) on the left side of the component.' + }, + editType: editType + }; }; diff --git a/src/plots/plots.js b/src/plots/plots.js index 7d8947ff6b9..0fd72fd7341 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -1324,14 +1324,25 @@ plots.supplyLayoutGlobalDefaults = function(layoutIn, layoutOut, formatObj) { var globalFont = Lib.coerceFont(coerce, 'font'); - coerce('title', layoutOut._dfltTitle.plot); + coerce('title.text', layoutOut._dfltTitle.plot); - Lib.coerceFont(coerce, 'titlefont', { + Lib.coerceFont(coerce, 'title.font', { family: globalFont.family, size: Math.round(globalFont.size * 1.4), color: globalFont.color }); + coerce('title.xref'); + coerce('title.yref'); + coerce('title.x'); + coerce('title.y'); + coerce('title.xanchor'); + coerce('title.yanchor'); + coerce('title.pad.t'); + coerce('title.pad.r'); + coerce('title.pad.b'); + coerce('title.pad.l'); + // Make sure that autosize is defaulted to *true* // on layouts with no set width and height for backward compatibly, // in particular https://plot.ly/javascript/responsive-fluid-layout/ diff --git a/src/plots/polar/layout_attributes.js b/src/plots/polar/layout_attributes.js index 8334b0c3d96..ec630027526 100644 --- a/src/plots/polar/layout_attributes.js +++ b/src/plots/polar/layout_attributes.js @@ -112,13 +112,17 @@ var radialAxisAttrs = { }, - title: extendFlat({}, axesAttrs.title, {editType: 'plot', dflt: ''}), - titlefont: overrideAll(axesAttrs.titlefont, 'plot', 'from-root'), + title: overrideAll(axesAttrs.title, 'plot', 'from-root'), // might need a 'titleside' and even 'titledirection' down the road hoverformat: axesAttrs.hoverformat, - editType: 'calc' + editType: 'calc', + + _deprecated: { + title: axesAttrs._deprecated.title, + titlefont: axesAttrs._deprecated.titlefont + } }; extendFlat( diff --git a/src/plots/polar/layout_defaults.js b/src/plots/polar/layout_defaults.js index 32bf5afff4b..69c031acef3 100644 --- a/src/plots/polar/layout_defaults.js +++ b/src/plots/polar/layout_defaults.js @@ -100,8 +100,8 @@ function handleDefaults(contIn, contOut, coerce, opts) { coerceAxis('side'); coerceAxis('angle', sector[0]); - coerceAxis('title'); - Lib.coerceFont(coerceAxis, 'titlefont', { + coerceAxis('title.text'); + Lib.coerceFont(coerceAxis, 'title.font', { family: opts.font.family, size: Math.round(opts.font.size * 1.2), color: dfltFontColor diff --git a/src/plots/polar/legacy/micropolar.js b/src/plots/polar/legacy/micropolar.js index eba2a693341..fc94cc39170 100644 --- a/src/plots/polar/legacy/micropolar.js +++ b/src/plots/polar/legacy/micropolar.js @@ -215,8 +215,8 @@ var µ = module.exports = { version: '0.2.2' }; centeringOffset[0] = Math.max(0, centeringOffset[0]); centeringOffset[1] = Math.max(0, centeringOffset[1]); svg.select('.outer-group').attr('transform', 'translate(' + centeringOffset + ')'); - if (axisConfig.title) { - var title = svg.select('g.title-group text').style(fontStyle).text(axisConfig.title); + if (axisConfig.title && axisConfig.title.text) { + var title = svg.select('g.title-group text').style(fontStyle).text(axisConfig.title.text); var titleBBox = title.node().getBBox(); title.attr({ x: chartCenter[0] - titleBBox.width / 2, diff --git a/src/plots/polar/polar.js b/src/plots/polar/polar.js index 2794ea1acab..9d4c8d50611 100644 --- a/src/plots/polar/polar.js +++ b/src/plots/polar/polar.js @@ -483,9 +483,13 @@ proto.updateRadialAxisTitle = function(fullLayout, polarLayout, _angle) { var sina = Math.sin(angleRad); var pad = 0; + + // Hint: no need to check if there is in fact a title.text set + // because if plot is editable, pad needs to be calculated anyways + // to properly show placeholder text when title is empty. if(radialLayout.title) { var h = Drawing.bBox(_this.layers['radial-axis'].node()).height; - var ts = radialLayout.titlefont.size; + var ts = radialLayout.title.font.size; pad = radialLayout.side === 'counterclockwise' ? -h - ts * 0.4 : h + ts * 0.8; diff --git a/src/plots/ternary/layout_attributes.js b/src/plots/ternary/layout_attributes.js index 72397f0f876..86ae1f9d910 100644 --- a/src/plots/ternary/layout_attributes.js +++ b/src/plots/ternary/layout_attributes.js @@ -17,7 +17,6 @@ var extendFlat = require('../../lib/extend').extendFlat; var ternaryAxesAttrs = { title: axesAttrs.title, - titlefont: axesAttrs.titlefont, color: axesAttrs.color, // ticks tickmode: axesAttrs.tickmode, @@ -63,6 +62,10 @@ var ternaryAxesAttrs = { 'values of the other two axes. The full view corresponds to', 'all the minima set to zero.' ].join(' ') + }, + _deprecated: { + title: axesAttrs._deprecated.title, + titlefont: axesAttrs._deprecated.titlefont } }; diff --git a/src/plots/ternary/layout_defaults.js b/src/plots/ternary/layout_defaults.js index 7952a4559a5..0ccbbd2dfd8 100644 --- a/src/plots/ternary/layout_defaults.js +++ b/src/plots/ternary/layout_defaults.js @@ -83,10 +83,10 @@ function handleAxisDefaults(containerIn, containerOut, options) { letterUpper = axName.charAt(0).toUpperCase(), dfltTitle = 'Component ' + letterUpper; - var title = coerce('title', dfltTitle); + var title = coerce('title.text', dfltTitle); containerOut._hovertitle = title === dfltTitle ? title : letterUpper; - Lib.coerceFont(coerce, 'titlefont', { + Lib.coerceFont(coerce, 'title.font', { family: options.font.family, size: Math.round(options.font.size * 1.2), color: dfltFontColor diff --git a/src/plots/ternary/ternary.js b/src/plots/ternary/ternary.js index 672015c4ef7..f13ebfa9199 100644 --- a/src/plots/ternary/ternary.js +++ b/src/plots/ternary/ternary.js @@ -377,7 +377,7 @@ proto.drawAxes = function(doTitles) { placeholder: _(gd, 'Click to enter Component A title'), attributes: { x: _this.x0 + _this.w / 2, - y: _this.y0 - aaxis.titlefont.size / 3 - apad, + y: _this.y0 - aaxis.title.font.size / 3 - apad, 'text-anchor': 'middle' } }); @@ -387,7 +387,7 @@ proto.drawAxes = function(doTitles) { placeholder: _(gd, 'Click to enter Component B title'), attributes: { x: _this.x0 - bpad, - y: _this.y0 + _this.h + baxis.titlefont.size * 0.83 + bpad, + y: _this.y0 + _this.h + baxis.title.font.size * 0.83 + bpad, 'text-anchor': 'middle' } }); @@ -397,7 +397,7 @@ proto.drawAxes = function(doTitles) { placeholder: _(gd, 'Click to enter Component C title'), attributes: { x: _this.x0 + _this.w + bpad, - y: _this.y0 + _this.h + caxis.titlefont.size * 0.83 + bpad, + y: _this.y0 + _this.h + caxis.title.font.size * 0.83 + bpad, 'text-anchor': 'middle' } }); diff --git a/src/snapshot/cloneplot.js b/src/snapshot/cloneplot.js index 5a9a49b359a..ebda88a12ad 100644 --- a/src/snapshot/cloneplot.js +++ b/src/snapshot/cloneplot.js @@ -24,7 +24,7 @@ function cloneLayoutOverride(tileClass) { autosize: true, width: 150, height: 150, - title: '', + title: {text: ''}, showlegend: false, margin: {l: 5, r: 5, t: 5, b: 5, pad: 0}, annotations: [] @@ -33,7 +33,7 @@ function cloneLayoutOverride(tileClass) { case 'thumbnail': override = { - title: '', + title: {text: ''}, hidesources: true, showlegend: false, borderwidth: 0, @@ -81,7 +81,7 @@ module.exports = function clonePlot(graphObj, options) { for(i = 0; i < keys.length; i++) { if(keyIsAxis(keys[i])) { - newLayout[keys[i]].title = ''; + newLayout[keys[i]].title = {text: ''}; } } @@ -109,7 +109,7 @@ module.exports = function clonePlot(graphObj, options) { var axesImageOverride = {}; if(options.tileClass === 'thumbnail') { axesImageOverride = { - title: '', + title: {text: ''}, showaxeslabels: false, showticklabels: false, linetickenable: false diff --git a/src/traces/carpet/axis_attributes.js b/src/traces/carpet/axis_attributes.js index 42e6dcc7f13..1bb49be9f42 100644 --- a/src/traces/carpet/axis_attributes.js +++ b/src/traces/carpet/axis_attributes.js @@ -34,26 +34,39 @@ module.exports = { editType: 'calc' }, title: { - valType: 'string', - role: 'info', + text: { + valType: 'string', + dflt: '', + role: 'info', + editType: 'calc', + description: [ + 'Sets the title of this axis.', + 'Note that before the existence of `title.text`, the title\'s', + 'contents used to be defined as the `title` attribute itself.', + 'This behavior has been deprecated.' + ].join(' ') + }, + font: fontAttrs({ + editType: 'calc', + description: [ + 'Sets this axis\' title font.', + 'Note that the title\'s font used to be set', + 'by the now deprecated `titlefont` attribute.' + ].join(' ') + }), + offset: { + valType: 'number', + role: 'info', + dflt: 10, + editType: 'calc', + description: [ + 'An additional amount by which to offset the title from the tick', + 'labels, given in pixels.', + 'Note that this used to be set', + 'by the now deprecated `titleoffset` attribute.' + ].join(' '), + }, editType: 'calc', - description: 'Sets the title of this axis.' - }, - titlefont: fontAttrs({ - editType: 'calc', - description: [ - 'Sets this axis\' title font.' - ].join(' ') - }), - titleoffset: { - valType: 'number', - role: 'info', - dflt: 10, - editType: 'calc', - description: [ - 'An additional amount by which to offset the title from the tick', - 'labels, given in pixels' - ].join(' '), }, type: { valType: 'enumerated', @@ -494,5 +507,30 @@ module.exports = { editType: 'calc', description: 'The stride between grid lines along the axis' }, + + _deprecated: { + title: { + valType: 'string', + role: 'info', + editType: 'calc', + description: [ + 'Deprecated in favor of `title.text`.', + 'Note that value of `title` is no longer a simple', + '*string* but a set of sub-attributes.' + ].join(' ') + }, + titlefont: fontAttrs({ + editType: 'calc', + description: 'Deprecated in favor of `title.font`.' + }), + titleoffset: { + valType: 'number', + role: 'info', + dflt: 10, + editType: 'calc', + description: 'Deprecated in favor of `title.offset`.' + } + }, + editType: 'calc' }; diff --git a/src/traces/carpet/axis_defaults.js b/src/traces/carpet/axis_defaults.js index 511428dc1ba..59cd9c19948 100644 --- a/src/traces/carpet/axis_defaults.js +++ b/src/traces/carpet/axis_defaults.js @@ -113,14 +113,15 @@ module.exports = function handleAxisDefaults(containerIn, containerOut, options) // inherit from global font color in case that was provided. var dfltFontColor = (dfltColor === containerIn.color) ? dfltColor : font.color; - coerce('title'); - Lib.coerceFont(coerce, 'titlefont', { - family: font.family, - size: Math.round(font.size * 1.2), - color: dfltFontColor - }); - - coerce('titleoffset'); + var title = coerce('title.text'); + if(title) { + Lib.coerceFont(coerce, 'title.font', { + family: font.family, + size: Math.round(font.size * 1.2), + color: dfltFontColor + }); + coerce('title.offset'); + } coerce('tickangle'); @@ -203,11 +204,6 @@ module.exports = function handleAxisDefaults(containerIn, containerOut, options) // but no, we *actually* want to coerce this. coerce('tickmode'); - if(!containerOut.title || (containerOut.title && containerOut.title.length === 0)) { - delete containerOut.titlefont; - delete containerOut.titleoffset; - } - return containerOut; }; diff --git a/src/traces/carpet/plot.js b/src/traces/carpet/plot.js index c7ad270a55c..2d978d31e0f 100644 --- a/src/traces/carpet/plot.js +++ b/src/traces/carpet/plot.js @@ -197,7 +197,7 @@ var midShift = ((1 - alignmentConstants.MID_SHIFT) / lineSpacing) + 1; function drawAxisTitle(gd, layer, trace, t, xy, dxy, axis, xa, ya, labelOrientation, labelClass) { var data = []; - if(axis.title) data.push(axis.title); + if(axis.title.text) data.push(axis.title.text); var titleJoin = layer.selectAll('text.' + labelClass).data(data); var offset = labelOrientation.maxExtent; @@ -213,8 +213,8 @@ function drawAxisTitle(gd, layer, trace, t, xy, dxy, axis, xa, ya, labelOrientat } // In addition to the size of the labels, add on some extra padding: - var titleSize = axis.titlefont.size; - offset += titleSize + axis.titleoffset; + var titleSize = axis.title.font.size; + offset += titleSize + axis.title.offset; var labelNorm = labelOrientation.angle + (labelOrientation.flip < 0 ? 180 : 0); var angleDiff = (labelNorm - orientation.angle + 450) % 360; @@ -222,7 +222,7 @@ function drawAxisTitle(gd, layer, trace, t, xy, dxy, axis, xa, ya, labelOrientat var el = d3.select(this); - el.text(axis.title || '') + el.text(axis.title.text) .call(svgTextUtils.convertToTspans, gd); if(reverseTitle) { @@ -236,7 +236,7 @@ function drawAxisTitle(gd, layer, trace, t, xy, dxy, axis, xa, ya, labelOrientat ) .classed('user-select-none', true) .attr('text-anchor', 'middle') - .call(Drawing.font, axis.titlefont); + .call(Drawing.font, axis.title.font); }); titleJoin.exit().remove(); diff --git a/src/traces/pie/attributes.js b/src/traces/pie/attributes.js index 1b42870a83d..a01385a23bd 100644 --- a/src/traces/pie/attributes.js +++ b/src/traces/pie/attributes.js @@ -185,31 +185,44 @@ module.exports = { }), title: { - valType: 'string', - dflt: '', - role: 'info', - editType: 'calc', - description: [ - 'Sets the title of the pie chart.', - 'If it is empty, no title is displayed.' - ].join(' ') - }, - titleposition: { - valType: 'enumerated', - values: [ - 'top left', 'top center', 'top right', - 'middle center', - 'bottom left', 'bottom center', 'bottom right' - ], - role: 'info', - editType: 'calc', - description: [ - 'Specifies the location of the `title`.', - ].join(' ') + text: { + valType: 'string', + dflt: '', + role: 'info', + editType: 'calc', + description: [ + 'Sets the title of the pie chart.', + 'If it is empty, no title is displayed.', + 'Note that before the existence of `title.text`, the title\'s', + 'contents used to be defined as the `title` attribute itself.', + 'This behavior has been deprecated.' + ].join(' ') + }, + font: extendFlat({}, textFontAttrs, { + description: [ + 'Sets the font used for `title`.', + 'Note that the title\'s font used to be set', + 'by the now deprecated `titlefont` attribute.' + ].join(' ') + }), + position: { + valType: 'enumerated', + values: [ + 'top left', 'top center', 'top right', + 'middle center', + 'bottom left', 'bottom center', 'bottom right' + ], + role: 'info', + editType: 'calc', + description: [ + 'Specifies the location of the `title`.', + 'Note that the title\'s position used to be set', + 'by the now deprecated `titleposition` attribute.' + ].join(' ') + }, + + editType: 'calc' }, - titlefont: extendFlat({}, textFontAttrs, { - description: 'Sets the font used for `title`.' - }), // position and shape domain: domainAttrs({name: 'pie', trace: true, editType: 'calc'}), @@ -283,5 +296,33 @@ module.exports = { 'to pull all slices apart from each other equally', 'or an array to highlight one or more slices.' ].join(' ') + }, + + _deprecated: { + title: { + valType: 'string', + dflt: '', + role: 'info', + editType: 'calc', + description: [ + 'Deprecated in favor of `title.text`.', + 'Note that value of `title` is no longer a simple', + '*string* but a set of sub-attributes.' + ].join(' ') + }, + titlefont: extendFlat({}, textFontAttrs, { + description: 'Deprecated in favor of `title.font`.' + }), + titleposition: { + valType: 'enumerated', + values: [ + 'top left', 'top center', 'top right', + 'middle center', + 'bottom left', 'bottom center', 'bottom right' + ], + role: 'info', + editType: 'calc', + description: 'Deprecated in favor of `title.position`.' + } } }; diff --git a/src/traces/pie/defaults.js b/src/traces/pie/defaults.js index 4b3c156140a..2110884688c 100644 --- a/src/traces/pie/defaults.js +++ b/src/traces/pie/defaults.js @@ -77,11 +77,11 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout handleDomainDefaults(traceOut, layout, coerce); var hole = coerce('hole'); - var title = coerce('title'); + var title = coerce('title.text'); if(title) { - var titlePosition = coerce('titleposition', hole ? 'middle center' : 'top center'); - if(!hole && titlePosition === 'middle center') traceOut.titleposition = 'top center'; - coerceFont(coerce, 'titlefont', layout.font); + var titlePosition = coerce('title.position', hole ? 'middle center' : 'top center'); + if(!hole && titlePosition === 'middle center') traceOut.title.position = 'top center'; + coerceFont(coerce, 'title.font', layout.font); } coerce('sort'); diff --git a/src/traces/pie/plot.js b/src/traces/pie/plot.js index eb41487af80..5eff4d6fc07 100644 --- a/src/traces/pie/plot.js +++ b/src/traces/pie/plot.js @@ -322,7 +322,7 @@ module.exports = function plot(gd, cdpie) { // add the title var titleTextGroup = d3.select(this).selectAll('g.titletext') - .data(trace.title ? [0] : []); + .data(trace.title.text ? [0] : []); titleTextGroup.enter().append('g') .classed('titletext', true); @@ -334,18 +334,18 @@ module.exports = function plot(gd, cdpie) { s.attr('data-notex', 1); }); - titleText.text(trace.title) + titleText.text(trace.title.text) .attr({ 'class': 'titletext', transform: '', 'text-anchor': 'middle', }) - .call(Drawing.font, trace.titlefont) + .call(Drawing.font, trace.title.font) .call(svgTextUtils.convertToTspans, gd); var transform; - if(trace.titleposition === 'middle center') { + if(trace.title.position === 'middle center') { transform = positionTitleInside(cd0); } else { transform = positionTitleOutside(cd0, fullLayout._size); @@ -473,11 +473,11 @@ function prerenderTitles(cdpie, gd) { cd0 = cdpie[i][0]; trace = cd0.trace; - if(trace.title) { + if(trace.title.text) { var dummyTitle = Drawing.tester.append('text') .attr('data-notex', 1) - .text(trace.title) - .call(Drawing.font, trace.titlefont) + .text(trace.title.text) + .call(Drawing.font, trace.title.font) .call(svgTextUtils.convertToTspans, gd); var bBox = Drawing.bBox(dummyTitle.node(), true); cd0.titleBox = { @@ -579,7 +579,7 @@ function positionTitleInside(cd0) { y: cd0.cy, scale: cd0.trace.hole * cd0.r * 2 / textDiameter, tx: 0, - ty: - cd0.titleBox.height / 2 + cd0.trace.titlefont.size + ty: - cd0.titleBox.height / 2 + cd0.trace.title.font.size }; } @@ -602,25 +602,25 @@ function positionTitleOutside(cd0, plotSize) { // we reason below as if the baseline is the top middle point of the text box. // so we must add the font size to approximate the y-coord. of the top. // note that this correction must happen after scaling. - translate.ty += trace.titlefont.size; + translate.ty += trace.title.font.size; maxPull = getMaxPull(trace); - if(trace.titleposition.indexOf('top') !== -1) { + if(trace.title.position.indexOf('top') !== -1) { topMiddle.y -= (1 + maxPull) * cd0.r; translate.ty -= cd0.titleBox.height; } - else if(trace.titleposition.indexOf('bottom') !== -1) { + else if(trace.title.position.indexOf('bottom') !== -1) { topMiddle.y += (1 + maxPull) * cd0.r; } - if(trace.titleposition.indexOf('left') !== -1) { + if(trace.title.position.indexOf('left') !== -1) { // we start the text at the left edge of the pie maxWidth = plotSize.w * (trace.domain.x[1] - trace.domain.x[0]) / 2 + cd0.r; topMiddle.x -= (1 + maxPull) * cd0.r; translate.tx += cd0.titleBox.width / 2; - } else if(trace.titleposition.indexOf('center') !== -1) { + } else if(trace.title.position.indexOf('center') !== -1) { maxWidth = plotSize.w * (trace.domain.x[1] - trace.domain.x[0]); - } else if(trace.titleposition.indexOf('right') !== -1) { + } else if(trace.title.position.indexOf('right') !== -1) { maxWidth = plotSize.w * (trace.domain.x[1] - trace.domain.x[0]) / 2 + cd0.r; topMiddle.x += (1 + maxPull) * cd0.r; translate.tx -= cd0.titleBox.width / 2; @@ -774,7 +774,7 @@ function scalePies(cdpie, plotSize) { pieBoxWidth = plotSize.w * (trace.domain.x[1] - trace.domain.x[0]); pieBoxHeight = plotSize.h * (trace.domain.y[1] - trace.domain.y[0]); // leave some space for the title, if it will be displayed outside - if(trace.title && trace.titleposition !== 'middle center') { + if(trace.title.text && trace.title.position !== 'middle center') { pieBoxHeight -= getTitleSpace(cd0, plotSize); } @@ -784,7 +784,7 @@ function scalePies(cdpie, plotSize) { cd0.cx = plotSize.l + plotSize.w * (trace.domain.x[1] + trace.domain.x[0]) / 2; cd0.cy = plotSize.t + plotSize.h * (1 - trace.domain.y[0]) - pieBoxHeight / 2; - if(trace.title && trace.titleposition.indexOf('bottom') !== -1) { + if(trace.title.text && trace.title.position.indexOf('bottom') !== -1) { cd0.cy -= getTitleSpace(cd0, plotSize); } diff --git a/test/image/mocks/shapes.json b/test/image/mocks/shapes.json index 2aadf3f0020..f98fcc6b843 100644 --- a/test/image/mocks/shapes.json +++ b/test/image/mocks/shapes.json @@ -12,10 +12,10 @@ "yaxis":"y2" }], "layout": { - "xaxis":{"title":"linear","range":[0,1],"domain":[0.35,0.65],"type":"linear","showgrid":false,"zeroline":false,"showticklabels":false}, - "yaxis":{"title":"log","range":[0,1],"domain":[0.6,1],"type":"log","showgrid":false,"zeroline":false,"showticklabels":false}, - "xaxis2":{"title":"date","range":["2000-01-01","2000-01-02"],"domain":[0.7,1],"anchor":"y2","type":"date","showgrid":false,"zeroline":false,"showticklabels":false}, - "yaxis2":{"title":"category","range":[0,1],"domain":[0.6,1],"anchor":"x2","type":"category","showgrid":false,"zeroline":false,"showticklabels":false}, + "xaxis":{"title":{"text":"linear"},"range":[0,1],"domain":[0.35,0.65],"type":"linear","showgrid":false,"zeroline":false,"showticklabels":false}, + "yaxis":{"title":{"text":"log"},"range":[0,1],"domain":[0.6,1],"type":"log","showgrid":false,"zeroline":false,"showticklabels":false}, + "xaxis2":{"title":{"text":"date"},"range":["2000-01-01","2000-01-02"],"domain":[0.7,1],"anchor":"y2","type":"date","showgrid":false,"zeroline":false,"showticklabels":false}, + "yaxis2":{"title":{"text":"category"},"range":[0,1],"domain":[0.6,1],"anchor":"x2","type":"category","showgrid":false,"zeroline":false,"showticklabels":false}, "height":400, "width":800, "margin": {"l":20,"r":20,"pad":0}, diff --git a/test/jasmine/bundle_tests/plotschema_test.js b/test/jasmine/bundle_tests/plotschema_test.js index 825c537ecc3..2e85825bb65 100644 --- a/test/jasmine/bundle_tests/plotschema_test.js +++ b/test/jasmine/bundle_tests/plotschema_test.js @@ -228,7 +228,7 @@ describe('plot schema', function() { assertPlotSchema( function(attr, attrName, attrs, level, attrString) { - if(attr && isPlainObject(attr[DEPRECATED])) { + if(attr && isPlainObject(attr[DEPRECATED]) && isValObject(attr[DEPRECATED])) { Object.keys(attr[DEPRECATED]).forEach(function(dAttrName) { var dAttr = attr[DEPRECATED][dAttrName]; diff --git a/test/jasmine/tests/axes_test.js b/test/jasmine/tests/axes_test.js index 595b4012fb3..5835d6ad832 100644 --- a/test/jasmine/tests/axes_test.js +++ b/test/jasmine/tests/axes_test.js @@ -29,7 +29,9 @@ describe('Test axes', function() { data: [{x: [1, 2, 3], y: [1, 2, 3]}], layout: { xaxis: { - title: 'A Title!!!', + title: { + text: 'A Title!!!' + }, type: 'log', autorange: 'reversed', rangemode: 'tozero', @@ -42,14 +44,18 @@ describe('Test axes', function() { tickcolor: '#f00' }, yaxis: { - title: 'Click to enter Y axis title', + title: { + text: 'Click to enter Y axis title' + }, type: 'date' } } }; var expectedYaxis = Lib.extendDeep({}, gd.layout.xaxis), expectedXaxis = { - title: 'Click to enter X axis title', + title: { + text: 'Click to enter X axis title' + }, type: 'date' }; @@ -345,11 +351,11 @@ describe('Test axes', function() { expect(layoutOut.xaxis.zerolinecolor).toBe(undefined); }); - it('should use \'axis.color\' as default for \'axis.titlefont.color\'', function() { + it('should use \'axis.color\' as default for \'axis.title.font.color\'', function() { layoutIn = { xaxis: { color: 'red' }, yaxis: {}, - yaxis2: { titlefont: { color: 'yellow' } } + yaxis2: { title: { font: { color: 'yellow' } } } }; layoutOut.font = { color: 'blue' }; @@ -357,9 +363,9 @@ describe('Test axes', function() { layoutOut._subplots.yaxis.push('y2'); supplyLayoutDefaults(layoutIn, layoutOut, fullData); - expect(layoutOut.xaxis.titlefont.color).toEqual('red'); - expect(layoutOut.yaxis.titlefont.color).toEqual('blue'); - expect(layoutOut.yaxis2.titlefont.color).toEqual('yellow'); + expect(layoutOut.xaxis.title.font.color).toEqual('red'); + expect(layoutOut.yaxis.title.font.color).toEqual('blue'); + expect(layoutOut.yaxis2.title.font.color).toEqual('yellow'); }); it('should use \'axis.color\' as default for \'axis.linecolor\'', function() { @@ -2882,14 +2888,14 @@ describe('Test axes', function() { expect(size.t).toBe(previousSize.t); previousSize = Lib.extendDeep({}, size); - return Plotly.relayout(gd, {'yaxis.titlefont.size': 30}); + return Plotly.relayout(gd, {'yaxis.title.font.size': 30}); }) .then(function() { var size = gd._fullLayout._size; expect(size).toEqual(previousSize); previousSize = Lib.extendDeep({}, size); - return Plotly.relayout(gd, {'yaxis.title': 'hello'}); + return Plotly.relayout(gd, {'yaxis.title.text': 'hello'}); }) .then(function() { var size = gd._fullLayout._size; diff --git a/test/jasmine/tests/hover_label_test.js b/test/jasmine/tests/hover_label_test.js index dbb12c7754b..b8b3bc16dad 100644 --- a/test/jasmine/tests/hover_label_test.js +++ b/test/jasmine/tests/hover_label_test.js @@ -1655,7 +1655,8 @@ describe('hover info', function() { it('should contain the axis names', function(done) { var gd = document.getElementById('graph'); - Plotly.restyle(gd, 'hovertemplate', '%{yaxis.title}:%{y:$.2f}
%{xaxis.title}:%{x:0.4f}') + Plotly.restyle(gd, 'hovertemplate', + '%{yaxis.title.text}:%{y:$.2f}
%{xaxis.title.text}:%{x:0.4f}') .then(function() { Fx.hover('graph', evt, 'xy'); diff --git a/test/jasmine/tests/legend_test.js b/test/jasmine/tests/legend_test.js index 62aacfa360c..3f474fcc026 100644 --- a/test/jasmine/tests/legend_test.js +++ b/test/jasmine/tests/legend_test.js @@ -6,7 +6,6 @@ var DBLCLICKDELAY = require('@src/constants/interactions').DBLCLICKDELAY; var Legend = require('@src/components/legend'); var getLegendData = require('@src/components/legend/get_legend_data'); var helpers = require('@src/components/legend/helpers'); -var anchorUtils = require('@src/components/legend/anchor_utils'); var d3 = require('d3'); var failTest = require('../assets/fail_test'); @@ -503,7 +502,7 @@ describe('legend anchor utils:', function() { 'use strict'; describe('isRightAnchor', function() { - var isRightAnchor = anchorUtils.isRightAnchor; + var isRightAnchor = Lib.isRightAnchor; var threshold = 2 / 3; it('should return true when \'xanchor\' is set to \'right\'', function() { @@ -524,7 +523,7 @@ describe('legend anchor utils:', function() { }); describe('isCenterAnchor', function() { - var isCenterAnchor = anchorUtils.isCenterAnchor; + var isCenterAnchor = Lib.isCenterAnchor; var threshold0 = 1 / 3; var threshold1 = 2 / 3; @@ -546,7 +545,7 @@ describe('legend anchor utils:', function() { }); describe('isBottomAnchor', function() { - var isBottomAnchor = anchorUtils.isBottomAnchor; + var isBottomAnchor = Lib.isBottomAnchor; var threshold = 1 / 3; it('should return true when \'yanchor\' is set to \'right\'', function() { @@ -567,7 +566,7 @@ describe('legend anchor utils:', function() { }); describe('isMiddleAnchor', function() { - var isMiddleAnchor = anchorUtils.isMiddleAnchor; + var isMiddleAnchor = Lib.isMiddleAnchor; var threshold0 = 1 / 3; var threshold1 = 2 / 3; diff --git a/test/jasmine/tests/lib_test.js b/test/jasmine/tests/lib_test.js index ad06311c263..65c28b9fe1d 100644 --- a/test/jasmine/tests/lib_test.js +++ b/test/jasmine/tests/lib_test.js @@ -2658,12 +2658,12 @@ describe('Queue', function() { expect(gd.undoQueue.queue[0].undo.args[0][1]['marker.color']).toEqual([null]); expect(gd.undoQueue.queue[0].redo.args[0][1]['marker.color']).toEqual('red'); - return Plotly.relayout(gd, 'title', 'A title'); + return Plotly.relayout(gd, 'title.text', 'A title'); }) .then(function() { expect(gd.undoQueue.index).toEqual(2); - expect(gd.undoQueue.queue[1].undo.args[0][1].title).toEqual(null); - expect(gd.undoQueue.queue[1].redo.args[0][1].title).toEqual('A title'); + expect(gd.undoQueue.queue[1].undo.args[0][1]['title.text']).toEqual(null); + expect(gd.undoQueue.queue[1].redo.args[0][1]['title.text']).toEqual('A title'); return Plotly.restyle(gd, 'mode', 'markers'); }) @@ -2674,8 +2674,8 @@ describe('Queue', function() { expect(gd.undoQueue.queue[1].undo.args[0][1].mode).toEqual([null]); expect(gd.undoQueue.queue[1].redo.args[0][1].mode).toEqual('markers'); - expect(gd.undoQueue.queue[0].undo.args[0][1].title).toEqual(null); - expect(gd.undoQueue.queue[0].redo.args[0][1].title).toEqual('A title'); + expect(gd.undoQueue.queue[0].undo.args[0][1]['title.text']).toEqual(null); + expect(gd.undoQueue.queue[0].redo.args[0][1]['title.text']).toEqual('A title'); return Plotly.restyle(gd, 'transforms[0]', { type: 'filter' }); }) diff --git a/test/jasmine/tests/pie_test.js b/test/jasmine/tests/pie_test.js index 0a9cfd04866..e131248ce48 100644 --- a/test/jasmine/tests/pie_test.js +++ b/test/jasmine/tests/pie_test.js @@ -692,29 +692,100 @@ describe('Pie traces', function() { }); }); - it('should be able to restyle title color', function(done) { - function _assert(msg, exp) { - var title = d3.select('.titletext > text').node(); - expect(title.style.fill).toBe(exp.color, msg); - } + function _assertTitle(msg, expText, expColor) { + var title = d3.select('.titletext > text'); + expect(title.text()).toBe(expText, msg + ' text'); + expect(title.node().style.fill).toBe(expColor, msg + ' color'); + } + it('show a user-defined title with a custom position and font', function(done) { + Plotly.plot(gd, [{ + type: 'pie', + values: [1, 2, 3], + title: { + text: 'yo', + font: {color: 'blue'}, + position: 'top left' + } + }]) + .then(function() { + _assertTitle('base', 'yo', 'rgb(0, 0, 255)'); + _verifyTitle(true, false, true, false, false); + }) + .catch(failTest) + .then(done); + }); + + it('still support the deprecated `title` structure (backwards-compatibility)', function(done) { Plotly.plot(gd, [{ type: 'pie', values: [1, 2, 3], title: 'yo', - titlefont: {color: 'blue'} + titlefont: {color: 'blue'}, + titleposition: 'top left' + }]) + .then(function() { + _assertTitle('base', 'yo', 'rgb(0, 0, 255)'); + _verifyTitle(true, false, true, false, false); + }) + .catch(failTest) + .then(done); + }); + + it('should be able to restyle title', function(done) { + Plotly.plot(gd, [{ + type: 'pie', + values: [1, 2, 3], + title: { + text: 'yo', + font: {color: 'blue'}, + position: 'top left' + } }]) .then(function() { - _assert('base', {color: 'rgb(0, 0, 255)'}); - return Plotly.restyle(gd, 'titlefont.color', 'red'); + _assertTitle('base', 'yo', 'rgb(0, 0, 255)'); + _verifyTitle(true, false, true, false, false); + + return Plotly.restyle(gd, { + 'title.text': 'oy', + 'title.font.color': 'red', + 'title.position': 'bottom right' + }); }) .then(function() { - _assert('base', {color: 'rgb(255, 0, 0)'}); + _assertTitle('base', 'oy', 'rgb(255, 0, 0)'); + _verifyTitle(false, true, false, true, false); }) .catch(failTest) .then(done); }); + it('should be able to restyle title despite using the deprecated attributes', function(done) { + Plotly.plot(gd, [{ + type: 'pie', + values: [1, 2, 3], + title: 'yo', + titlefont: {color: 'blue'}, + titleposition: 'top left' + }]) + .then(function() { + _assertTitle('base', 'yo', 'rgb(0, 0, 255)'); + _verifyTitle(true, false, true, false, false); + + return Plotly.restyle(gd, { + 'title': 'oy', + 'titlefont.color': 'red', + 'titleposition': 'bottom right' + }); + }) + .then(function() { + _assertTitle('base', 'oy', 'rgb(255, 0, 0)'); + _verifyTitle(false, true, false, true, false); + }) + .catch(failTest) + .then(done); + }); + it('should be able to react with new text colors', function(done) { Plotly.plot(gd, [{ type: 'pie', diff --git a/test/jasmine/tests/plot_api_test.js b/test/jasmine/tests/plot_api_test.js index 4898c620c41..4d8141cf2e6 100644 --- a/test/jasmine/tests/plot_api_test.js +++ b/test/jasmine/tests/plot_api_test.js @@ -547,6 +547,30 @@ describe('Test plot api', function() { .catch(failTest) .then(done); }); + + it('passes update data back to plotly_relayout unmodified ' + + 'even if deprecated attributes have been used', function(done) { + Plotly.newPlot(gd, [{y: [1, 3, 2]}]); + + gd.on('plotly_relayout', function(eventData) { + expect(eventData).toEqual({ + 'title': 'Plotly chart', + 'xaxis.title': 'X', + 'xaxis.titlefont': {color: 'green'}, + 'yaxis.title': 'Y', + 'polar.radialaxis.title': 'Radial' + }); + done(); + }); + + Plotly.relayout(gd, { + 'title': 'Plotly chart', + 'xaxis.title': 'X', + 'xaxis.titlefont': {color: 'green'}, + 'yaxis.title': 'Y', + 'polar.radialaxis.title': 'Radial' + }); + }); }); describe('Plotly.relayout subroutines switchboard', function() { diff --git a/test/jasmine/tests/polar_test.js b/test/jasmine/tests/polar_test.js index be0325be62b..2d48c4afd1f 100644 --- a/test/jasmine/tests/polar_test.js +++ b/test/jasmine/tests/polar_test.js @@ -134,7 +134,7 @@ describe('Test polar plots defaults:', function() { expect(layoutOut.polar.angularaxis.linecolor).toBe('red'); expect(layoutOut.polar.angularaxis.gridcolor).toBe('rgb(255, 153, 153)', 'blend by 60% with bgcolor'); - expect(layoutOut.polar.radialaxis.titlefont.color).toBe('blue'); + expect(layoutOut.polar.radialaxis.title.font.color).toBe('blue'); expect(layoutOut.polar.radialaxis.linecolor).toBe('blue'); expect(layoutOut.polar.radialaxis.gridcolor).toBe('rgb(153, 153, 255)', 'blend by 60% with bgcolor'); }); diff --git a/test/jasmine/tests/snapshot_test.js b/test/jasmine/tests/snapshot_test.js index 90c875e535d..d7da2aa9d52 100644 --- a/test/jasmine/tests/snapshot_test.js +++ b/test/jasmine/tests/snapshot_test.js @@ -36,19 +36,19 @@ describe('Plotly.Snapshot', function() { data = [dummyTrace1, dummyTrace2]; layout = { - title: 'Chart Title', + title: {text: 'Chart Title'}, showlegend: true, autosize: true, width: 688, height: 460, xaxis: { - title: 'xaxis title', + title: {text: 'xaxis title'}, range: [-0.323374917925, 5.32337491793], type: 'linear', autorange: true }, yaxis: { - title: 'yaxis title', + title: {text: 'yaxis title'}, range: [1.41922290389, 10.5807770961], type: 'linear', autorange: true @@ -70,7 +70,7 @@ describe('Plotly.Snapshot', function() { autosize: true, width: 150, height: 150, - title: '', + title: {text: ''}, showlegend: false, margin: {'l': 5, 'r': 5, 't': 5, 'b': 5, 'pad': 0}, annotations: [] @@ -100,7 +100,7 @@ describe('Plotly.Snapshot', function() { }; var THUMBNAIL_DEFAULT_LAYOUT = { - 'title': '', + 'title': {text: ''}, 'hidesources': true, 'showlegend': false, 'hovermode': false, @@ -117,6 +117,7 @@ describe('Plotly.Snapshot', function() { expect(thumbTile.layout.showlegend).toEqual(THUMBNAIL_DEFAULT_LAYOUT.showlegend); expect(thumbTile.layout.borderwidth).toEqual(THUMBNAIL_DEFAULT_LAYOUT.borderwidth); expect(thumbTile.layout.annotations).toEqual(THUMBNAIL_DEFAULT_LAYOUT.annotations); + expect(thumbTile.layout.title).toEqual(THUMBNAIL_DEFAULT_LAYOUT.title); }); it('should create a 3D thumbnail with limited attributes', function() { @@ -142,7 +143,7 @@ describe('Plotly.Snapshot', function() { }; var AXIS_OVERRIDE = { - title: '', + title: {text: ''}, showaxeslabels: false, showticklabels: false, linetickenable: false diff --git a/test/jasmine/tests/splom_test.js b/test/jasmine/tests/splom_test.js index f1dfb13523b..3c4d81911b3 100644 --- a/test/jasmine/tests/splom_test.js +++ b/test/jasmine/tests/splom_test.js @@ -325,10 +325,10 @@ describe('Test splom trace defaults:', function() { }); var fullLayout = gd._fullLayout; - expect(fullLayout.xaxis.title).toBe('A'); - expect(fullLayout.yaxis.title).toBe('A'); - expect(fullLayout.xaxis2.title).toBe('B'); - expect(fullLayout.yaxis2.title).toBe('B'); + expect(fullLayout.xaxis.title.text).toBe('A'); + expect(fullLayout.yaxis.title.text).toBe('A'); + expect(fullLayout.xaxis2.title.text).toBe('B'); + expect(fullLayout.yaxis2.title.text).toBe('B'); }); it('should set axis title default using dimensions *label* (even visible false dimensions)', function() { @@ -346,12 +346,12 @@ describe('Test splom trace defaults:', function() { }); var fullLayout = gd._fullLayout; - expect(fullLayout.xaxis.title).toBe('A'); - expect(fullLayout.yaxis.title).toBe('A'); - expect(fullLayout.xaxis2.title).toBe('B'); - expect(fullLayout.yaxis2.title).toBe('B'); - expect(fullLayout.xaxis3.title).toBe('C'); - expect(fullLayout.yaxis3.title).toBe('C'); + expect(fullLayout.xaxis.title.text).toBe('A'); + expect(fullLayout.yaxis.title.text).toBe('A'); + expect(fullLayout.xaxis2.title.text).toBe('B'); + expect(fullLayout.yaxis2.title.text).toBe('B'); + expect(fullLayout.xaxis3.title.text).toBe('C'); + expect(fullLayout.yaxis3.title.text).toBe('C'); }); it('should ignore (x|y)axes values beyond dimensions length', function() { @@ -382,12 +382,12 @@ describe('Test splom trace defaults:', function() { 'x2y', 'x2y2', 'x2y3', 'x3y', 'x3y2', 'x3y3' ]); - expect(fullLayout.xaxis.title).toBe('A'); - expect(fullLayout.yaxis.title).toBe('A'); - expect(fullLayout.xaxis2.title).toBe('B'); - expect(fullLayout.yaxis2.title).toBe('B'); - expect(fullLayout.xaxis3.title).toBe('C'); - expect(fullLayout.yaxis3.title).toBe('C'); + expect(fullLayout.xaxis.title.text).toBe('A'); + expect(fullLayout.yaxis.title.text).toBe('A'); + expect(fullLayout.xaxis2.title.text).toBe('B'); + expect(fullLayout.yaxis2.title.text).toBe('B'); + expect(fullLayout.xaxis3.title.text).toBe('C'); + expect(fullLayout.yaxis3.title.text).toBe('C'); expect(fullLayout.xaxis4).toBe(undefined); expect(fullLayout.yaxis4).toBe(undefined); }); @@ -422,12 +422,12 @@ describe('Test splom trace defaults:', function() { ]); expect(fullLayout.xaxis).toBe(undefined); expect(fullLayout.yaxis).toBe(undefined); - expect(fullLayout.xaxis2.title).toBe('A'); - expect(fullLayout.yaxis2.title).toBe('A'); - expect(fullLayout.xaxis3.title).toBe('B'); - expect(fullLayout.yaxis3.title).toBe('B'); - expect(fullLayout.xaxis4.title).toBe('C'); - expect(fullLayout.yaxis4.title).toBe('C'); + expect(fullLayout.xaxis2.title.text).toBe('A'); + expect(fullLayout.yaxis2.title.text).toBe('A'); + expect(fullLayout.xaxis3.title.text).toBe('B'); + expect(fullLayout.yaxis3.title.text).toBe('B'); + expect(fullLayout.xaxis4.title.text).toBe('C'); + expect(fullLayout.yaxis4.title.text).toBe('C'); expect(fullLayout.xaxis5).toBe(undefined); expect(fullLayout.yaxis5).toBe(undefined); }); diff --git a/test/jasmine/tests/template_test.js b/test/jasmine/tests/template_test.js index 0e4097c6b59..9ebff733c6a 100644 --- a/test/jasmine/tests/template_test.js +++ b/test/jasmine/tests/template_test.js @@ -158,7 +158,9 @@ describe('makeTemplate', function() { {fill: 'toself'} ] }, layout: { - title: 'Fill toself and tonext', + title: { + text: 'Fill toself and tonext' + }, width: 400, height: 400 } diff --git a/test/jasmine/tests/ternary_test.js b/test/jasmine/tests/ternary_test.js index f202749ed3b..7d209b41f8d 100644 --- a/test/jasmine/tests/ternary_test.js +++ b/test/jasmine/tests/ternary_test.js @@ -1,5 +1,6 @@ var Plotly = require('@lib'); var Lib = require('@src/lib'); +var rgb = require('@src/components/color').rgb; var supplyLayoutDefaults = require('@src/plots/ternary/layout_defaults'); @@ -382,6 +383,55 @@ describe('ternary plots', function() { .then(done); }); + it('should be able to relayout axis title attributes', function(done) { + var gd = createGraphDiv(); + var fig = Lib.extendDeep({}, require('@mocks/ternary_simple.json')); + + function _assert(axisPrefix, title, family, color, size) { + var titleSel = d3.select('.' + axisPrefix + 'title'); + var titleNode = titleSel.node(); + + var msg = 'for ' + axisPrefix + 'axis title'; + expect(titleSel.text()).toBe(title, 'title ' + msg); + expect(titleNode.style['font-family']).toBe(family, 'font family ' + msg); + expect(parseFloat(titleNode.style['font-size'])).toBe(size, 'font size ' + msg); + expect(titleNode.style.fill).toBe(color, 'font color ' + msg); + } + + Plotly.plot(gd, fig).then(function() { + _assert('a', 'Component A', '"Open Sans", verdana, arial, sans-serif', rgb('#ccc'), 14); + _assert('b', 'chocolate', '"Open Sans", verdana, arial, sans-serif', rgb('#0f0'), 14); + _assert('c', 'Component C', '"Open Sans", verdana, arial, sans-serif', rgb('#444'), 14); + + // Note: Different update notations to also test legacy title structures + return Plotly.relayout(gd, { + 'ternary.aaxis.title.text': 'chips', + 'ternary.aaxis.title.font.color': 'yellow', + 'ternary.aaxis.titlefont.family': 'monospace', + 'ternary.aaxis.titlefont.size': 16, + 'ternary.baxis.title': 'white chocolate', + 'ternary.baxis.title.font.color': 'blue', + 'ternary.baxis.titlefont.family': 'sans-serif', + 'ternary.baxis.titlefont.size': 10, + 'ternary.caxis.title': { + text: 'candy', + font: { + color: 'pink', + family: 'serif', + size: 30 + } + } + }); + }) + .then(function() { + _assert('a', 'chips', 'monospace', rgb('yellow'), 16); + _assert('b', 'white chocolate', 'sans-serif', rgb('blue'), 10); + _assert('c', 'candy', 'serif', rgb('pink'), 30); + }) + .catch(failTest) + .then(done); + }); + it('should be able to hide/show ticks and tick labels', function(done) { var gd = createGraphDiv(); var fig = Lib.extendDeep({}, require('@mocks/ternary_simple.json')); @@ -546,9 +596,9 @@ describe('ternary defaults', function() { layoutIn = {}; supplyLayoutDefaults(layoutIn, layoutOut, fullData); - expect(layoutOut.ternary.aaxis.title).toEqual('Component A'); - expect(layoutOut.ternary.baxis.title).toEqual('Component B'); - expect(layoutOut.ternary.caxis.title).toEqual('Component C'); + expect(layoutOut.ternary.aaxis.title.text).toEqual('Component A'); + expect(layoutOut.ternary.baxis.title.text).toEqual('Component B'); + expect(layoutOut.ternary.caxis.title.text).toEqual('Component C'); }); it('should default \'gricolor\' to 60% dark', function() { diff --git a/test/jasmine/tests/titles_test.js b/test/jasmine/tests/titles_test.js index 0e81784bb29..2dd4ba2230f 100644 --- a/test/jasmine/tests/titles_test.js +++ b/test/jasmine/tests/titles_test.js @@ -1,13 +1,1060 @@ var d3 = require('d3'); var Plotly = require('@lib/index'); +var alignmentConstants = require('@src/constants/alignment'); var interactConstants = require('@src/constants/interactions'); +var Lib = require('@src/lib'); +var rgb = require('@src/components/color').rgb; var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); var mouseEvent = require('../assets/mouse_event'); -describe('editable titles', function() { +describe('Plot title', function() { + 'use strict'; + + var data = [{x: [1, 2, 3], y: [1, 2, 3]}]; + var titlePad = {t: 10, r: 10, b: 10, l: 10}; + var gd; + + beforeEach(function() { + gd = createGraphDiv(); + }); + + afterEach(destroyGraphDiv); + + var containerElemSelector = { + desc: 'container', + select: function() { + return d3.selectAll('.svg-container').node(); + }, + ref: 'container' + }; + + var paperElemSelector = { + desc: 'plot area', + select: function() { + var bgLayer = d3.selectAll('.bglayer .bg'); + expect(bgLayer.empty()).toBe(false, + 'No background layer found representing the size of the plot area'); + return bgLayer.node(); + }, + ref: 'paper' + }; + + it('is centered horizontally and vertically above the plot by default', function() { + Plotly.plot(gd, data, {title: {text: 'Plotly line chart'}}); + + expectDefaultCenteredPosition(gd); + }); + + it('can still be defined as `layout.title` to ensure backwards-compatibility', function() { + Plotly.plot(gd, data, {title: 'Plotly line chart'}); + + expectTitle('Plotly line chart'); + expectDefaultCenteredPosition(gd); + }); + + it('can be updated via `relayout`', function(done) { + Plotly.plot(gd, data, {title: 'Plotly line chart'}) + .then(expectTitleFn('Plotly line chart')) + .then(function() { + return Plotly.relayout(gd, {title: {text: 'Some other title'}}); + }) + .then(expectTitleFn('Some other title')) + .catch(fail) + .then(done); + }); + + // Horizontal alignment + [ + { + selector: containerElemSelector, + xref: 'container' + }, + { + selector: paperElemSelector, + xref: 'paper' + } + ].forEach(function(testCase) { + it('can be placed at the left edge of the ' + testCase.selector.desc, function() { + Plotly.plot(gd, data, extendLayout({ + xref: testCase.xref, + x: 0, + xanchor: 'left' + })); + + expectLeftEdgeAlignedTo(testCase.selector); + }); + + it('can be placed at the right edge of the ' + testCase.selector.desc, function() { + Plotly.plot(gd, data, extendLayout({ + xref: testCase.xref, + x: 1, + xanchor: 'right' + })); + + expectRightEdgeAlignedTo(testCase.selector); + }); + + it('can be placed at the center of the ' + testCase.selector.desc, function() { + Plotly.plot(gd, data, extendLayout({ + xref: testCase.xref, + x: 0.5, + xanchor: 'center' + })); + + expectCenteredWithin(testCase.selector); + }); + }); + + // Vertical alignment + [ + { + selector: containerElemSelector, + yref: 'container' + }, + { + selector: paperElemSelector, + yref: 'paper' + } + ].forEach(function(testCase) { + it('can be placed at the top edge of the ' + testCase.selector.desc, function() { + Plotly.plot(gd, data, extendLayout({ + yref: testCase.yref, + y: 1, + yanchor: 'top' + })); + + expectCapLineAlignsWithTopEdgeOf(testCase.selector); + }); + + it('can be placed at the bottom edge of the ' + testCase.selector.desc, function() { + Plotly.plot(gd, data, extendLayout({ + yref: testCase.yref, + y: 0, + yanchor: 'bottom' + })); + + expectBaselineAlignsWithBottomEdgeOf(testCase.selector); + }); + + it('can be placed in the vertical center of the ' + testCase.selector.desc, function() { + Plotly.plot(gd, data, extendLayout({ + yref: testCase.yref, + y: 0.5, + yanchor: 'middle' + })); + + expectCenteredVerticallyWithin(testCase.selector); + }); + }); + + // y 'auto' value + it('provides a y \'auto\' value putting title baseline in middle ' + + 'of top margin irrespective of `yref`', function() { + + // yref: 'container' + Plotly.plot(gd, data, extendLayout({ + yref: 'container', + y: 'auto' + })); + + expectBaselineInMiddleOfTopMargin(gd); + + // yref: 'paper' + Plotly.relayout(gd, {'title.yref': 'paper'}); + + expectBaselineInMiddleOfTopMargin(gd); + }); + + // xanchor 'auto' test + [ + {x: 0, expAlignment: 'start'}, + {x: 0.3, expAlignment: 'start'}, + {x: 0.4, expAlignment: 'middle'}, + {x: 0.5, expAlignment: 'middle'}, + {x: 0.6, expAlignment: 'middle'}, + {x: 0.7, expAlignment: 'end'}, + {x: 1, expAlignment: 'end'} + ].forEach(function(testCase) { + runXAnchorAutoTest(testCase, 'container'); + runXAnchorAutoTest(testCase, 'paper'); + }); + + function runXAnchorAutoTest(testCase, xref) { + var testDesc = 'with {xanchor: \'auto\', x: ' + testCase.x + ', xref: \'' + xref + + '\'} expected to be aligned ' + testCase.expAlignment; + it(testDesc, function() { + Plotly.plot(gd, data, extendLayout({ + xref: xref, + x: testCase.x, + xanchor: 'auto' + })); + + var textAnchor = titleSel().attr('text-anchor'); + expect(textAnchor).toBe(testCase.expAlignment, testDesc); + }); + } + + // yanchor 'auto' test + // + // Note: in contrast to xanchor, there's no SVG attribute like + // text-anchor we can safely assume to work in all browsers. Thus the + // dy attribute has to be used and as a consequence it's much harder to test + // arbitrary vertical alignment options. Because of that only the + // most common use cases are tested in this regard. + [ + {y: 0, expAlignment: 'bottom', expFn: expectBaselineAlignsWithBottomEdgeOf}, + {y: 0.5, expAlignment: 'middle', expFn: expectCenteredVerticallyWithin}, + {y: 1, expAlignment: 'top', expFn: expectCapLineAlignsWithTopEdgeOf}, + ].forEach(function(testCase) { + runYAnchorAutoTest(testCase, 'container', containerElemSelector); + runYAnchorAutoTest(testCase, 'paper', paperElemSelector); + }); + + function runYAnchorAutoTest(testCase, yref, elemSelector) { + var testDesc = 'with {yanchor: \'auto\', y: ' + testCase.y + ', yref: \'' + yref + + '\'} expected to be aligned ' + testCase.expAlignment; + it(testDesc, function() { + Plotly.plot(gd, data, extendLayout({ + yref: yref, + y: testCase.y, + yanchor: 'auto' + })); + + testCase.expFn(elemSelector); + }); + } + + it('{y: \'auto\'} overrules {yanchor: \'auto\'} to support behavior ' + + 'before chart title alignment was introduced', function() { + Plotly.plot(gd, data, extendLayout({ + y: 'auto', + yanchor: 'auto' + })); + + expectDefaultCenteredPosition(gd); + }); + + // Horizontal padding + [containerElemSelector, paperElemSelector].forEach(function(refSelector) { + it('can be placed x pixels away from left ' + refSelector.desc + ' edge', function() { + Plotly.plot(gd, data, extendLayout({ + xref: refSelector.ref, + xanchor: 'left', + x: 0, + pad: titlePad + })); + + expect(titleSel().attr('text-anchor')).toBe('start'); + expect(titleX() - 10).toBe(leftOf(refSelector)); + }); + }); + + [containerElemSelector, paperElemSelector].forEach(function(refSelector) { + it('can be placed x pixels away from right ' + refSelector.desc + ' edge', function() { + Plotly.plot(gd, data, extendLayout({ + xref: refSelector.ref, + xanchor: 'right', + x: 1, + pad: titlePad + })); + + expect(titleSel().attr('text-anchor')).toBe('end'); + expect(titleX() + 10).toBe(rightOf(refSelector)); + }); + }); + + [containerElemSelector, paperElemSelector].forEach(function(refSelector) { + it('figures out for itself which horizontal padding applies when {xanchor: \'auto\'}' + + refSelector.desc + ' edge', function() { + Plotly.plot(gd, data, extendLayout({ + xref: refSelector.ref, + xanchor: 'auto', + x: 1, + pad: titlePad + })); + + expect(titleSel().attr('text-anchor')).toBe('end'); + expect(titleX() + 10).toBe(rightOf(refSelector)); + + Plotly.relayout(gd, 'title.x', 0); + + expect(titleSel().attr('text-anchor')).toBe('start'); + expect(titleX() - 10).toBe(leftOf(refSelector)); + + Plotly.relayout(gd, 'title.x', 0.5); + expectCenteredWithin(refSelector); + }); + }); + + // Cases when horizontal padding is ignored + // (just testing with paper is sufficient) + [ + {pad: {l: 20}, dir: 'left'}, + {pad: {r: 20}, dir: 'right'} + ].forEach(function(testCase) { + it('mutes ' + testCase.dir + ' padding for {xanchor: \'center\'}', function() { + Plotly.plot(gd, data, extendLayout({ + xref: 'paper', + xanchor: 'middle', + x: 0.5, + pad: testCase.pad + })); + + expectCenteredWithin(paperElemSelector); + }); + }); + + it('mutes left padding when xanchor is right', function() { + Plotly.plot(gd, data, extendLayout({ + xref: 'paper', + x: 1, + xanchor: 'right', + pad: { + l: 1000 + } + })); + + expectRightEdgeAlignedTo(paperElemSelector); + }); + + it('mutes right padding when xanchor is left', function() { + Plotly.plot(gd, data, extendLayout({ + xref: 'paper', + x: 0, + xanchor: 'left', + pad: { + r: 1000 + } + })); + + expectLeftEdgeAlignedTo(paperElemSelector); + }); + + // Vertical padding + [containerElemSelector, paperElemSelector].forEach(function(refSelector) { + it('can be placed x pixels below top ' + refSelector.desc + ' edge', function() { + Plotly.plot(gd, data, extendLayout({ + yref: refSelector.ref, + yanchor: 'top', + y: 1, + pad: titlePad + })); + + var capLineY = calcTextCapLineY(titleSel()); + expect(capLineY).toBe(topOf(refSelector) + 10); + }); + }); + + [containerElemSelector, paperElemSelector].forEach(function(refSelector) { + it('can be placed x pixels above bottom ' + refSelector.desc + ' edge', function() { + Plotly.plot(gd, data, extendLayout({ + yref: refSelector.ref, + yanchor: 'bottom', + y: 0, + pad: titlePad + })); + + var baselineY = calcTextBaselineY(titleSel()); + expect(baselineY).toBe(bottomOf(refSelector) - 10); + }); + }); + + [containerElemSelector, paperElemSelector].forEach(function(refSelector) { + it('figures out for itself which vertical padding applies when {yanchor: \'auto\'}' + + refSelector.desc + ' edge', function() { + Plotly.plot(gd, data, extendLayout({ + yref: refSelector.ref, + yanchor: 'auto', + y: 1, + pad: titlePad + })); + + var capLineY = calcTextCapLineY(titleSel()); + expect(capLineY).toBe(topOf(refSelector) + 10); + + Plotly.relayout(gd, 'title.y', 0); + + var baselineY = calcTextBaselineY(titleSel()); + expect(baselineY).toBe(bottomOf(refSelector) - 10); + + Plotly.relayout(gd, 'title.y', 0.5); + expectCenteredVerticallyWithin(refSelector); + }); + }); + + // Cases when vertical padding is ignored + // (just testing with paper is sufficient) + [ + {pad: {t: 20}, dir: 'top'}, + {pad: {b: 20}, dir: 'bottom'} + ].forEach(function(testCase) { + it('mutes ' + testCase.dir + ' padding for {yanchor: \'middle\'}', function() { + Plotly.plot(gd, data, extendLayout({ + yref: 'paper', + yanchor: 'middle', + y: 0.5, + pad: testCase.pad + })); + + expectCenteredVerticallyWithin(paperElemSelector); + }); + }); + + it('mutes top padding when yanchor is bottom', function() { + Plotly.plot(gd, data, extendLayout({ + yref: 'paper', + y: 0, + yanchor: 'bottom', + pad: { + t: 1000 + } + })); + + expectBaselineAlignsWithBottomEdgeOf(paperElemSelector); + }); + + it('mutes bottom padding when yanchor is top', function() { + Plotly.plot(gd, data, extendLayout({ + yref: 'paper', + y: 1, + yanchor: 'top', + pad: { + b: 1000 + } + })); + + expectCapLineAlignsWithTopEdgeOf(paperElemSelector); + }); + + function extendLayout(titleAttrs) { + return { + title: Lib.extendFlat({text: 'A Chart Title'}, titleAttrs), + + // This needs to be set to have a DOM element that represents the + // exact size of the plot area. + plot_bgcolor: '#f9f9f9', + }; + } + + function topOf(elemSelector) { + return elemSelector.select().getBoundingClientRect().top; + } + + function rightOf(elemSelector) { + return elemSelector.select().getBoundingClientRect().right; + } + + function bottomOf(elemSelector) { + return elemSelector.select().getBoundingClientRect().bottom; + } + + function leftOf(elemSelector) { + return elemSelector.select().getBoundingClientRect().left; + } + + function expectLeftEdgeAlignedTo(elemSelector) { + expectHorizontalEdgeAligned(elemSelector, 'left'); + } + + function expectRightEdgeAlignedTo(elemSelector) { + expectHorizontalEdgeAligned(elemSelector, 'right'); + } + + function expectHorizontalEdgeAligned(elemSelector, edgeKey) { + var refElem = elemSelector.select(); + var titleElem = titleSel().node(); + var refElemBB = refElem.getBoundingClientRect(); + var titleBB = titleElem.getBoundingClientRect(); + + var tolerance = 1.1; + var msg = 'Title ' + edgeKey + ' of ' + elemSelector.desc; + expect(titleBB[edgeKey] - refElemBB[edgeKey]).toBeWithin(0, tolerance, msg); + } + + function expectCapLineAlignsWithTopEdgeOf(elemSelector) { + var refElem = elemSelector.select(); + var refElemBB = refElem.getBoundingClientRect(); + + // Note: getBoundingClientRect of an SVG element + // doesn't return the tightest bounding box of the current text + // but a rectangle that is wide enough to contain any + // possible character even though something like 'Ö' isn't + // in the current title string. Moreover getBoundingClientRect + // (with respect to SVG elements) differs from browser to + // browser and thus is unreliable for testing vertical alignment + // of SVG text. Because of that the cap line is calculated based on the + // element properties. + var capLineY = calcTextCapLineY(titleSel()); + + var msg = 'Title\'s cap line y is same as the top of ' + elemSelector.desc; + expect(capLineY).toBeWithin(refElemBB.top, 1.1, msg); + } + + function expectBaselineAlignsWithBottomEdgeOf(elemSelector) { + var refElem = elemSelector.select(); + var refElemBB = refElem.getBoundingClientRect(); + + // Note: using getBoundingClientRect is not reliable, see + // comment in `expectCapLineAlignsWithTopEdgeOf` for more info. + var baselineY = calcTextBaselineY(titleSel()); + + var msg = 'Title baseline sits on the bottom of ' + elemSelector.desc; + expect(baselineY).toBeWithin(refElemBB.bottom, 1.1, msg); + } + + function expectCenteredWithin(elemSelector) { + var refElem = elemSelector.select(); + var titleElem = titleSel().node(); + var refElemBB = refElem.getBoundingClientRect(); + var titleBB = titleElem.getBoundingClientRect(); + + var leftDistance = titleBB.left - refElemBB.left; + var rightDistance = refElemBB.right - titleBB.right; + + var tolerance = 1.1; + var msg = 'Title in center of ' + elemSelector.desc; + expect(leftDistance).toBeWithin(rightDistance, tolerance, msg); + } + + function expectCenteredVerticallyWithin(elemSelector) { + var refElem = elemSelector.select(); + var titleElem = titleSel().node(); + var refElemBB = refElem.getBoundingClientRect(); + var titleBB = titleElem.getBoundingClientRect(); + + var topDistance = titleBB.top - refElemBB.top; + var bottomDistance = refElemBB.bottom - titleBB.bottom; + + var tolerance = 1.1; + var msg = 'Title centered vertically within ' + elemSelector.desc; + expect(topDistance).toBeWithin(bottomDistance, tolerance, msg); + } +}); + +describe('Titles can be updated', function() { + 'use strict'; + + var data = [{x: [1, 2, 3], y: [1, 2, 3]}]; + var NEW_TITLE = 'Weight over years'; + var NEW_XTITLE = 'Age in years'; + var NEW_YTITLE = 'Average weight'; + var gd; + + beforeEach(function() { + var layout = { + title: {text: 'Plotly line chart'}, + xaxis: {title: {text: 'Age'}}, + yaxis: {title: {text: 'Weight'}} + }; + gd = createGraphDiv(); + Plotly.plot(gd, data, Lib.extendDeep({}, layout)); + + expectTitles('Plotly line chart', 'Age', 'Weight'); + }); + + afterEach(destroyGraphDiv); + + [ + { + desc: 'by replacing the entire title objects', + update: { + title: {text: NEW_TITLE}, + xaxis: {title: {text: NEW_XTITLE}}, + yaxis: {title: {text: NEW_YTITLE}} + } + }, + { + desc: 'by using attribute strings', + update: { + 'title.text': NEW_TITLE, + 'xaxis.title.text': NEW_XTITLE, + 'yaxis.title.text': NEW_YTITLE + } + }, + { + desc: 'despite passing title only as a string (backwards-compatibility)', + update: { + title: NEW_TITLE, + xaxis: {title: NEW_XTITLE}, + yaxis: {title: NEW_YTITLE} + } + }, + { + desc: 'despite passing title only as a string using string attributes ' + + '(backwards-compatibility)', + update: { + 'title': NEW_TITLE, + 'xaxis.title': NEW_XTITLE, + 'yaxis.title': NEW_YTITLE + } + } + ].forEach(function(testCase) { + it('via `Plotly.relayout` ' + testCase.desc, function() { + Plotly.relayout(gd, testCase.update); + + expectChangedTitles(); + }); + + it('via `Plotly.update` ' + testCase.desc, function() { + Plotly.update(gd, {}, testCase.update); + + expectChangedTitles(); + }); + }); + + function expectChangedTitles() { + expectTitles(NEW_TITLE, NEW_XTITLE, NEW_YTITLE); + } + + function expectTitles(expTitle, expXTitle, expYTitle) { + expectTitle(expTitle); + + var xSel = xTitleSel(); + expect(xSel.empty()).toBe(false, 'X-axis title element missing'); + expect(xSel.text()).toBe(expXTitle); + + var ySel = yTitleSel(); + expect(ySel.empty()).toBe(false, 'Y-axis title element missing'); + expect(ySel.text()).toBe(expYTitle); + } +}); + +describe('Titles support setting custom font properties', function() { + 'use strict'; + + var data = [{x: [1, 2, 3], y: [1, 2, 3]}]; + var gd; + + beforeEach(function() { + gd = createGraphDiv(); + }); + + afterEach(destroyGraphDiv); + + it('through defining a `font` property in the respective title attribute', function() { + var layout = { + title: { + text: 'Plotly line chart', + font: { + color: 'blue', + family: 'serif', + size: 24 + } + }, + xaxis: { + title: { + text: 'X-Axis', + font: { + color: '#333', + family: 'sans-serif', + size: 20 + } + } + }, + yaxis: { + title: { + text: 'Y-Axis', + font: { + color: '#666', + family: 'Arial', + size: 16 + } + } + } + }; + Plotly.plot(gd, data, layout); + + expectTitleFont('blue', 'serif', 24); + expectXAxisTitleFont('#333', 'sans-serif', 20); + expectYAxisTitleFont('#666', 'Arial', 16); + }); + + it('through using the deprecated `titlefont` properties (backwards-compatibility)', function() { + var layout = { + title: { + text: 'Plotly line chart', + }, + titlefont: { + color: 'blue', + family: 'serif', + size: 24 + }, + xaxis: { + title: { + text: 'X-Axis', + }, + titlefont: { + color: '#333', + family: 'sans-serif', + size: 20 + } + }, + yaxis: { + title: { + text: 'Y-Axis', + }, + titlefont: { + color: '#666', + family: 'Arial', + size: 16 + } + } + }; + Plotly.plot(gd, data, layout); + + expectTitleFont('blue', 'serif', 24); + expectXAxisTitleFont('#333', 'sans-serif', 20); + expectYAxisTitleFont('#666', 'Arial', 16); + }); +}); + +describe('Title fonts can be updated', function() { + 'use strict'; + + var data = [{x: [1, 2, 3], y: [1, 2, 3]}]; + var NEW_TITLE = 'Weight over years'; + var NEW_XTITLE = 'Age in years'; + var NEW_YTITLE = 'Average weight'; + var NEW_TITLE_FONT = {color: '#333', family: 'serif', size: 28}; + var NEW_XTITLE_FONT = {color: '#666', family: 'sans-serif', size: 18}; + var NEW_YTITLE_FONT = {color: '#999', family: 'serif', size: 12}; + var gd; + + beforeEach(function() { + var layout = { + title: { + text: 'Plotly line chart', + font: {color: 'black', family: 'sans-serif', size: 24} + }, + xaxis: { + title: { + text: 'Age', + font: {color: 'red', family: 'serif', size: 20} + } + }, + yaxis: { + title: { + text: 'Weight', + font: {color: 'green', family: 'monospace', size: 16} + } + } + }; + gd = createGraphDiv(); + Plotly.plot(gd, data, Lib.extendDeep({}, layout)); + + expectTitleFont('black', 'sans-serif', 24); + expectXAxisTitleFont('red', 'serif', 20); + expectYAxisTitleFont('green', 'monospace', 16); + }); + + afterEach(destroyGraphDiv); + + [ + { + desc: 'by replacing the entire title objects', + update: { + title: { + text: NEW_TITLE, + font: NEW_TITLE_FONT + }, + xaxis: { + title: { + text: NEW_XTITLE, + font: NEW_XTITLE_FONT + } + }, + yaxis: { + title: { + text: NEW_YTITLE, + font: NEW_YTITLE_FONT + } + } + } + }, + { + desc: 'by using attribute strings', + update: { + 'title.font.color': NEW_TITLE_FONT.color, + 'title.font.family': NEW_TITLE_FONT.family, + 'title.font.size': NEW_TITLE_FONT.size, + 'xaxis.title.font.color': NEW_XTITLE_FONT.color, + 'xaxis.title.font.family': NEW_XTITLE_FONT.family, + 'xaxis.title.font.size': NEW_XTITLE_FONT.size, + 'yaxis.title.font.color': NEW_YTITLE_FONT.color, + 'yaxis.title.font.family': NEW_YTITLE_FONT.family, + 'yaxis.title.font.size': NEW_YTITLE_FONT.size + } + }, + { + desc: 'despite passing deprecated `titlefont` properties (backwards-compatibility)', + update: { + titlefont: NEW_TITLE_FONT, + xaxis: { + title: NEW_XTITLE, + titlefont: NEW_XTITLE_FONT + }, + yaxis: { + title: NEW_YTITLE, + titlefont: NEW_YTITLE_FONT + } + } + }, + { + desc: 'despite using string attributes representing the deprecated structure ' + + '(backwards-compatibility)', + update: { + 'titlefont.color': NEW_TITLE_FONT.color, + 'titlefont.family': NEW_TITLE_FONT.family, + 'titlefont.size': NEW_TITLE_FONT.size, + 'xaxis.titlefont.color': NEW_XTITLE_FONT.color, + 'xaxis.titlefont.family': NEW_XTITLE_FONT.family, + 'xaxis.titlefont.size': NEW_XTITLE_FONT.size, + 'yaxis.titlefont.color': NEW_YTITLE_FONT.color, + 'yaxis.titlefont.family': NEW_YTITLE_FONT.family, + 'yaxis.titlefont.size': NEW_YTITLE_FONT.size + } + }, + { + desc: 'despite using string attributes replacing deprecated `titlefont` attributes ' + + '(backwards-compatibility)', + update: { + 'titlefont': NEW_TITLE_FONT, + 'xaxis.titlefont': NEW_XTITLE_FONT, + 'yaxis.titlefont': NEW_YTITLE_FONT + } + } + ].forEach(function(testCase) { + it('via `Plotly.relayout` ' + testCase.desc, function() { + Plotly.relayout(gd, testCase.update); + + expectChangedTitleFonts(); + }); + + it('via `Plotly.update` ' + testCase.desc, function() { + Plotly.update(gd, {}, testCase.update); + + expectChangedTitleFonts(); + }); + }); + + function expectChangedTitleFonts() { + expectTitleFont(NEW_TITLE_FONT.color, NEW_TITLE_FONT.family, NEW_TITLE_FONT.size); + expectXAxisTitleFont(NEW_XTITLE_FONT.color, NEW_XTITLE_FONT.family, NEW_XTITLE_FONT.size); + expectYAxisTitleFont(NEW_YTITLE_FONT.color, NEW_YTITLE_FONT.family, NEW_YTITLE_FONT.size); + } +}); + +describe('Titles for multiple axes', function() { + 'use strict'; + + var data = [ + {x: [1, 2, 3], y: [1, 2, 3], xaxis: 'x', yaxis: 'y'}, + {x: [1, 2, 3], y: [3, 2, 1], xaxis: 'x2', yaxis: 'y2'} + ]; + var multiAxesLayout = { + xaxis: { + title: 'X-Axis 1', + titlefont: { + size: 30 + } + }, + xaxis2: { + title: 'X-Axis 2', + titlefont: { + family: 'serif' + }, + side: 'top' + }, + yaxis: { + title: 'Y-Axis 1', + titlefont: { + family: 'Roboto' + }, + }, + yaxis2: { + title: 'Y-Axis 2', + titlefont: { + color: 'blue' + }, + side: 'right' + } + }; + var gd; + + beforeEach(function() { + gd = createGraphDiv(); + }); + + afterEach(destroyGraphDiv); + + it('still support deprecated `title` and `titlefont` syntax (backwards-compatibility)', function() { + Plotly.plot(gd, data, multiAxesLayout); + + expect(xTitleSel(1).text()).toBe('X-Axis 1'); + expect(xTitleSel(1).node().style.fontSize).toBe('30px'); + + expect(xTitleSel(2).text()).toBe('X-Axis 2'); + expect(xTitleSel(2).node().style.fontFamily).toBe('serif'); + + expect(yTitleSel(1).text()).toBe('Y-Axis 1'); + expect(yTitleSel(1).node().style.fontFamily).toBe('Roboto'); + + expect(yTitleSel(2).text()).toBe('Y-Axis 2'); + expect(yTitleSel(2).node().style.fill).toBe(rgb('blue')); + }); + + it('can be updated using deprecated `title` and `titlefont` syntax (backwards-compatibility)', function() { + Plotly.plot(gd, data, multiAxesLayout); + + Plotly.relayout(gd, { + 'xaxis2.title': '2nd X-Axis', + 'xaxis2.titlefont.color': 'pink', + 'xaxis2.titlefont.family': 'sans-serif', + 'xaxis2.titlefont.size': '14', + 'yaxis2.title': '2nd Y-Axis', + 'yaxis2.titlefont.color': 'yellow', + 'yaxis2.titlefont.family': 'monospace', + 'yaxis2.titlefont.size': '5' + }); + + var x2Style = xTitleSel(2).node().style; + expect(xTitleSel(2).text()).toBe('2nd X-Axis'); + expect(x2Style.fill).toBe(rgb('pink')); + expect(x2Style.fontFamily).toBe('sans-serif'); + expect(x2Style.fontSize).toBe('14px'); + + var y2Style = yTitleSel(2).node().style; + expect(yTitleSel(2).text()).toBe('2nd Y-Axis'); + expect(y2Style.fill).toBe(rgb('yellow')); + expect(y2Style.fontFamily).toBe('monospace'); + expect(y2Style.fontSize).toBe('5px'); + }); +}); + +function expectTitle(expTitle) { + expectTitleFn(expTitle)(); +} + +function expectTitleFn(expTitle) { + return function() { + expect(titleSel().text()).toBe(expTitle); + }; +} + +function expectTitleFont(color, family, size) { + expectFont(titleSel(), color, family, size); +} + +function expectXAxisTitleFont(color, family, size) { + expectFont(xTitleSel(), color, family, size); +} + +function expectYAxisTitleFont(color, family, size) { + expectFont(yTitleSel(), color, family, size); +} + +function expectFont(sel, color, family, size) { + var node = sel.node(); + expect(node.style.fill).toBe(rgb(color)); + expect(node.style.fontFamily).toBe(family); + expect(node.style.fontSize).toBe(size + 'px'); +} + +function expectDefaultCenteredPosition(gd) { + var containerBB = gd.getBoundingClientRect(); + + expect(titleX()).toBe(containerBB.width / 2); + expectBaselineInMiddleOfTopMargin(gd); +} + +function expectBaselineInMiddleOfTopMargin(gd) { + var baselineY = calcTextBaselineY(titleSel()); + var topMarginHeight = gd._fullLayout.margin.t; + + expect(baselineY).toBe(topMarginHeight / 2); +} + +function titleX() { + return Number.parseFloat(titleSel().attr('x')); +} + +function calcTextBaselineY(textSel) { + var y = Number.parseFloat(textSel.attr('y')); + var yShift = 0; + var dy = textSel.attr('dy'); + var parsedDy, dyNumValue, dyUnit; + var fontSize, parsedFontSize; + + if(dy) { + parsedDy = /^([0-9.]*)(\w*)$/.exec(dy); + if(parsedDy) { + dyUnit = parsedDy[2]; + dyNumValue = Number.parseFloat(parsedDy[1]); + + if(dyUnit === 'em') { + fontSize = textSel.node().style.fontSize; + parsedFontSize = parseFontSizeAttr(fontSize); + + yShift = dyNumValue * Number.parseFloat(parsedFontSize.val); + } else if(dyUnit === '') { + yShift = dyNumValue; + } else { + fail('Calculating y-shift for unit ' + dyUnit + ' not implemented in test'); + } + } else { + fail('dy value \'' + dy + '\' could not be parsed by test'); + } + } + + return y + yShift; +} + +function calcTextCapLineY(textSel) { + var baselineY = calcTextBaselineY(textSel); + var fontSize = textSel.node().style.fontSize; + var fontSizeVal = parseFontSizeAttr(fontSize).val; + + // CAP_SHIFT is assuming a cap height of an average font. + // One would have to analyze the font metrics of the + // used font to determine an accurate cap shift factor. + return baselineY - fontSizeVal * alignmentConstants.CAP_SHIFT; +} + +function parseFontSizeAttr(fontSizeAttr) { + var parsedFontSize = /^([0-9.]*)px$/.exec(fontSizeAttr); + var isFontSizeInPx = !!parsedFontSize; + expect(isFontSizeInPx).toBe(true, 'Tests assumes font-size is set in pixel'); + + return { + val: parsedFontSize[1], + unit: parsedFontSize[2] + }; +} + +function titleSel() { + var titleSel = d3.select('.infolayer .g-gtitle .gtitle'); + expect(titleSel.empty()).toBe(false, 'Plot title element missing'); + return titleSel; +} + +function xTitleSel(num) { + var axIdx = num === 1 ? '' : (num || ''); + var xTitleSel = d3.select('.x' + axIdx + 'title'); + expect(xTitleSel.empty()).toBe(false, 'X-axis ' + axIdx + ' title element missing'); + return xTitleSel; +} + +function yTitleSel(num) { + var axIdx = num === 1 ? '' : (num || ''); + var yTitleSel = d3.select('.y' + axIdx + 'title'); + expect(yTitleSel.empty()).toBe(false, 'Y-axis ' + axIdx + ' title element missing'); + return yTitleSel; +} + +describe('Editable titles', function() { 'use strict'; var data = [{x: [1, 2, 3], y: [1, 2, 3]}]; @@ -81,9 +1128,9 @@ describe('editable titles', function() { it('has hover effects for blank titles', function(done) { Plotly.plot(gd, data, { - xaxis: {title: ''}, - yaxis: {title: ''}, - title: '' + xaxis: {title: {text: ''}}, + yaxis: {title: {text: ''}}, + title: {text: ''} }, {editable: true}) .then(function() { return Promise.all([ @@ -97,18 +1144,18 @@ describe('editable titles', function() { it('has no hover effects for titles that used to be blank', function(done) { Plotly.plot(gd, data, { - xaxis: {title: ''}, - yaxis: {title: ''}, - title: '' + xaxis: {title: {text: ''}}, + yaxis: {title: {text: ''}}, + title: {text: ''} }, {editable: true}) .then(function() { - return editTitle('x', 'xaxis.title', 'XXX'); + return editTitle('x', 'xaxis.title.text', 'XXX'); }) .then(function() { - return editTitle('y', 'yaxis.title', 'YYY'); + return editTitle('y', 'yaxis.title.text', 'YYY'); }) .then(function() { - return editTitle('g', 'title', 'TTT'); + return editTitle('g', 'title.text', 'TTT'); }) .then(function() { return Promise.all([ diff --git a/test/jasmine/tests/validate_test.js b/test/jasmine/tests/validate_test.js index 0a455f2b4e5..aed5c603233 100644 --- a/test/jasmine/tests/validate_test.js +++ b/test/jasmine/tests/validate_test.js @@ -18,7 +18,9 @@ describe('Plotly.validate', function() { type: 'scatter', x: [1, 2, 3] }], { - title: 'my simple graph' + title: { + text: 'my simple graph' + } }); expect(out).toBeUndefined(); @@ -371,7 +373,9 @@ describe('Plotly.validate', function() { }] }), ], { - title: 'my transformed graph' + title: { + text: 'my transformed graph' + } }); expect(out.length).toEqual(5);