How do I make the width of the title box span the entire plot?

Question:

consider the following pandas series s and plot

import pandas as pd
import numpy as np

s = pd.Series(np.random.lognormal(.001, .01, 100))
ax = s.cumprod().plot()
ax.set_title('My Log Normal Example', position=(.5, 1.02),
             backgroundcolor='black', color='white')

enter image description here

How do I get the box that contains the title to span the entire plot?

Asked By: piRSquared

||

Answers:

Instead of scaling the bounding box of the title text itself, you can create a secondary axis above the primary one and use it as a “box” for your title. As axes normally don’t look as boxes, we’ll switch off its axes labels and ticks, and will set the background color to black to match the OP.

I’m using the same approach to make a secondary, matching, axis as here.

Additionally, I’ve used AnchoredText to snap the title text to the axis so that it can easily be located in the center of it.

import matplotlib.pyplot as plt 
from matplotlib.offsetbox import AnchoredText
from mpl_toolkits.axes_grid1 import make_axes_locatable
import pandas as pd
import numpy as np

s = pd.Series(np.random.lognormal(.001, .01, 100))
ax = s.cumprod().plot()

divider = make_axes_locatable(ax)
cax = divider.append_axes("top", size="11%", pad=0)
cax.get_xaxis().set_visible(False)
cax.get_yaxis().set_visible(False)
cax.set_facecolor('black')

at = AnchoredText("My Log Normal Example", loc=10,
                  prop=dict(backgroundcolor='black',
                            size=12, color='white'))
cax.add_artist(at)

plt.show()

enter image description here

Edit: for older matplotlib versions you might need to switch to cax.set_axis_bgcolor('black') when setting the background color.

Answered By: Vlas Sokolov

It is, of course, possible to get the bounding box of the title, which is a Text element. This can be done with

title = ax.set_title(...) 
bb = title.get_bbox_patch() 

In principle, one can then manipulate the bounding box, e.g. via
bb.set_width(...). However, all settings are lost, once matplotlib draws the title to the canvas. At least this is how I interpret the Text‘s draw() method.

I’m not aware of other methods of setting the bounding box. For example a legend‘s bounding box can be set via
plt.legend(bbox_to_anchor=(0., 1.02, 1., .102), loc=3, mode="expand") such that it expands over the full axes range (see here). It would be very useful to have the same option for Text as well. But as for now, we don’t.

The Text object allows setting a bbox argument which is normally meant for setting the style of the bounding box. There is no way to set the bounding box extents, but it accepts some dictionary of properties of the surrounding box. And one of the accepted properties is a boxstyle. Per default, this is a square, but can be set to a circle or arrow or other strange shapes.

Those boxstyles are actually the key to a possible solution. They all inherit from BoxStyle._Base and – as can be seen at the bottom of the annotations guide – one can define a custom shape, subclassing BoxStyle._Base.

The following solution is based on subclassing BoxStyle._Base in a way that it accepts the width of the axes as an argument and draws the title’s rectangle path such that it has exactly this width.

As a bonus, we can register an event handler such that this width, once it changes due to resizing of the window, is adapted.

Here is the code:

import matplotlib.pyplot as plt
import pandas as pd
import numpy as np

from matplotlib.path import Path
from matplotlib.patches import BoxStyle


class ExtendedTextBox(BoxStyle._Base):
    """
    An Extended Text Box that expands to the axes limits 
                        if set in the middle of the axes
    """

    def __init__(self, pad=0.3, width=500.):
        """
        width: 
            width of the textbox. 
            Use `ax.get_window_extent().width` 
                   to get the width of the axes.
        pad: 
            amount of padding (in vertical direction only)
        """
        self.width=width
        self.pad = pad
        super(ExtendedTextBox, self).__init__()

    def transmute(self, x0, y0, width, height, mutation_size):
        """
        x0 and y0 are the lower left corner of original text box
        They are set automatically by matplotlib
        """
        # padding
        pad = mutation_size * self.pad

        # we add the padding only to the box height
        height = height + 2.*pad
        # boundary of the padded box
        y0 = y0 - pad
        y1 = y0 + height
        _x0 = x0
        x0 = _x0 +width /2. - self.width/2.
        x1 = _x0 +width /2. + self.width/2.

        cp = [(x0, y0),
              (x1, y0), (x1, y1), (x0, y1),
              (x0, y0)]

        com = [Path.MOVETO,
               Path.LINETO, Path.LINETO, Path.LINETO,
               Path.CLOSEPOLY]

        path = Path(cp, com)

        return path

