diff --git a/draftlogs/6428_add.md b/draftlogs/6428_add.md new file mode 100644 index 00000000000..be70d5535e2 --- /dev/null +++ b/draftlogs/6428_add.md @@ -0,0 +1 @@ + - Add `title.automargin` to enable automatic margining for both container and paper referenced titles [[#6428](https://github.com/plotly/plotly.js/pull/6428)], with thanks to [Gamma Technologies](https://www.gtisoft.com/) for sponsoring the related development. diff --git a/src/components/titles/index.js b/src/components/titles/index.js index 0e6b4a07eeb..41b5c85b9b8 100644 --- a/src/components/titles/index.js +++ b/src/components/titles/index.js @@ -167,11 +167,21 @@ function draw(gd, titleClass, options) { var pad = isNumeric(avoid.pad) ? avoid.pad : 2; var titlebb = Drawing.bBox(titleGroup.node()); + + // Account for reservedMargins + var reservedMargins = {t: 0, b: 0, l: 0, r: 0}; + var margins = gd._fullLayout._reservedMargin; + for(var key in margins) { + for(var side in margins[key]) { + var val = margins[key][side]; + reservedMargins[side] = Math.max(reservedMargins[side], val); + } + } var paperbb = { - left: 0, - top: 0, - right: fullLayout.width, - bottom: fullLayout.height + left: reservedMargins.l, + top: reservedMargins.t, + right: fullLayout.width - reservedMargins.r, + bottom: fullLayout.height - reservedMargins.b }; var maxshift = avoid.maxShift || diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 77fd6ee9ccc..7088f08eb3d 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -277,6 +277,7 @@ function _doPlot(gd, data, layout, config) { subroutines.drawMarginPushers(gd); Axes.allowAutoMargin(gd); + if(gd._fullLayout.title.text && gd._fullLayout.title.automargin) Plots.allowAutoMargin(gd, 'title.automargin'); // TODO can this be moved elsewhere? if(fullLayout._has('pie')) { diff --git a/src/plot_api/subroutines.js b/src/plot_api/subroutines.js index 9ec735d48de..1386e1e8ab1 100644 --- a/src/plot_api/subroutines.js +++ b/src/plot_api/subroutines.js @@ -5,6 +5,7 @@ var Registry = require('../registry'); var Plots = require('../plots/plots'); var Lib = require('../lib'); +var svgTextUtils = require('../lib/svg_text_utils'); var clearGlCanvases = require('../lib/clear_gl_canvases'); var Color = require('../components/color'); @@ -397,24 +398,120 @@ function findCounterAxisLineWidth(ax, side, counterAx, axList) { } exports.drawMainTitle = function(gd) { + var title = gd._fullLayout.title; var fullLayout = gd._fullLayout; - var textAnchor = getMainTitleTextAnchor(fullLayout); var dy = getMainTitleDy(fullLayout); + var y = getMainTitleY(fullLayout, dy); + var x = getMainTitleX(fullLayout, textAnchor); Titles.draw(gd, 'gtitle', { propContainer: fullLayout, propName: 'title.text', placeholder: fullLayout._dfltTitle.plot, - attributes: { - x: getMainTitleX(fullLayout, textAnchor), - y: getMainTitleY(fullLayout, dy), + attributes: ({ + x: x, + y: y, 'text-anchor': textAnchor, dy: dy - } + }) }); + + if(title.text && title.automargin) { + var titleObj = d3.selectAll('.gtitle'); + var titleHeight = Drawing.bBox(titleObj.node()).height; + var pushMargin = needsMarginPush(gd, title, titleHeight); + if(pushMargin > 0) { + applyTitleAutoMargin(gd, y, pushMargin, titleHeight); + // Re-position the title once we know where it needs to be + titleObj.attr({ + x: x, + y: y, + 'text-anchor': textAnchor, + dy: getMainTitleDyAdj(title.yanchor) + }).call(svgTextUtils.positionText, x, y); + } + } }; + +function isOutsideContainer(gd, title, position, y, titleHeight) { + var plotHeight = title.yref === 'paper' ? gd._fullLayout._size.h : gd._fullLayout.height; + var yPosTop = Lib.isTopAnchor(title) ? y : y - titleHeight; // Standardize to the top of the title + var yPosRel = position === 'b' ? plotHeight - yPosTop : yPosTop; // Position relative to the top or bottom of plot + if((Lib.isTopAnchor(title) && position === 't') || Lib.isBottomAnchor(title) && position === 'b') { + return false; + } else { + return yPosRel < titleHeight; + } +} + +function containerPushVal(position, titleY, titleYanchor, height, titleDepth) { + var push = 0; + if(titleYanchor === 'middle') { + push += titleDepth / 2; + } + if(position === 't') { + if(titleYanchor === 'top') { + push += titleDepth; + } + push += (height - titleY * height); + } else { + if(titleYanchor === 'bottom') { + push += titleDepth; + } + push += titleY * height; + } + return push; +} + +function needsMarginPush(gd, title, titleHeight) { + var titleY = title.y; + var titleYanchor = title.yanchor; + var position = titleY > 0.5 ? 't' : 'b'; + var curMargin = gd._fullLayout.margin[position]; + var pushMargin = 0; + if(title.yref === 'paper') { + pushMargin = ( + titleHeight + + title.pad.t + + title.pad.b + ); + } else if(title.yref === 'container') { + pushMargin = ( + containerPushVal(position, titleY, titleYanchor, gd._fullLayout.height, titleHeight) + + title.pad.t + + title.pad.b + ); + } + if(pushMargin > curMargin) { + return pushMargin; + } + return 0; +} + +function applyTitleAutoMargin(gd, y, pushMargin, titleHeight) { + var titleID = 'title.automargin'; + var title = gd._fullLayout.title; + var position = title.y > 0.5 ? 't' : 'b'; + var push = { + x: title.x, + y: title.y, + t: 0, + b: 0 + }; + var reservedPush = {}; + + if(title.yref === 'paper' && isOutsideContainer(gd, title, position, y, titleHeight)) { + push[position] = pushMargin; + } else if(title.yref === 'container') { + reservedPush[position] = pushMargin; + gd._fullLayout._reservedMargin[titleID] = reservedPush; + } + Plots.allowAutoMargin(gd, titleID); + Plots.autoMargin(gd, titleID, push); +} + function getMainTitleX(fullLayout, textAnchor) { var title = fullLayout.title; var gs = fullLayout._size; @@ -439,7 +536,6 @@ 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') { @@ -459,6 +555,16 @@ function getMainTitleY(fullLayout, dy) { } } +function getMainTitleDyAdj(yanchor) { + if(yanchor === 'top') { + return alignmentConstants.CAP_SHIFT + 0.3 + 'em'; + } else if(yanchor === 'bottom') { + return '-0.3em'; + } else { + return alignmentConstants.MID_SHIFT + 'em'; + } +} + function getMainTitleTextAnchor(fullLayout) { var title = fullLayout.title; diff --git a/src/plots/layout_attributes.js b/src/plots/layout_attributes.js index 38fe31ed189..a0cb4e412d3 100644 --- a/src/plots/layout_attributes.js +++ b/src/plots/layout_attributes.js @@ -125,6 +125,21 @@ module.exports = { 'Padding is muted if the respective anchor value is *middle*/*center*.' ].join(' ') }), + automargin: { + valType: 'boolean', + dflt: false, + editType: 'plot', + description: [ + 'Determines whether the title can automatically push the figure margins.', + 'If `yref=\'paper\'` then the margin will expand to ensure that the title doesn\’t', + 'overlap with the edges of the container. If `yref=\'container\'` then the margins', + 'will ensure that the title doesn\’t overlap with the plot area, tick labels,', + 'and axis titles. If `automargin=true` and the margins need to be expanded,', + 'then y will be set to a default 1 and yanchor will be set to an appropriate', + 'default to ensure that minimal margin space is needed. Note that when `yref=\'paper\'`,', + 'only 1 or 0 are allowed y values. Invalid values will be reset to the default 1.' + ].join(' ') + }, editType: 'layoutstyle' }, uniformtext: { diff --git a/src/plots/plots.js b/src/plots/plots.js index 1a61f90bd8c..385df065112 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -1480,15 +1480,41 @@ plots.supplyLayoutGlobalDefaults = function(layoutIn, layoutOut, formatObj) { coerce('title.text', layoutOut._dfltTitle.plot); coerce('title.xref'); - coerce('title.yref'); - coerce('title.x'); - coerce('title.y'); - coerce('title.xanchor'); - coerce('title.yanchor'); + var titleYref = coerce('title.yref'); coerce('title.pad.t'); coerce('title.pad.r'); coerce('title.pad.b'); coerce('title.pad.l'); + var titleAutomargin = coerce('title.automargin'); + + coerce('title.x'); + coerce('title.xanchor'); + coerce('title.y'); + coerce('title.yanchor'); + + if(titleAutomargin) { + // when automargin=true + // title.y is 1 or 0 if paper ref + // 'auto' is not supported for either title.y or title.yanchor + + // TODO: mention this smart default in the title.y and title.yanchor descriptions + + if(titleYref === 'paper') { + if(layoutOut.title.y !== 0) layoutOut.title.y = 1; + + if(layoutOut.title.yanchor === 'auto') { + layoutOut.title.yanchor = layoutOut.title.y === 0 ? 'top' : 'bottom'; + } + } + + if(titleYref === 'container') { + if(layoutOut.title.y === 'auto') layoutOut.title.y = 1; + + if(layoutOut.title.yanchor === 'auto') { + layoutOut.title.yanchor = layoutOut.title.y < 0.5 ? 'bottom' : 'top'; + } + } + } var uniformtextMode = coerce('uniformtext.mode'); if(uniformtextMode) { @@ -1862,6 +1888,7 @@ function initMargins(fullLayout) { } if(!fullLayout._pushmargin) fullLayout._pushmargin = {}; if(!fullLayout._pushmarginIds) fullLayout._pushmarginIds = {}; + if(!fullLayout._reservedMargin) fullLayout._reservedMargin = {}; } // non-negotiable - this is the smallest height we will allow users to specify via explicit margins @@ -1979,8 +2006,16 @@ plots.doAutoMargin = function(gd) { var gs = fullLayout._size; var margin = fullLayout.margin; + var reservedMargins = {t: 0, b: 0, l: 0, r: 0}; var oldMargins = Lib.extendFlat({}, gs); + var margins = gd._fullLayout._reservedMargin; + for(var key in margins) { + for(var side in margins[key]) { + var val = margins[key][side]; + reservedMargins[side] = Math.max(reservedMargins[side], val); + } + } // adjust margins for outside components // fullLayout.margin is the requested margin, // fullLayout._size has margins and plotsize after adjustment @@ -2016,14 +2051,16 @@ plots.doAutoMargin = function(gd) { var pl = pushleft.size; var fb = pushbottom.val; var pb = pushbottom.size; + var availableWidth = width - reservedMargins.r - reservedMargins.l; + var availableHeight = height - reservedMargins.t - reservedMargins.b; for(var k2 in pushMargin) { if(isNumeric(pl) && pushMargin[k2].r) { var fr = pushMargin[k2].r.val; var pr = pushMargin[k2].r.size; if(fr > fl) { - var newL = (pl * fr + (pr - width) * fl) / (fr - fl); - var newR = (pr * (1 - fl) + (pl - width) * (1 - fr)) / (fr - fl); + var newL = (pl * fr + (pr - availableWidth) * fl) / (fr - fl); + var newR = (pr * (1 - fl) + (pl - availableWidth) * (1 - fr)) / (fr - fl); if(newL + newR > ml + mr) { ml = newL; mr = newR; @@ -2035,8 +2072,8 @@ plots.doAutoMargin = function(gd) { var ft = pushMargin[k2].t.val; var pt = pushMargin[k2].t.size; if(ft > fb) { - var newB = (pb * ft + (pt - height) * fb) / (ft - fb); - var newT = (pt * (1 - fb) + (pb - height) * (1 - ft)) / (ft - fb); + var newB = (pb * ft + (pt - availableHeight) * fb) / (ft - fb); + var newT = (pt * (1 - fb) + (pb - availableHeight) * (1 - ft)) / (ft - fb); if(newB + newT > mb + mt) { mb = newB; mt = newT; @@ -2078,10 +2115,11 @@ plots.doAutoMargin = function(gd) { } } - gs.l = Math.round(ml); - gs.r = Math.round(mr); - gs.t = Math.round(mt); - gs.b = Math.round(mb); + + gs.l = Math.round(ml) + reservedMargins.l; + gs.r = Math.round(mr) + reservedMargins.r; + gs.t = Math.round(mt) + reservedMargins.t; + gs.b = Math.round(mb) + reservedMargins.b; gs.p = Math.round(margin.pad); gs.w = Math.round(width) - gs.l - gs.r; gs.h = Math.round(height) - gs.t - gs.b; diff --git a/test/image/baselines/zzz-automargin-title-container-bottom.png b/test/image/baselines/zzz-automargin-title-container-bottom.png new file mode 100644 index 00000000000..7e78abf8fb1 Binary files /dev/null and b/test/image/baselines/zzz-automargin-title-container-bottom.png differ diff --git a/test/image/baselines/zzz-automargin-title-container.png b/test/image/baselines/zzz-automargin-title-container.png new file mode 100644 index 00000000000..94d999b0004 Binary files /dev/null and b/test/image/baselines/zzz-automargin-title-container.png differ diff --git a/test/image/baselines/zzz-automargin-title-paper-bottom.png b/test/image/baselines/zzz-automargin-title-paper-bottom.png new file mode 100644 index 00000000000..b7599df7665 Binary files /dev/null and b/test/image/baselines/zzz-automargin-title-paper-bottom.png differ diff --git a/test/image/baselines/zzz-automargin-title-paper-multiline.png b/test/image/baselines/zzz-automargin-title-paper-multiline.png new file mode 100644 index 00000000000..a83a9e493b5 Binary files /dev/null and b/test/image/baselines/zzz-automargin-title-paper-multiline.png differ diff --git a/test/image/baselines/zzz-automargin-title-paper.png b/test/image/baselines/zzz-automargin-title-paper.png new file mode 100644 index 00000000000..5a9e78bdd84 Binary files /dev/null and b/test/image/baselines/zzz-automargin-title-paper.png differ diff --git a/test/image/mocks/zzz-automargin-title-container-bottom.json b/test/image/mocks/zzz-automargin-title-container-bottom.json new file mode 100644 index 00000000000..638cc8e2268 --- /dev/null +++ b/test/image/mocks/zzz-automargin-title-container-bottom.json @@ -0,0 +1,30 @@ +{ + "data": [ + { + "showlegend": false, + "type": "scatter", + "x": [ + 1, + 2, + 3 + ], + "y": [ + 4, + 5, + 6 + ] + }], + "layout": { + "height": 300, + "width": 400, + "margin": {"t":0, "b": 0, "l": 0, "r": 0}, + "xaxis": {"automargin": true, "title": {"text": "x-axis title"}}, + "title": { + "automargin": true, + "text": "Container | pad | y=0", + "pad": {"t": 15, "b": 10}, + "y": 0, + "yref": "container" + } + } +} diff --git a/test/image/mocks/zzz-automargin-title-container.json b/test/image/mocks/zzz-automargin-title-container.json new file mode 100644 index 00000000000..c6f8cee49da --- /dev/null +++ b/test/image/mocks/zzz-automargin-title-container.json @@ -0,0 +1,29 @@ +{ + "data": [ + { + "showlegend": false, + "type": "scatter", + "x": [ + 1, + 2, + 3 + ], + "y": [ + 4, + 5, + 6 + ] + }], + "layout": { + "height": 300, + "width": 400, + "margin": {"t":0, "b": 0, "l": 0, "r": 0}, + "xaxis": {"automargin": true, "title": {"text": "x-axis title"}}, + "title": { + "automargin": true, + "text": "Container | no-pad | y=1", + "pad": {"t": 0, "b": 0}, + "yref": "container" + } + } +} diff --git a/test/image/mocks/zzz-automargin-title-paper-bottom.json b/test/image/mocks/zzz-automargin-title-paper-bottom.json new file mode 100644 index 00000000000..70c6a260b17 --- /dev/null +++ b/test/image/mocks/zzz-automargin-title-paper-bottom.json @@ -0,0 +1,29 @@ +{ + "data": [ + { + "showlegend": false, + "type": "scatter", + "x": [ + 1, + 2, + 3 + ], + "y": [ + 4, + 5, + 6 + ] + }], + "layout": { + "height": 300, + "width": 400, + "margin": {"t":0, "b": 0, "l": 0, "r": 0}, + "title": { + "automargin": true, + "text": "Paper | pad | y=0", + "pad": {"t": 15, "b": 10}, + "yref": "paper", + "y": 0 + } + } +} diff --git a/test/image/mocks/zzz-automargin-title-paper-multiline.json b/test/image/mocks/zzz-automargin-title-paper-multiline.json new file mode 100644 index 00000000000..c97f1c810f1 --- /dev/null +++ b/test/image/mocks/zzz-automargin-title-paper-multiline.json @@ -0,0 +1,28 @@ +{ + "data": [ + { + "showlegend": false, + "type": "scatter", + "x": [ + 1, + 2, + 3 + ], + "y": [ + 4, + 5, + 6 + ] + }], + "layout": { + "height": 300, + "width": 400, + "margin": {"t":0, "b": 0, "l": 0, "r": 0}, + "title": { + "automargin": true, + "text": "Paper
Multi-line", + "pad": {"t": 0, "b": 0}, + "yref": "paper" + } + } +} diff --git a/test/image/mocks/zzz-automargin-title-paper.json b/test/image/mocks/zzz-automargin-title-paper.json new file mode 100644 index 00000000000..e029e208068 --- /dev/null +++ b/test/image/mocks/zzz-automargin-title-paper.json @@ -0,0 +1,28 @@ +{ + "data": [ + { + "showlegend": false, + "type": "scatter", + "x": [ + 1, + 2, + 3 + ], + "y": [ + 4, + 5, + 6 + ] + }], + "layout": { + "height": 300, + "width": 400, + "margin": {"t":0, "b": 0, "l": 0, "r": 0}, + "title": { + "automargin": true, + "text": "Paper | no-pad | y=1", + "pad": {"t": 0, "b": 0}, + "yref": "paper" + } + } +} diff --git a/test/jasmine/tests/titles_test.js b/test/jasmine/tests/titles_test.js index 3f1db329007..75ac84a30b8 100644 --- a/test/jasmine/tests/titles_test.js +++ b/test/jasmine/tests/titles_test.js @@ -1066,6 +1066,119 @@ describe('Titles for multiple axes', function() { }); }); +// TODO: Add in tests for interactions with other automargined elements +describe('Title automargining', function() { + 'use strict'; + + var data = [{x: [1, 1, 3], y: [1, 2, 3]}]; + var gd; + + beforeEach(function() { + gd = createGraphDiv(); + }); + + afterEach(destroyGraphDiv); + + it('should avoid overlap with container for yref=paper and allow padding', function(done) { + Plotly.newPlot(gd, data, { + margin: {t: 0, b: 0, l: 0, r: 0}, + height: 300, + width: 400, + title: { + text: 'Basic title', + font: {size: 24}, + yref: 'paper' + } + }).then(function() { + expect(gd._fullLayout._size.t).toBe(0); + expect(gd._fullLayout._size.h).toBe(300); + return Plotly.relayout(gd, 'title.automargin', true); + }).then(function() { + expect(gd._fullLayout.title.automargin).toBe(true); + expect(gd._fullLayout.title.y).toBe(1); + expect(gd._fullLayout.title.yanchor).toBe('bottom'); + expect(gd._fullLayout._size.t).toBeCloseTo(27, -1); + expect(gd._fullLayout._size.h).toBeCloseTo(273, -1); + return Plotly.relayout(gd, 'title.pad.t', 10); + }).then(function() { + expect(gd._fullLayout._size.t).toBeCloseTo(37, -1); + expect(gd._fullLayout._size.h).toBeCloseTo(263, -1); + return Plotly.relayout(gd, 'title.pad.b', 10); + }).then(function() { + expect(gd._fullLayout._size.h).toBeCloseTo(253, -1); + expect(gd._fullLayout._size.t).toBeCloseTo(47, -1); + return Plotly.relayout(gd, 'title.yanchor', 'top'); + }).then(function() { + expect(gd._fullLayout._size.t).toBe(0); + }).then(done, done.fail); + }); + + + it('should automargin and position title at the bottom of the plot if title.y=0', function(done) { + Plotly.newPlot(gd, data, { + margin: {t: 0, b: 0, l: 0, r: 0}, + height: 300, + width: 400, + title: { + text: 'Basic title', + font: {size: 24}, + yref: 'paper' + } + }).then(function() { + return Plotly.relayout(gd, {'title.automargin': true, 'title.y': 0}); + }).then(function() { + expect(gd._fullLayout._size.b).toBeCloseTo(27, -1); + expect(gd._fullLayout._size.h).toBeCloseTo(273, -1); + expect(gd._fullLayout.title.yanchor).toBe('top'); + }).then(done, done.fail); + }); + + it('should avoid overlap with container and plot area for yref=container and allow padding', function(done) { + Plotly.newPlot(gd, data, { + margin: {t: 0, b: 0, l: 0, r: 0}, + height: 300, + width: 400, + title: { + text: 'Basic title', + font: {size: 24}, + yref: 'container', + automargin: true + } + }).then(function() { + expect(gd._fullLayout._size.t).toBeCloseTo(27, -1); + expect(gd._fullLayout._size.h).toBeCloseTo(273, -1); + expect(gd._fullLayout.title.y).toBe(1); + expect(gd._fullLayout.title.yanchor).toBe('top'); + return Plotly.relayout(gd, 'title.y', 0.6); + }).then(function() { + expect(gd._fullLayout._size.t).toBeCloseTo(147, -1); + expect(gd._fullLayout._size.h).toBeCloseTo(153, -1); + }).then(done, done.fail); + }); + + it('should make space for multiple container-referenced components on the same side of the plot', function(done) { + Plotly.newPlot(gd, data, { + margin: {t: 0, b: 0, l: 0, r: 0}, + xaxis: { + automargin: true, + title: {text: 'x-axis title'} + }, + height: 300, + width: 400, + title: { + text: 'Basic title', + font: {size: 24}, + yref: 'container', + automargin: true, + y: 0 + } + }).then(function() { + expect(gd._fullLayout._size.b).toBeCloseTo(57, -1); + expect(gd._fullLayout._size.h).toBeCloseTo(243, -1); + }).then(done, done.fail); + }); +}); + function expectTitle(expTitle) { expectTitleFn(expTitle)(); } diff --git a/test/plot-schema.json b/test/plot-schema.json index 3d99e78433b..411afae1354 100644 --- a/test/plot-schema.json +++ b/test/plot-schema.json @@ -9661,6 +9661,12 @@ } }, "title": { + "automargin": { + "description": "Determines whether the title can automatically push the figure margins. If `yref='paper'` then the margin will expand to ensure that the title doesn’t overlap with the edges of the container. If `yref='container'` then the margins will ensure that the title doesn’t overlap with the plot area, tick labels, and axis titles. If `automargin=true` and the margins need to be expanded, then y will be set to a default 1 and yanchor will be set to an appropriate default to ensure that minimal margin space is needed. Note that when `yref='paper'`, only 1 or 0 are allowed y values. Invalid values will be reset to the default 1.", + "dflt": false, + "editType": "plot", + "valType": "boolean" + }, "editType": "layoutstyle", "font": { "color": {