Skip to content

Commit 9adfc12

Browse files
authored
Merge pull request #7012 from plotly/add-subtitle
Add subtitle to plots
2 parents 5fde4d9 + b8942ee commit 9adfc12

24 files changed

+458
-62
lines changed

.circleci/test.sh

+1-1
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ case $1 in
102102
;;
103103

104104
make-baselines-mathjax3)
105-
python3 test/image/make_baseline.py mathjax3 legend_mathjax_title_and_items mathjax parcats_grid_subplots table_latex_multitrace_scatter table_plain_birds table_wrapped_birds ternary-mathjax || EXIT_STATE=$?
105+
python3 test/image/make_baseline.py mathjax3 legend_mathjax_title_and_items mathjax parcats_grid_subplots table_latex_multitrace_scatter table_plain_birds table_wrapped_birds ternary-mathjax zz-ternary-mathjax-title-place-subtitle || EXIT_STATE=$?
106106
exit $EXIT_STATE
107107
;;
108108

draftlogs/7012_add.md

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- Add `subtitle` attribute to `layout.title` to enable adding subtitles to plots [[#7012](https://github.com/plotly/plotly.js/pull/7012)]

lib/locales/es.js

+1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ module.exports = {
1111
'Click to enter Component B title': 'Introducir el título del Componente B', // plots/ternary/ternary.js:406
1212
'Click to enter Component C title': 'Introducir el título del Componente C', // plots/ternary/ternary.js:417
1313
'Click to enter Plot title': 'Introducir el título de la Gráfica', // plot_api/plot_api.js:579
14+
'Click to enter Plot subtitle': 'Introducir el subtítulo de la Gráfica', // plot_api/plot_api.js:579
1415
'Click to enter X axis title': 'Introducir el título del eje X', // plots/plots.js:301
1516
'Click to enter Y axis title': 'Introducir el título del eje Y', // plots/plots.js:302
1617
'Click to enter radial axis title': 'Introducir el título del eje radial',

lib/locales/fr.js

+1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ module.exports = {
1111
'Click to enter Component B title': 'Ajouter un titre à la composante B',
1212
'Click to enter Component C title': 'Ajouter un titre à la composante C',
1313
'Click to enter Plot title': 'Ajouter un titre au graphique',
14+
'Click to enter Plot subtitle': 'Ajouter un sous-titre au graphique',
1415
'Click to enter X axis title': 'Ajouter un titre à l\'axe des x',
1516
'Click to enter Y axis title': 'Ajouter un titre à l\'axe des y',
1617
'Click to enter radial axis title': 'Ajouter un titre à l\'axe radial',

src/components/titles/index.js

+158-27
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ var interactConstants = require('../../constants/interactions');
1414

1515
var OPPOSITE_SIDE = require('../../constants/alignment').OPPOSITE_SIDE;
1616
var numStripRE = / [XY][0-9]* /;
17+
var SUBTITLE_PADDING_MATHJAX_EM = 1.6;
18+
var SUBTITLE_PADDING_EM = 1.6;
1719

1820
/**
1921
* Titles - (re)draw titles on the axes and plot:
@@ -48,6 +50,8 @@ var numStripRE = / [XY][0-9]* /;
4850
* @return {selection} d3 selection of title container group
4951
*/
5052
function draw(gd, titleClass, options) {
53+
var fullLayout = gd._fullLayout;
54+
5155
var cont = options.propContainer;
5256
var prop = options.propName;
5357
var placeholder = options.placeholder;
@@ -56,13 +60,10 @@ function draw(gd, titleClass, options) {
5660
var attributes = options.attributes;
5761
var transform = options.transform;
5862
var group = options.containerGroup;
59-
60-
var fullLayout = gd._fullLayout;
61-
6263
var opacity = 1;
63-
var isplaceholder = false;
6464
var title = cont.title;
6565
var txt = (title && title.text ? title.text : '').trim();
66+
var titleIsPlaceholder = false;
6667

6768
var font = title && title.font ? title.font : {};
6869
var fontFamily = font.family;
@@ -75,23 +76,58 @@ function draw(gd, titleClass, options) {
7576
var fontLineposition = font.lineposition;
7677
var fontShadow = font.shadow;
7778

79+
// Get subtitle properties
80+
var subtitleProp = options.subtitlePropName;
81+
var subtitleEnabled = !!subtitleProp;
82+
var subtitlePlaceholder = options.subtitlePlaceholder;
83+
var subtitle = (cont.title || {}).subtitle || {text: '', font: {}};
84+
var subtitleTxt = subtitle.text.trim();
85+
var subtitleIsPlaceholder = false;
86+
var subtitleOpacity = 1;
87+
88+
var subtitleFont = subtitle.font;
89+
var subFontFamily = subtitleFont.family;
90+
var subFontSize = subtitleFont.size;
91+
var subFontColor = subtitleFont.color;
92+
var subFontWeight = subtitleFont.weight;
93+
var subFontStyle = subtitleFont.style;
94+
var subFontVariant = subtitleFont.variant;
95+
var subFontTextcase = subtitleFont.textcase;
96+
var subFontLineposition = subtitleFont.lineposition;
97+
var subFontShadow = subtitleFont.shadow;
98+
7899
// only make this title editable if we positively identify its property
79100
// as one that has editing enabled.
101+
// Subtitle is editable if and only if title is editable
80102
var editAttr;
81103
if(prop === 'title.text') editAttr = 'titleText';
82104
else if(prop.indexOf('axis') !== -1) editAttr = 'axisTitleText';
83105
else if(prop.indexOf('colorbar' !== -1)) editAttr = 'colorbarTitleText';
84106
var editable = gd._context.edits[editAttr];
85107

108+
function matchesPlaceholder(text, placeholder) {
109+
if(text === undefined || placeholder === undefined) return false;
110+
// look for placeholder text while stripping out numbers from eg X2, Y3
111+
// this is just for backward compatibility with the old version that had
112+
// "Click to enter X2 title" and may have gotten saved in some old plots,
113+
// we don't want this to show up when these are displayed.
114+
return text.replace(numStripRE, ' % ') === placeholder.replace(numStripRE, ' % ');
115+
}
116+
86117
if(txt === '') opacity = 0;
87-
// look for placeholder text while stripping out numbers from eg X2, Y3
88-
// this is just for backward compatibility with the old version that had
89-
// "Click to enter X2 title" and may have gotten saved in some old plots,
90-
// we don't want this to show up when these are displayed.
91-
else if(txt.replace(numStripRE, ' % ') === placeholder.replace(numStripRE, ' % ')) {
92-
opacity = 0.2;
93-
isplaceholder = true;
118+
else if(matchesPlaceholder(txt, placeholder)) {
94119
if(!editable) txt = '';
120+
opacity = 0.2;
121+
titleIsPlaceholder = true;
122+
}
123+
124+
if(subtitleEnabled) {
125+
if(subtitleTxt === '') subtitleOpacity = 0;
126+
else if(matchesPlaceholder(subtitleTxt, subtitlePlaceholder)) {
127+
if(!editable) subtitleTxt = '';
128+
subtitleOpacity = 0.2;
129+
subtitleIsPlaceholder = true;
130+
}
95131
}
96132

97133
if(options._meta) {
@@ -100,15 +136,15 @@ function draw(gd, titleClass, options) {
100136
txt = Lib.templateString(txt, fullLayout._meta);
101137
}
102138

103-
var elShouldExist = txt || editable;
139+
var elShouldExist = txt || subtitleTxt || editable;
104140

105141
var hColorbarMoveTitle;
106142
if(!group) {
107143
group = Lib.ensureSingle(fullLayout._infolayer, 'g', 'g-' + titleClass);
108144
hColorbarMoveTitle = fullLayout._hColorbarMoveTitle;
109145
}
110146

111-
var el = group.selectAll('text')
147+
var el = group.selectAll('text.' + titleClass)
112148
.data(elShouldExist ? [0] : []);
113149
el.enter().append('text');
114150
el.text(txt)
@@ -120,13 +156,29 @@ function draw(gd, titleClass, options) {
120156
.attr('class', titleClass);
121157
el.exit().remove();
122158

159+
var subtitleEl = null;
160+
var subtitleClass = titleClass + '-subtitle';
161+
var subtitleElShouldExist = subtitleTxt || editable;
162+
163+
if(subtitleEnabled && subtitleElShouldExist) {
164+
subtitleEl = group.selectAll('text.' + subtitleClass)
165+
.data(subtitleElShouldExist ? [0] : []);
166+
subtitleEl.enter().append('text');
167+
subtitleEl.text(subtitleTxt).attr('class', subtitleClass);
168+
subtitleEl.exit().remove();
169+
}
170+
171+
123172
if(!elShouldExist) return group;
124173

125-
function titleLayout(titleEl) {
126-
Lib.syncOrAsync([drawTitle, scootTitle], titleEl);
174+
function titleLayout(titleEl, subtitleEl) {
175+
Lib.syncOrAsync([drawTitle, scootTitle], { title: titleEl, subtitle: subtitleEl });
127176
}
128177

129-
function drawTitle(titleEl) {
178+
function drawTitle(titleAndSubtitleEls) {
179+
var titleEl = titleAndSubtitleEls.title;
180+
var subtitleEl = titleAndSubtitleEls.subtitle;
181+
130182
var transformVal;
131183

132184
if(!transform && hColorbarMoveTitle) {
@@ -147,6 +199,23 @@ function draw(gd, titleClass, options) {
147199

148200
titleEl.attr('transform', transformVal);
149201

202+
// Callback to adjust the subtitle position after mathjax is rendered
203+
// Mathjax is rendered asynchronously, which is why this step needs to be
204+
// passed as a callback
205+
function adjustSubtitlePosition(titleElMathGroup) {
206+
if(!titleElMathGroup) return;
207+
208+
var subtitleElement = d3.select(titleElMathGroup.node().parentNode).select('.' + subtitleClass);
209+
if(!subtitleElement.empty()) {
210+
var titleElMathBbox = titleElMathGroup.node().getBBox();
211+
if(titleElMathBbox.height) {
212+
// Position subtitle based on bottom of Mathjax title
213+
var subtitleY = titleElMathBbox.y + titleElMathBbox.height + (SUBTITLE_PADDING_MATHJAX_EM * subFontSize);
214+
subtitleElement.attr('y', subtitleY);
215+
}
216+
}
217+
}
218+
150219
titleEl.style('opacity', opacity * Color.opacity(fontColor))
151220
.call(Drawing.font, {
152221
color: Color.rgb(fontColor),
@@ -160,12 +229,43 @@ function draw(gd, titleClass, options) {
160229
lineposition: fontLineposition,
161230
})
162231
.attr(attributes)
163-
.call(svgTextUtils.convertToTspans, gd);
232+
.call(svgTextUtils.convertToTspans, gd, adjustSubtitlePosition);
233+
234+
if(subtitleEl) {
235+
// Set subtitle y position based on bottom of title
236+
// We need to check the Mathjax group as well, in case the Mathjax
237+
// has already rendered
238+
var titleElMathGroup = group.select('.' + titleClass + '-math-group');
239+
var titleElBbox = titleEl.node().getBBox();
240+
var titleElMathBbox = titleElMathGroup.node() ? titleElMathGroup.node().getBBox() : undefined;
241+
var subtitleY = titleElMathBbox ? titleElMathBbox.y + titleElMathBbox.height + (SUBTITLE_PADDING_MATHJAX_EM * subFontSize) : titleElBbox.y + titleElBbox.height + (SUBTITLE_PADDING_EM * subFontSize);
242+
243+
var subtitleAttributes = Lib.extendFlat({}, attributes, {
244+
y: subtitleY
245+
});
246+
247+
subtitleEl.attr('transform', transformVal);
248+
subtitleEl.style('opacity', subtitleOpacity * Color.opacity(subFontColor))
249+
.call(Drawing.font, {
250+
color: Color.rgb(subFontColor),
251+
size: d3.round(subFontSize, 2),
252+
family: subFontFamily,
253+
weight: subFontWeight,
254+
style: subFontStyle,
255+
variant: subFontVariant,
256+
textcase: subFontTextcase,
257+
shadow: subFontShadow,
258+
lineposition: subFontLineposition,
259+
})
260+
.attr(subtitleAttributes)
261+
.call(svgTextUtils.convertToTspans, gd);
262+
}
164263

165264
return Plots.previousPromises(gd);
166265
}
167266

168-
function scootTitle(titleElIn) {
267+
function scootTitle(titleAndSubtitleEls) {
268+
var titleElIn = titleAndSubtitleEls.title;
169269
var titleGroup = d3.select(titleElIn.node().parentNode);
170270

171271
if(avoid && avoid.selection && avoid.side && txt) {
@@ -239,12 +339,10 @@ function draw(gd, titleClass, options) {
239339
}
240340
}
241341

242-
el.call(titleLayout);
342+
el.call(titleLayout, subtitleEl);
243343

244-
function setPlaceholder() {
245-
opacity = 0;
246-
isplaceholder = true;
247-
el.text(placeholder)
344+
function setPlaceholder(element, placeholderText) {
345+
element.text(placeholderText)
248346
.on('mouseover.opacity', function() {
249347
d3.select(this).transition()
250348
.duration(interactConstants.SHOW_PLACEHOLDER).style('opacity', 1);
@@ -256,8 +354,10 @@ function draw(gd, titleClass, options) {
256354
}
257355

258356
if(editable) {
259-
if(!txt) setPlaceholder();
260-
else el.on('.opacity', null);
357+
if(!txt) {
358+
setPlaceholder(el, placeholder);
359+
titleIsPlaceholder = true;
360+
} else el.on('.opacity', null);
261361

262362
el.call(svgTextUtils.makeEditable, {gd: gd})
263363
.on('edit', function(text) {
@@ -275,12 +375,43 @@ function draw(gd, titleClass, options) {
275375
this.text(d || ' ')
276376
.call(svgTextUtils.positionText, attributes.x, attributes.y);
277377
});
378+
379+
if(subtitleEnabled) {
380+
// Adjust subtitle position now that title placeholder has been added
381+
// Only adjust if subtitle is enabled and title text was originally empty
382+
if(subtitleEnabled && !txt) {
383+
var titleElBbox = el.node().getBBox();
384+
var subtitleY = titleElBbox.y + titleElBbox.height + (SUBTITLE_PADDING_EM * subFontSize);
385+
subtitleEl.attr('y', subtitleY);
386+
}
387+
388+
if(!subtitleTxt) {
389+
setPlaceholder(subtitleEl, subtitlePlaceholder);
390+
subtitleIsPlaceholder = true;
391+
} else subtitleEl.on('.opacity', null);
392+
subtitleEl.call(svgTextUtils.makeEditable, {gd: gd})
393+
.on('edit', function(text) {
394+
Registry.call('_guiRelayout', gd, 'title.subtitle.text', text);
395+
})
396+
.on('cancel', function() {
397+
this.text(this.attr('data-unformatted'))
398+
.call(titleLayout);
399+
})
400+
.on('input', function(d) {
401+
this.text(d || ' ')
402+
.call(svgTextUtils.positionText, subtitleEl.attr('x'), subtitleEl.attr('y'));
403+
});
404+
}
278405
}
279-
el.classed('js-placeholder', isplaceholder);
406+
407+
el.classed('js-placeholder', titleIsPlaceholder);
408+
if(subtitleEl) subtitleEl.classed('js-placeholder', subtitleIsPlaceholder);
280409

281410
return group;
282411
}
283412

284413
module.exports = {
285-
draw: draw
414+
draw: draw,
415+
SUBTITLE_PADDING_EM: SUBTITLE_PADDING_EM,
416+
SUBTITLE_PADDING_MATHJAX_EM: SUBTITLE_PADDING_MATHJAX_EM,
286417
};

src/plot_api/subroutines.js

+19-2
Original file line numberDiff line numberDiff line change
@@ -410,18 +410,20 @@ exports.drawMainTitle = function(gd) {
410410
Titles.draw(gd, 'gtitle', {
411411
propContainer: fullLayout,
412412
propName: 'title.text',
413+
subtitlePropName: 'title.subtitle.text',
413414
placeholder: fullLayout._dfltTitle.plot,
415+
subtitlePlaceholder: fullLayout._dfltTitle.subtitle,
414416
attributes: ({
415417
x: x,
416418
y: y,
417419
'text-anchor': textAnchor,
418420
dy: dy
419-
})
421+
}),
420422
});
421423

422424
if(title.text && title.automargin) {
423425
var titleObj = d3.selectAll('.gtitle');
424-
var titleHeight = Drawing.bBox(titleObj.node()).height;
426+
var titleHeight = Drawing.bBox(d3.selectAll('.g-gtitle').node()).height;
425427
var pushMargin = needsMarginPush(gd, title, titleHeight);
426428
if(pushMargin > 0) {
427429
applyTitleAutoMargin(gd, y, pushMargin, titleHeight);
@@ -445,6 +447,21 @@ exports.drawMainTitle = function(gd) {
445447
this.setAttribute('dy', newDy);
446448
});
447449
}
450+
451+
// If there is a subtitle
452+
var subtitleObj = d3.selectAll('.gtitle-subtitle');
453+
if(subtitleObj.node()) {
454+
// Get bottom edge of title bounding box
455+
var titleBB = titleObj.node().getBBox();
456+
var titleBottom = titleBB.y + titleBB.height;
457+
var subtitleY = titleBottom + Titles.SUBTITLE_PADDING_EM * title.subtitle.font.size;
458+
subtitleObj.attr({
459+
x: x,
460+
y: subtitleY,
461+
'text-anchor': textAnchor,
462+
dy: getMainTitleDyAdj(title.yanchor)
463+
}).call(svgTextUtils.positionText, x, subtitleY);
464+
}
448465
}
449466
}
450467
};

src/plots/layout_attributes.js

+12
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,18 @@ module.exports = {
4141
'by the now deprecated `titlefont` attribute.'
4242
].join(' ')
4343
}),
44+
subtitle: {
45+
text: {
46+
valType: 'string',
47+
editType: 'layoutstyle',
48+
description: 'Sets the plot\'s subtitle.'
49+
},
50+
font: fontAttrs({
51+
editType: 'layoutstyle',
52+
description: 'Sets the subtitle font.'
53+
}),
54+
editType: 'layoutstyle',
55+
},
4456
xref: {
4557
valType: 'enumerated',
4658
dflt: 'container',

0 commit comments

Comments
 (0)