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
+ }
+ }
+}