Skip to content

Commit

Permalink
Merge pull request #7012 from plotly/add-subtitle
Browse files Browse the repository at this point in the history
Add subtitle to plots
  • Loading branch information
archmoj authored Jul 18, 2024
2 parents 5fde4d9 + b8942ee commit 9adfc12
Show file tree
Hide file tree
Showing 24 changed files with 458 additions and 62 deletions.
2 changes: 1 addition & 1 deletion .circleci/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ case $1 in
;;

make-baselines-mathjax3)
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=$?
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=$?
exit $EXIT_STATE
;;

Expand Down
1 change: 1 addition & 0 deletions draftlogs/7012_add.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Add `subtitle` attribute to `layout.title` to enable adding subtitles to plots [[#7012](https://github.com/plotly/plotly.js/pull/7012)]
1 change: 1 addition & 0 deletions lib/locales/es.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ module.exports = {
'Click to enter Component B title': 'Introducir el título del Componente B', // plots/ternary/ternary.js:406
'Click to enter Component C title': 'Introducir el título del Componente C', // plots/ternary/ternary.js:417
'Click to enter Plot title': 'Introducir el título de la Gráfica', // plot_api/plot_api.js:579
'Click to enter Plot subtitle': 'Introducir el subtítulo de la Gráfica', // plot_api/plot_api.js:579
'Click to enter X axis title': 'Introducir el título del eje X', // plots/plots.js:301
'Click to enter Y axis title': 'Introducir el título del eje Y', // plots/plots.js:302
'Click to enter radial axis title': 'Introducir el título del eje radial',
Expand Down
1 change: 1 addition & 0 deletions lib/locales/fr.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ module.exports = {
'Click to enter Component B title': 'Ajouter un titre à la composante B',
'Click to enter Component C title': 'Ajouter un titre à la composante C',
'Click to enter Plot title': 'Ajouter un titre au graphique',
'Click to enter Plot subtitle': 'Ajouter un sous-titre au graphique',
'Click to enter X axis title': 'Ajouter un titre à l\'axe des x',
'Click to enter Y axis title': 'Ajouter un titre à l\'axe des y',
'Click to enter radial axis title': 'Ajouter un titre à l\'axe radial',
Expand Down
185 changes: 158 additions & 27 deletions src/components/titles/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ var interactConstants = require('../../constants/interactions');

var OPPOSITE_SIDE = require('../../constants/alignment').OPPOSITE_SIDE;
var numStripRE = / [XY][0-9]* /;
var SUBTITLE_PADDING_MATHJAX_EM = 1.6;
var SUBTITLE_PADDING_EM = 1.6;

/**
* Titles - (re)draw titles on the axes and plot:
Expand Down Expand Up @@ -48,6 +50,8 @@ var numStripRE = / [XY][0-9]* /;
* @return {selection} d3 selection of title container group
*/
function draw(gd, titleClass, options) {
var fullLayout = gd._fullLayout;

var cont = options.propContainer;
var prop = options.propName;
var placeholder = options.placeholder;
Expand All @@ -56,13 +60,10 @@ function draw(gd, titleClass, options) {
var attributes = options.attributes;
var transform = options.transform;
var group = options.containerGroup;

var fullLayout = gd._fullLayout;

var opacity = 1;
var isplaceholder = false;
var title = cont.title;
var txt = (title && title.text ? title.text : '').trim();
var titleIsPlaceholder = false;

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

// Get subtitle properties
var subtitleProp = options.subtitlePropName;
var subtitleEnabled = !!subtitleProp;
var subtitlePlaceholder = options.subtitlePlaceholder;
var subtitle = (cont.title || {}).subtitle || {text: '', font: {}};
var subtitleTxt = subtitle.text.trim();
var subtitleIsPlaceholder = false;
var subtitleOpacity = 1;

var subtitleFont = subtitle.font;
var subFontFamily = subtitleFont.family;
var subFontSize = subtitleFont.size;
var subFontColor = subtitleFont.color;
var subFontWeight = subtitleFont.weight;
var subFontStyle = subtitleFont.style;
var subFontVariant = subtitleFont.variant;
var subFontTextcase = subtitleFont.textcase;
var subFontLineposition = subtitleFont.lineposition;
var subFontShadow = subtitleFont.shadow;

// only make this title editable if we positively identify its property
// as one that has editing enabled.
// Subtitle is editable if and only if title is editable
var editAttr;
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];

function matchesPlaceholder(text, placeholder) {
if(text === undefined || placeholder === undefined) return false;
// look for placeholder text while stripping out numbers from eg X2, Y3
// this is just for backward compatibility with the old version that had
// "Click to enter X2 title" and may have gotten saved in some old plots,
// we don't want this to show up when these are displayed.
return text.replace(numStripRE, ' % ') === placeholder.replace(numStripRE, ' % ');
}

if(txt === '') opacity = 0;
// look for placeholder text while stripping out numbers from eg X2, Y3
// this is just for backward compatibility with the old version that had
// "Click to enter X2 title" and may have gotten saved in some old plots,
// we don't want this to show up when these are displayed.
else if(txt.replace(numStripRE, ' % ') === placeholder.replace(numStripRE, ' % ')) {
opacity = 0.2;
isplaceholder = true;
else if(matchesPlaceholder(txt, placeholder)) {
if(!editable) txt = '';
opacity = 0.2;
titleIsPlaceholder = true;
}

if(subtitleEnabled) {
if(subtitleTxt === '') subtitleOpacity = 0;
else if(matchesPlaceholder(subtitleTxt, subtitlePlaceholder)) {
if(!editable) subtitleTxt = '';
subtitleOpacity = 0.2;
subtitleIsPlaceholder = true;
}
}

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

var elShouldExist = txt || editable;
var elShouldExist = txt || subtitleTxt || editable;

var hColorbarMoveTitle;
if(!group) {
group = Lib.ensureSingle(fullLayout._infolayer, 'g', 'g-' + titleClass);
hColorbarMoveTitle = fullLayout._hColorbarMoveTitle;
}

var el = group.selectAll('text')
var el = group.selectAll('text.' + titleClass)
.data(elShouldExist ? [0] : []);
el.enter().append('text');
el.text(txt)
Expand All @@ -120,13 +156,29 @@ function draw(gd, titleClass, options) {
.attr('class', titleClass);
el.exit().remove();

var subtitleEl = null;
var subtitleClass = titleClass + '-subtitle';
var subtitleElShouldExist = subtitleTxt || editable;

if(subtitleEnabled && subtitleElShouldExist) {
subtitleEl = group.selectAll('text.' + subtitleClass)
.data(subtitleElShouldExist ? [0] : []);
subtitleEl.enter().append('text');
subtitleEl.text(subtitleTxt).attr('class', subtitleClass);
subtitleEl.exit().remove();
}


if(!elShouldExist) return group;

function titleLayout(titleEl) {
Lib.syncOrAsync([drawTitle, scootTitle], titleEl);
function titleLayout(titleEl, subtitleEl) {
Lib.syncOrAsync([drawTitle, scootTitle], { title: titleEl, subtitle: subtitleEl });
}

function drawTitle(titleEl) {
function drawTitle(titleAndSubtitleEls) {
var titleEl = titleAndSubtitleEls.title;
var subtitleEl = titleAndSubtitleEls.subtitle;

var transformVal;

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

titleEl.attr('transform', transformVal);

// Callback to adjust the subtitle position after mathjax is rendered
// Mathjax is rendered asynchronously, which is why this step needs to be
// passed as a callback
function adjustSubtitlePosition(titleElMathGroup) {
if(!titleElMathGroup) return;

var subtitleElement = d3.select(titleElMathGroup.node().parentNode).select('.' + subtitleClass);
if(!subtitleElement.empty()) {
var titleElMathBbox = titleElMathGroup.node().getBBox();
if(titleElMathBbox.height) {
// Position subtitle based on bottom of Mathjax title
var subtitleY = titleElMathBbox.y + titleElMathBbox.height + (SUBTITLE_PADDING_MATHJAX_EM * subFontSize);
subtitleElement.attr('y', subtitleY);
}
}
}

titleEl.style('opacity', opacity * Color.opacity(fontColor))
.call(Drawing.font, {
color: Color.rgb(fontColor),
Expand All @@ -160,12 +229,43 @@ function draw(gd, titleClass, options) {
lineposition: fontLineposition,
})
.attr(attributes)
.call(svgTextUtils.convertToTspans, gd);
.call(svgTextUtils.convertToTspans, gd, adjustSubtitlePosition);

if(subtitleEl) {
// Set subtitle y position based on bottom of title
// We need to check the Mathjax group as well, in case the Mathjax
// has already rendered
var titleElMathGroup = group.select('.' + titleClass + '-math-group');
var titleElBbox = titleEl.node().getBBox();
var titleElMathBbox = titleElMathGroup.node() ? titleElMathGroup.node().getBBox() : undefined;
var subtitleY = titleElMathBbox ? titleElMathBbox.y + titleElMathBbox.height + (SUBTITLE_PADDING_MATHJAX_EM * subFontSize) : titleElBbox.y + titleElBbox.height + (SUBTITLE_PADDING_EM * subFontSize);

var subtitleAttributes = Lib.extendFlat({}, attributes, {
y: subtitleY
});

subtitleEl.attr('transform', transformVal);
subtitleEl.style('opacity', subtitleOpacity * Color.opacity(subFontColor))
.call(Drawing.font, {
color: Color.rgb(subFontColor),
size: d3.round(subFontSize, 2),
family: subFontFamily,
weight: subFontWeight,
style: subFontStyle,
variant: subFontVariant,
textcase: subFontTextcase,
shadow: subFontShadow,
lineposition: subFontLineposition,
})
.attr(subtitleAttributes)
.call(svgTextUtils.convertToTspans, gd);
}

return Plots.previousPromises(gd);
}

function scootTitle(titleElIn) {
function scootTitle(titleAndSubtitleEls) {
var titleElIn = titleAndSubtitleEls.title;
var titleGroup = d3.select(titleElIn.node().parentNode);

if(avoid && avoid.selection && avoid.side && txt) {
Expand Down Expand Up @@ -239,12 +339,10 @@ function draw(gd, titleClass, options) {
}
}

el.call(titleLayout);
el.call(titleLayout, subtitleEl);

function setPlaceholder() {
opacity = 0;
isplaceholder = true;
el.text(placeholder)
function setPlaceholder(element, placeholderText) {
element.text(placeholderText)
.on('mouseover.opacity', function() {
d3.select(this).transition()
.duration(interactConstants.SHOW_PLACEHOLDER).style('opacity', 1);
Expand All @@ -256,8 +354,10 @@ function draw(gd, titleClass, options) {
}

if(editable) {
if(!txt) setPlaceholder();
else el.on('.opacity', null);
if(!txt) {
setPlaceholder(el, placeholder);
titleIsPlaceholder = true;
} else el.on('.opacity', null);

el.call(svgTextUtils.makeEditable, {gd: gd})
.on('edit', function(text) {
Expand All @@ -275,12 +375,43 @@ function draw(gd, titleClass, options) {
this.text(d || ' ')
.call(svgTextUtils.positionText, attributes.x, attributes.y);
});

if(subtitleEnabled) {
// Adjust subtitle position now that title placeholder has been added
// Only adjust if subtitle is enabled and title text was originally empty
if(subtitleEnabled && !txt) {
var titleElBbox = el.node().getBBox();
var subtitleY = titleElBbox.y + titleElBbox.height + (SUBTITLE_PADDING_EM * subFontSize);
subtitleEl.attr('y', subtitleY);
}

if(!subtitleTxt) {
setPlaceholder(subtitleEl, subtitlePlaceholder);
subtitleIsPlaceholder = true;
} else subtitleEl.on('.opacity', null);
subtitleEl.call(svgTextUtils.makeEditable, {gd: gd})
.on('edit', function(text) {
Registry.call('_guiRelayout', gd, 'title.subtitle.text', text);
})
.on('cancel', function() {
this.text(this.attr('data-unformatted'))
.call(titleLayout);
})
.on('input', function(d) {
this.text(d || ' ')
.call(svgTextUtils.positionText, subtitleEl.attr('x'), subtitleEl.attr('y'));
});
}
}
el.classed('js-placeholder', isplaceholder);

el.classed('js-placeholder', titleIsPlaceholder);
if(subtitleEl) subtitleEl.classed('js-placeholder', subtitleIsPlaceholder);

return group;
}

module.exports = {
draw: draw
draw: draw,
SUBTITLE_PADDING_EM: SUBTITLE_PADDING_EM,
SUBTITLE_PADDING_MATHJAX_EM: SUBTITLE_PADDING_MATHJAX_EM,
};
21 changes: 19 additions & 2 deletions src/plot_api/subroutines.js
Original file line number Diff line number Diff line change
Expand Up @@ -410,18 +410,20 @@ exports.drawMainTitle = function(gd) {
Titles.draw(gd, 'gtitle', {
propContainer: fullLayout,
propName: 'title.text',
subtitlePropName: 'title.subtitle.text',
placeholder: fullLayout._dfltTitle.plot,
subtitlePlaceholder: fullLayout._dfltTitle.subtitle,
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 titleHeight = Drawing.bBox(d3.selectAll('.g-gtitle').node()).height;
var pushMargin = needsMarginPush(gd, title, titleHeight);
if(pushMargin > 0) {
applyTitleAutoMargin(gd, y, pushMargin, titleHeight);
Expand All @@ -445,6 +447,21 @@ exports.drawMainTitle = function(gd) {
this.setAttribute('dy', newDy);
});
}

// If there is a subtitle
var subtitleObj = d3.selectAll('.gtitle-subtitle');
if(subtitleObj.node()) {
// Get bottom edge of title bounding box
var titleBB = titleObj.node().getBBox();
var titleBottom = titleBB.y + titleBB.height;
var subtitleY = titleBottom + Titles.SUBTITLE_PADDING_EM * title.subtitle.font.size;
subtitleObj.attr({
x: x,
y: subtitleY,
'text-anchor': textAnchor,
dy: getMainTitleDyAdj(title.yanchor)
}).call(svgTextUtils.positionText, x, subtitleY);
}
}
}
};
Expand Down
12 changes: 12 additions & 0 deletions src/plots/layout_attributes.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,18 @@ module.exports = {
'by the now deprecated `titlefont` attribute.'
].join(' ')
}),
subtitle: {
text: {
valType: 'string',
editType: 'layoutstyle',
description: 'Sets the plot\'s subtitle.'
},
font: fontAttrs({
editType: 'layoutstyle',
description: 'Sets the subtitle font.'
}),
editType: 'layoutstyle',
},
xref: {
valType: 'enumerated',
dflt: 'container',
Expand Down
Loading

0 comments on commit 9adfc12

Please sign in to comment.