diff --git a/src/components/titles/index.js b/src/components/titles/index.js index 96c5a5fc550..52fbc346621 100644 --- a/src/components/titles/index.js +++ b/src/components/titles/index.js @@ -20,10 +20,7 @@ var Color = require('../color'); var svgTextUtils = require('../../lib/svg_text_utils'); var interactConstants = require('../../constants/interactions'); -module.exports = { - draw: draw -}; - +var OPPOSITE_SIDE = require('../../constants/alignment').OPPOSITE_SIDE; var numStripRE = / [XY][0-9]* /; /** @@ -167,16 +164,10 @@ function draw(gd, titleClass, options) { // move toward avoid.side (= left, right, top, bottom) if needed // can include pad (pixels, default 2) - var shift = 0; - var backside = { - left: 'right', - right: 'left', - top: 'bottom', - bottom: 'top' - }[avoid.side]; - var shiftSign = (['left', 'top'].indexOf(avoid.side) !== -1) ? - -1 : 1; + var backside = OPPOSITE_SIDE[avoid.side]; + var shiftSign = (avoid.side === 'left' || avoid.side === 'top') ? -1 : 1; var pad = isNumeric(avoid.pad) ? avoid.pad : 2; + var titlebb = Drawing.bBox(titleGroup.node()); var paperbb = { left: 0, @@ -184,12 +175,15 @@ function draw(gd, titleClass, options) { right: fullLayout.width, bottom: fullLayout.height }; - var maxshift = avoid.maxShift || ( - (paperbb[avoid.side] - titlebb[avoid.side]) * - ((avoid.side === 'left' || avoid.side === 'top') ? -1 : 1)); + + var maxshift = avoid.maxShift || + shiftSign * (paperbb[avoid.side] - titlebb[avoid.side]); + var shift = 0; + // Prevent the title going off the paper - if(maxshift < 0) shift = maxshift; - else { + if(maxshift < 0) { + shift = maxshift; + } else { // so we don't have to offset each avoided element, // give the title the opposite offset var offsetLeft = avoid.offsetLeft || 0; @@ -211,6 +205,7 @@ function draw(gd, titleClass, options) { }); shift = Math.min(maxshift, shift); } + if(shift > 0 || maxshift < 0) { var shiftTemplate = { left: [-shift, 0], @@ -218,8 +213,7 @@ function draw(gd, titleClass, options) { top: [0, -shift], bottom: [0, shift] }[avoid.side]; - titleGroup.attr('transform', - 'translate(' + shiftTemplate + ')'); + titleGroup.attr('transform', 'translate(' + shiftTemplate + ')'); } } } @@ -265,3 +259,7 @@ function draw(gd, titleClass, options) { return group; } + +module.exports = { + draw: draw +}; diff --git a/src/plots/cartesian/axes.js b/src/plots/cartesian/axes.js index 2f3c4c7f59c..db386dd6bf8 100644 --- a/src/plots/cartesian/axes.js +++ b/src/plots/cartesian/axes.js @@ -32,9 +32,11 @@ var ONESEC = constants.ONESEC; var MINUS_SIGN = constants.MINUS_SIGN; var BADNUM = constants.BADNUM; -var MID_SHIFT = require('../../constants/alignment').MID_SHIFT; -var LINE_SPACING = require('../../constants/alignment').LINE_SPACING; -var OPPOSITE_SIDE = require('../../constants/alignment').OPPOSITE_SIDE; +var alignmentConstants = require('../../constants/alignment'); +var MID_SHIFT = alignmentConstants.MID_SHIFT; +var CAP_SHIFT = alignmentConstants.CAP_SHIFT; +var LINE_SPACING = alignmentConstants.LINE_SPACING; +var OPPOSITE_SIDE = alignmentConstants.OPPOSITE_SIDE; var axes = module.exports = {}; @@ -1831,7 +1833,6 @@ axes.drawOne = function(gd, ax, opts) { if(ax.type === 'multicategory') { var pad = {x: 2, y: 10}[axLetter]; - var sgn = {l: -1, t: -1, r: 1, b: 1}[ax.side.charAt(0)]; seq.push(function() { var bboxKey = {x: 'height', y: 'width'}[axLetter]; @@ -1845,20 +1846,24 @@ axes.drawOne = function(gd, ax, opts) { repositionOnUpdate: true, secondary: true, transFn: transFn, - labelFns: axes.makeLabelFns(ax, mainLinePosition + standoff * sgn) + labelFns: axes.makeLabelFns(ax, mainLinePosition + standoff * tickSigns[4]) }); }); seq.push(function() { - ax._depth = sgn * (getLabelLevelBbox('tick2')[ax.side] - mainLinePosition); + ax._depth = tickSigns[4] * (getLabelLevelBbox('tick2')[ax.side] - mainLinePosition); return drawDividers(gd, ax, { vals: dividerVals, layer: mainAxLayer, - path: axes.makeTickPath(ax, mainLinePosition, sgn, ax._depth), + path: axes.makeTickPath(ax, mainLinePosition, tickSigns[4], ax._depth), transFn: transFn }); }); + } else if(ax.title.hasOwnProperty('standoff')) { + seq.push(function() { + ax._depth = tickSigns[4] * (getLabelLevelBbox()[ax.side] - mainLinePosition); + }); } var hasRangeSlider = Registry.getComponentMethod('rangeslider', 'isVisible')(ax); @@ -1936,10 +1941,7 @@ axes.drawOne = function(gd, ax, opts) { ax._anchorAxis.domain[domainIndices[0]]; if(ax.title.text !== fullLayout._dfltTitle[axLetter]) { - var extraLines = (ax.title.text.match(svgTextUtils.BR_TAG_ALL) || []).length; - push[s] += extraLines ? - ax.title.font.size * (extraLines + 1) * LINE_SPACING : - ax.title.font.size; + push[s] += approxTitleDepth(ax) + (ax.title.standoff || 0); } if(ax.mirror && ax.anchor !== 'free') { @@ -2097,6 +2099,7 @@ function calcLabelLevelBbox(ax, cls) { * - [1]: sign for bottom/left ticks (i.e. positive SVG direction) * - [2]: sign for ticks corresponding to 'ax.side' * - [3]: sign for ticks mirroring 'ax.side' + * - [4]: sign of arrow starting at axis pointing towards margin */ axes.getTickSigns = function(ax) { var axLetter = ax._id.charAt(0); @@ -2107,6 +2110,10 @@ axes.getTickSigns = function(ax) { if((ax.ticks !== 'inside') === (axLetter === 'x')) { out = out.map(function(v) { return -v; }); } + // independent of `ticks`; do not flip this one + if(ax.side) { + out.push({l: -1, t: -1, r: 1, b: 1}[ax.side.charAt(0)]); + } return out; }; @@ -2699,6 +2706,46 @@ axes.getPxPosition = function(gd, ax) { } }; +/** + * Approximate axis title depth (w/o computing its bounding box) + * + * @param {object} ax (full) axis object + * - {string} title.text + * - {number} title.font.size + * - {number} title.standoff + * @return {number} (in px) + */ +function approxTitleDepth(ax) { + var fontSize = ax.title.font.size; + var extraLines = (ax.title.text.match(svgTextUtils.BR_TAG_ALL) || []).length; + if(ax.title.hasOwnProperty('standoff')) { + return extraLines ? + fontSize * (CAP_SHIFT + (extraLines * LINE_SPACING)) : + fontSize * CAP_SHIFT; + } else { + return extraLines ? + fontSize * (extraLines + 1) * LINE_SPACING : + fontSize; + } +} + +/** + * Draw axis title, compute default standoff if necessary + * + * @param {DOM element} gd + * @param {object} ax (full) axis object + * - {string} _id + * - {string} _name + * - {string} side + * - {number} title.font.size + * - {object} _selections + * + * - {number} _depth + * - {number} title.standoff + * OR + * - {number} linewidth + * - {boolean} showticklabels + */ function drawTitle(gd, ax) { var fullLayout = gd._fullLayout; var axId = ax._id; @@ -2706,11 +2753,26 @@ function drawTitle(gd, ax) { var fontSize = ax.title.font.size; var titleStandoff; - if(ax.type === 'multicategory') { - titleStandoff = ax._depth; + + if(ax.title.hasOwnProperty('standoff')) { + titleStandoff = ax._depth + ax.title.standoff + approxTitleDepth(ax); } else { - var offsetBase = 1.5; - titleStandoff = 10 + fontSize * offsetBase + (ax.linewidth ? ax.linewidth - 1 : 0); + if(ax.type === 'multicategory') { + titleStandoff = ax._depth; + } else { + var offsetBase = 1.5; + titleStandoff = 10 + fontSize * offsetBase + (ax.linewidth ? ax.linewidth - 1 : 0); + } + + if(axLetter === 'x') { + titleStandoff += ax.side === 'top' ? + fontSize * (ax.showticklabels ? 1 : 0) : + fontSize * (ax.showticklabels ? 1.5 : 0.5); + } else { + titleStandoff += ax.side === 'right' ? + fontSize * (ax.showticklabels ? 1 : 0.5) : + fontSize * (ax.showticklabels ? 0.5 : 0); + } } var pos = axes.getPxPosition(gd, ax); @@ -2718,23 +2780,10 @@ function drawTitle(gd, ax) { if(axLetter === 'x') { x = ax._offset + ax._length / 2; - - if(ax.side === 'top') { - y = -titleStandoff - fontSize * (ax.showticklabels ? 1 : 0); - } else { - y = titleStandoff + fontSize * (ax.showticklabels ? 1.5 : 0.5); - } - y += pos; + y = (ax.side === 'top') ? pos - titleStandoff : pos + titleStandoff; } else { y = ax._offset + ax._length / 2; - - if(ax.side === 'right') { - x = titleStandoff + fontSize * (ax.showticklabels ? 1 : 0.5); - } else { - x = -titleStandoff - fontSize * (ax.showticklabels ? 0.5 : 0); - } - x += pos; - + x = (ax.side === 'right') ? pos + titleStandoff : pos - titleStandoff; transform = {rotate: '-90', offset: 0}; } @@ -2753,6 +2802,10 @@ function drawTitle(gd, ax) { avoid.offsetLeft = translation.x; avoid.offsetTop = translation.y; } + + if(ax.title.hasOwnProperty('standoff')) { + avoid.pad = 0; + } } return Titles.draw(gd, axId + 'title', { diff --git a/src/plots/cartesian/layout_attributes.js b/src/plots/cartesian/layout_attributes.js index 41106d1686e..713d79bf815 100644 --- a/src/plots/cartesian/layout_attributes.js +++ b/src/plots/cartesian/layout_attributes.js @@ -62,6 +62,21 @@ module.exports = { 'by the now deprecated `titlefont` attribute.' ].join(' ') }), + standoff: { + valType: 'number', + role: 'info', + min: 0, + editType: 'ticks', + description: [ + 'Sets the standoff distance (in px) between the axis labels and the title text', + 'The default value is a function of the axis tick labels, the title `font.size`', + 'and the axis `linewidth`.', + 'Note that the axis title position is always constrained within the margins,', + 'so the actual standoff distance is always less than the set or default value.', + 'By setting `standoff` and turning on `automargin`, plotly.js will push the', + 'margins to fit the axis title at given standoff distance.' + ].join(' ') + }, editType: 'ticks' }, type: { diff --git a/src/plots/cartesian/layout_defaults.js b/src/plots/cartesian/layout_defaults.js index 3865a3751fb..58c86d5fc4b 100644 --- a/src/plots/cartesian/layout_defaults.js +++ b/src/plots/cartesian/layout_defaults.js @@ -235,6 +235,8 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) { grid: layoutOut.grid }); + coerce('title.standoff'); + axLayoutOut._input = axLayoutIn; } diff --git a/src/plots/gl3d/layout/axis_attributes.js b/src/plots/gl3d/layout/axis_attributes.js index 6699e73575a..28babfcf585 100644 --- a/src/plots/gl3d/layout/axis_attributes.js +++ b/src/plots/gl3d/layout/axis_attributes.js @@ -71,7 +71,10 @@ module.exports = overrideAll({ color: axesAttrs.color, categoryorder: axesAttrs.categoryorder, categoryarray: axesAttrs.categoryarray, - title: axesAttrs.title, + title: { + text: axesAttrs.title.text, + font: axesAttrs.title.font + }, type: extendFlat({}, axesAttrs.type, { values: ['-', 'linear', 'log', 'date', 'category'] }), diff --git a/src/plots/polar/layout_attributes.js b/src/plots/polar/layout_attributes.js index f6905453dd9..82c5d9cf699 100644 --- a/src/plots/polar/layout_attributes.js +++ b/src/plots/polar/layout_attributes.js @@ -114,8 +114,18 @@ var radialAxisAttrs = { }, - title: overrideAll(axesAttrs.title, 'plot', 'from-root'), - // might need a 'titleside' and even 'titledirection' down the road + title: { + // radial title is not gui-editable at the moment, + // so it needs dflt: '', similar to carpet axes. + text: extendFlat({}, axesAttrs.title.text, {editType: 'plot', dflt: ''}), + font: extendFlat({}, axesAttrs.title.font, {editType: 'plot'}), + + // TODO + // - might need a 'titleside' and even 'titledirection' down the road + // - what about standoff ?? + + editType: 'plot' + }, hoverformat: axesAttrs.hoverformat, @@ -138,9 +148,6 @@ var radialAxisAttrs = { } }; -// radial title is not gui-editable, so it needs dflt: '', similar to carpet axes. -radialAxisAttrs.title.text.dflt = ''; - extendFlat( radialAxisAttrs, diff --git a/src/plots/ternary/layout_attributes.js b/src/plots/ternary/layout_attributes.js index effa824be8d..b684b477a09 100644 --- a/src/plots/ternary/layout_attributes.js +++ b/src/plots/ternary/layout_attributes.js @@ -16,7 +16,11 @@ var overrideAll = require('../../plot_api/edit_types').overrideAll; var extendFlat = require('../../lib/extend').extendFlat; var ternaryAxesAttrs = { - title: axesAttrs.title, + title: { + text: axesAttrs.title.text, + font: axesAttrs.title.font + // TODO does standoff here make sense? + }, color: axesAttrs.color, // ticks tickmode: axesAttrs.tickmode, diff --git a/src/traces/carpet/axis_attributes.js b/src/traces/carpet/axis_attributes.js index 9d3fb672039..a6671494428 100644 --- a/src/traces/carpet/axis_attributes.js +++ b/src/traces/carpet/axis_attributes.js @@ -57,6 +57,7 @@ module.exports = { 'by the now deprecated `titlefont` attribute.' ].join(' ') }), + // TODO how is this different than `title.standoff` offset: { valType: 'number', role: 'info', diff --git a/test/image/baselines/automargin-title-standoff.png b/test/image/baselines/automargin-title-standoff.png new file mode 100644 index 00000000000..6bcc837fa71 Binary files /dev/null and b/test/image/baselines/automargin-title-standoff.png differ diff --git a/test/image/baselines/axis-title-standoff.png b/test/image/baselines/axis-title-standoff.png new file mode 100644 index 00000000000..ab92f3a55b6 Binary files /dev/null and b/test/image/baselines/axis-title-standoff.png differ diff --git a/test/image/mocks/automargin-title-standoff.json b/test/image/mocks/automargin-title-standoff.json new file mode 100644 index 00000000000..571a054e8a3 --- /dev/null +++ b/test/image/mocks/automargin-title-standoff.json @@ -0,0 +1,65 @@ +{ + "data": [ + {"x": ["looooooooooong label"], "y": [1]}, + {"y": ["looooooooooong label"], "x": [1], "xaxis": "x2", "yaxis": "y2"} + ], + "layout": { + "grid": {"rows": 1, "columns": 2, "pattern": "independent"}, + "width": 600, + "height": 500, + "margin": {"l": 0, "r": 0, "t": 0, "b": 0}, + "showlegend": false, + "title": { + "text": "With axis automargin:true
with margin:0", + "x": 0, + "xanchor": "left", + "xref": "paper" + }, + "xaxis": { + "title": { + "text": "X Axis (standoff: 60)", + "standoff": 60, + "font": {"size": 25} + }, + "automargin": true, + "showline": true, + "mirror": true, + "ticks": "outside", + "tickangle": 20 + }, + "yaxis": { + "title": { + "text": "Y
Axis (standoff: 100)", + "standoff": 100, + "font": {"size": 20} + }, + "automargin": true, + "showline": true, + "mirror": true + }, + "xaxis2": { + "anchor": "y2", + "side": "top", + "title": { + "text": "X
Axis 2
(standoff: 80)", + "standoff": 80 + }, + "automargin": true, + "showline": true, + "mirror": true + }, + "yaxis2": { + "anchor": "x2", + "side": "right", + "title": { + "text": "Y Axis 2 (standoff: 30)", + "standoff": 30 + }, + "automargin": true, + "showline": true, + "mirror": true, + "ticks": "outside", + "tickangle": 80 + } + } +} diff --git a/test/image/mocks/axis-title-standoff.json b/test/image/mocks/axis-title-standoff.json new file mode 100644 index 00000000000..30789e6dd3e --- /dev/null +++ b/test/image/mocks/axis-title-standoff.json @@ -0,0 +1,59 @@ +{ + "data": [ + {"x": ["looooooooooong label"], "y": [1]}, + {"y": ["looooooooooong label"], "x": [1], "xaxis": "x2", "yaxis": "y2"} + ], + "layout": { + "grid": {"rows": 1, "columns": 2, "pattern": "independent"}, + "width": 600, + "height": 500, + "showlegend": false, + "title": { + "text": "No axis automargin:true
with default margins", + "x": 0, + "xanchor": "left", + "xref": "paper" + }, + "xaxis": { + "title": { + "text": "X Axis (standoff:10)", + "standoff": 10, + "font": {"size": 25} + }, + "showline": true, + "mirror": true, + "ticks": "outside" + }, + "yaxis": { + "title": { + "text": "Y
Axis (standoff:8)", + "standoff": 0, + "font": {"size": 8} + }, + "showline": true, + "mirror": true + }, + "xaxis2": { + "anchor": "y2", + "side": "top", + "title": { + "text": "X
Axis 2
(standoff:0)", + "standoff": 0 + }, + "showline": true, + "mirror": true + }, + "yaxis2": { + "anchor": "x2", + "side": "right", + "title": { + "text": "Y Axis 2 (standoff:0)", + "standoff": 0 + }, + "showline": true, + "mirror": true, + "ticks": "outside", + "tickangle": 80 + } + } +}