dpi = 80

# register the custom style
BoxStyle._style_list["ext"] = ExtendedTextBox

plt.figure(dpi=dpi)
s = pd.Series(np.random.lognormal(.001, .01, 100))
ax = s.cumprod().plot()
# set the title position to the horizontal center (0.5) of the axes
title = ax.set_title('My Log Normal Example', position=(.5, 1.02), 
             backgroundcolor='black', color='white')
# set the box style of the title text box toour custom box
bb = title.get_bbox_patch()
# use the axes' width as width of the text box
bb.set_boxstyle("ext", pad=0.4, width=ax.get_window_extent().width )


# Optionally: use eventhandler to resize the title box, in case the window is resized
def on_resize(event):
    print "resize"
    bb.set_boxstyle("ext", pad=0.4, width=ax.get_window_extent().width )

cid = plt.gcf().canvas.mpl_connect('resize_event', on_resize)

# use the same dpi for saving to file as for plotting on screen
plt.savefig(__file__+".png", dpi=dpi)
plt.show()

enter image description here


Just in case someone is interested in a lighter solution, there is also the option to play around with the mutation_aspect of the title’s bounding box, which is apparently left unchanged when drawing the title. While the mutation_aspect itself basically only changes the height of the box, one can use extremely large padding for the box and set mutation_aspect to a very small number such that at the end the box appears extended in width. The clear drawback of this solution is, that the values for the padding and aspect have to be found by trial and error and will change for different font and figure sizes.
In my case, the values of mutation_aspect = 0.04 and pad=11.9 produce the desired result, but on other systems, they may, of course, be different.

import matplotlib.pyplot as plt
import pandas as pd
import numpy as np

s = pd.Series(np.random.lognormal(.001, .01, 100))
ax = s.cumprod().plot()
title = ax.set_title('My Log Normal Example', position=(.5, 1.02),
             backgroundcolor='black', color='white',
             verticalalignment="bottom", horizontalalignment="center")
title._bbox_patch._mutation_aspect = 0.04
title.get_bbox_patch().set_boxstyle("square", pad=11.9)
plt.tight_layout()
plt.savefig(__file__+".png")
plt.show()

piRSquared’s answer is great!
But using the Class ExtendedTextBox may cause python’s warning: "MatplotlibDeprecationWarning:
The transmute method was deprecated in Matplotlib 3.4 and will be removed two minor releases later."
I find some new ways to Custom box styles! (ref:https://matplotlib.org/stable/gallery/userdemo/custom_boxstyle01.html?highlight=boxstyle+_style_list)
Let me re-write the Class ExtendedTextBox without a python warning:

class ExtendedTextBox_v2:
    """
    制作自己的box类,以提供调整width功能
    参考:
    https://stackoverflow.com/questions/40796117/how-do-i-make-the-width-of-the-title-box-span-the-entire-plot
    https://matplotlib.org/stable/gallery/userdemo/custom_boxstyle01.html?highlight=boxstyle+_style_list
    """

    def __init__(self, pad=0.3, width=500.):
        """
        The arguments must be floats and have default values.

        Parameters
        ----------
        pad : float
            amount of padding
        """
        self.width = width
        self.pad = pad
        super().__init__()

    def __call__(self, x0, y0, width, height, mutation_size):
        """
        Given the location and size of the box, return the path of the box
        around it.

        Rotation is automatically taken care of.

        Parameters
        ----------
        x0, y0, width, height : float
            Box location and size.
        mutation_size : float
            Reference scale for the mutation, typically the text font size.
        """
        # padding
        pad = mutation_size * self.pad
        # width and height with padding added
        #width = width + 2.*pad
        height = height + 2.*pad
        # boundary of the padded box
        y0 = y0 - pad
        y1 = y0 + height
        _x0 = x0
        x0 = _x0 +width /2. - self.width/2.
        x1 = _x0 +width /2. + self.width/2.
        # return the new path
        return Path([(x0, y0),
                     (x1, y0), (x1, y1), (x0, y1),
                     (x0, y0)],
                    closed=True)
Answered By: Gdx
Categories: questions Tags: , ,
Answers are sorted by their score. The answer accepted by the question owner as the best is marked with
at the top-right corner.