Using a subfigure as textbody fails on tight layout with a lot of text - matplotlib

Using a subfigure as textbody fails on tight layout with a lot of text. As seen in the provided example, the bound of a figure are overreached by one subfigure, as if the text was not wrapped.
import pandas as pd
from matplotlib import pyplot as plt
# Paramters for A4 Paper
fullheight = 11.69
fullwidth = 8.27
# how the subfigures dive the space
fig0_factor = 0.7
fig1_factor = 0.3
# generate the figure
fig = plt.figure(constrained_layout = True, figsize=(fullwidth, fullheight)) #
# generate 2 subfigures
subfigs = fig.subfigures(2,height_ratios=[fig0_factor,fig1_factor])
# fill the fist subfigure
axes = subfigs[0].subplots(nrows=1)
# some plot
ax = plt.plot([0,0],[-1,1], lw=1, c='b', figure=subfigs[0])
# some text
subfigs[0].text(0.55, 0.00001, 'Figure 1', ha='center', va='center', rotation='horizontal',weight='bold')
# fill the second subfigure
text_ax = subfigs[1].subplots(1)
# make its background transparent
subfigs[1].patch.set_alpha(0.0)
# remove the axis, not removing it makes no difference regarding the problem
text_ax.set_axis_off()
# generate some text
message = ['word']*50 # 50 is enough to make the problem visable, my usecase has a way longer text
message = ' '.join(message)
# fill in the Text and wrap it
text_ax.text(0.00001, 0.8, message, horizontalalignment='left', verticalalignment='top',size=7, wrap=True)
# call tight layout
# this is neccecary for the whole plot, that is not shown for simplicity reasons
# explaination: subfigure one would be an array of subplots with different scales and descriptions,
# ever changing depending on the data that is to be plotted, so tight_layout mittigates
# a lot of tideos formatting
fig.tight_layout()
Please notice the figures right edge reaches for the whole panel, as seen in the second image
# omitting the wrap, it is clear why the figure is out of bound: somehow the layout gets its information from the unwrapped text
text_ax.text(0.00001, 0.8, message, horizontalalignment='left', verticalalignment='top',size=7, wrap=False)
Is there another way to have the text rendered in the subplot with it correctly noticing the wrapped bounds instead of the unwrapped bounds?

If you modify your code above to remove fig.tight_layout() and to take the text out of the layout then everything works as expected.
# fill in the Text and wrap it
thetext = text_ax.text(0.00001, 0.8, message, horizontalalignment='left',
verticalalignment='top',size=7, wrap=True)
thetext.set_in_layout(False)
There should be nothing that tight_layout does that layout='constrained' cannot do, and constrained layout is much more flexible.
BTW, you might consider subfigs[0].supxlabel() instead of the text command for "Figure 1"

Related

Why does get_legend_handles_labels() return empty lists when legend labels are added via ax.legend() [duplicate]

This one is blowing my mind. I'm creating a little customize legend function where you put in some text and properties and it'll make it for you. So, before this is reachable in the GUI, a figure with a legend has already been created by this point which has "run5" and "run6" in it.
I wrote something that deletes the existing legend and calls legend on that same axis again with new handles/labels. However, when I do ax.get_legend_handles_labels() right afterwards it returns the deleted legend's handle and labels, completely ignoring the legend call I just did.
I've tried removing the legend and then just recreating it. But clearly ax is holding onto the previous legend's data.
from matplotlib.lines import Line2D
ax = self.axes[ind] #just the axis handle
custom_lines, custom_strings = [], []
try:
ax.get_legend().remove()
except:
# Means no legend exists
pass
for idx, i in enumerate(self.lgndRowWidget):
if not i.isHidden():
#The below five lines are grabbing data from GUI
lineText = self.lgndStr[idx].text() # Is "test" here
lineType = self.lgndLine[idx].currentText()
lineWidth = self.lgndWidth[idx].value()
lineMrkr = self.lgndMrkr[idx].currentText()
lineClr = self.lgndClr[idx].text()
custom_lines.append(Line2D([0],[0],
lw=lineWidth,
ls=lineType,
marker=lineMrkr,
color=lineClr))
custom_strings.append(lineText)
if len(custom_lines) != 0:
print(custom_strings)
self.legList = ax.legend(custom_lines, custom_strings)
a,b = ax.get_legend_handles_labels()
print(b)
self.fig.canvas.draw()
print(custom_strings) returns whatever I input. In this case "test".
print(b) returns what was previously in the legend that I can't seem to get rid of: the initial "run5" and "run6". It SHOULD be "test".
You might have misunderstood the functionality of ax.get_legend_handles_labels().
What it does is to look for artists (like lines, collections etc.) that have a label.
It then returns those artists and their respective labels. Hence
ax.legend() is roughly equivalent to
handles, labels = ax.get_legend_handles_labels()
ax.legend(handles=handles, labels=labels)
.get_legend_handles_labels() does not know about whether there is a legend present, because it returns what is supposed to be in the legend, not what currently is in it.

Geopandas reduce legend size (and remove white space below map)

I would like to know how to change the legend automatically generated by Geopandas. Mostly I would like to reduce its size because it's quite big on the generated image. The legend seems to take all the available space.
Additional question, do you know how to remove the empty space below my map ? I've tried with
pad_inches = 0, bbox_inches='tight'
but I still have an empty space below the map.
Thanks for your help.
This works for me:
some_geodataframe.plot(..., legend=True, legend_kwds={'shrink': 0.3})
Other options here: https://matplotlib.org/api/_as_gen/matplotlib.pyplot.colorbar.html
To show how to get proper size of a colorbar legend accompanying a map created by geopandas' plot() method I use the built-in 'naturalearth_lowres' dataset.
The working code is as follows.
import matplotlib.pyplot as plt
import geopandas as gpd
world = gpd.read_file(gpd.datasets.get_path('naturalearth_lowres'))
world = world[(world.name != "Antarctica") & (world.name != "Fr. S. Antarctic Lands")] # exclude 2 no-man lands
plot as usual, grab the axes 'ax' returned by the plot
colormap = "copper_r" # add _r to reverse the colormap
ax = world.plot(column='pop_est', cmap=colormap, \
figsize=[12,9], \
vmin=min(world.pop_est), vmax=max(world.pop_est))
map marginal/face deco
ax.set_title('World Population')
ax.grid()
colorbar will be created by ...
fig = ax.get_figure()
# add colorbar axes to the figure
# here, need trial-and-error to get [l,b,w,h] right
# l:left, b:bottom, w:width, h:height; in normalized unit (0-1)
cbax = fig.add_axes([0.95, 0.3, 0.03, 0.39])
cbax.set_title('Population')
sm = plt.cm.ScalarMappable(cmap=colormap, \
norm=plt.Normalize(vmin=min(world.pop_est), vmax=max(world.pop_est)))
at this stage, 'cbax' is just a blank axes, with un needed labels on x and y axes blank-out the array of the scalar mappable 'sm'
sm._A = []
draw colorbar into 'cbax'
fig.colorbar(sm, cax=cbax, format="%d")
# dont use: plt.tight_layout()
plt.show()
Read the comments in the code for useful info.
The resulting plot:

Reduce the size of pyplot subplot by scaling

I am using Julia v.0.6.0, with Juno+Atom IDE, and I am trying to make subplots with PyPlot package, v2.3.2. (Pretty new to this)
Consider the following MWE:
using PyPlot
fig = figure("Test subplots",figsize=(9,9))
subplot(2,2,1)
title("Plot 221")
fig[:add_subplot](2,2,2,polar="true")
title("Plot 222")
fig[:canvas][:draw]() # Update the figure
suptitle("2x2 Subplot",fontsize=15)
tight_layout(pad=2)
which yields me this:
Note how the second subplot is too big such that its title is too close to the polar plot.
What I want to achieve is to have the subplot 222 to still take up the same amount of space in the grid, but to have the polar plot scaled down in size, perhaps to 0.9 of its current size.
Note that this should not affect the size of rectangular grid in subplot 221 as well.
Is there an argument that I am missing from the matplotlib documentation?
The main thing here is to capture any subplot axes, title objects etc into 'handles', so that you can manipulate their properties individually in an easy manner. So change your initial code like so:
using PyPlot
fig = figure("Test subplots",figsize=(9,9))
subplot(2,2,1)
title("Plot 221")
S = subplot(2,2,2,polar="true") ## captured
T = title("Plot 222") ## captured
fig[:canvas][:draw]() # Update the figure
ST = suptitle("2x2 Subplot",fontsize=15) ## captured
tight_layout(pad=2)
Now you can use properties such as T[:get_verticalalignment] to inspect, and T[:set_verticalalignment] to set it to one of "center", "bottom", "top" or "baseline" (as per the matplotlib documentation). E.g.
T[:set_verticalalignment]("bottom")
ST[:set_verticalalignment]("center")
seems to get the amount of separation you'd probably expect.
Alternatively, for even finer control, you can inspect or change the absolute positioning (and implicitly the size) for S, T, or ST via their [:get_position] and [:set_position] methods respectively.
These methods accept either via a normal array denoting [start_x, start_y, width_x, width_y], or a Bbox, which is the form returned by the get methods above, therefore you might want to get that object from matplotlib:
Bbox = PyPlot.matplotlib[:transforms][:Bbox]
Now you can do stuff like this:
# inspect the object's position
S[:get_position]()
#> PyObject Bbox([[0.544201388889, 0.517261679293],
#> [0.943952721661, 0.917013012065]])
# absolute positioning using 'width' notation
S[:set_position]([0.51, 0.51, 0.4, 0.4])
# absolute positioning using a 'Bbox' (note 2D array input, not a vector!)
S[:set_position](Bbox([0.51 0.51; 0.89 0.89]))
# 'relative' by adjusting current position, and wrapping back in a Bbox
S[:set_position](Bbox( S[:get_position]()[:get_points]() + [0.1 0.1; -0.1 -0.1]))
# inspect title position
T[:get_position]()
#> (0.5, 1.05)
# raise title a bit higher (manually)
T[:set_position]([0.5, 1.10])
etc.

How might I plot multiple lines (same data, different styles) with a single plotting command in matplotlib?

In my example, I want to plot a "highlighting" line (a heavy black line with a narrower yellow line over it to create a black outline) behind the actual data (the blue 'Pitch Angle' line in my example).
My function highlight does this for elements in pandas Series x where h is True and also finally plots x. highlight returns unique identifiers for the highlighting lines created and the actual line styles used to allow the legend entries to be edited to produce what you see in the image, but as far as I can tell that has to be done after all plot commands, i.e. outside of highlight, assuming other things will be added to the Axes e.g. the 'Fine pitch' and '10 degrees' lines in my example.
def highlight(x, h, hlw=8, hc='#FFFBCC', hll='Highlighted', **kwargs):
"""
Plot data in Series x, highlighting the elements correponding to True in h.
"""
# For passing to extra the highlighter lines, drop any passed kwargs which
# would clobber their appearance. Keep a copy of the full set for the data.
# The extra lines are plotted first so they appear underneath the actual
# data.
fullkwargs = kwargs.copy()
for k in ['lw', 'linewidth', 'linestyle', 'ls', 'color', 'c', 'label']:
kwargs.pop(k, None)
# Generate a probably unique ID so that the legend entries of these lines
# can be identified by the caller.
import random
gid = random.random()
# Plot the highlighting lines. First, plot a heavier black line to provide
# a clear outline to the bright highlighted region.
x.where(h).plot(lw=hlw+2, c='k', label='_dont_put_me_in_legend', **kwargs)
# Plot the bright highlighter line on top of that. Give this line an id.
x.where(h).plot(lw=hlw, c=hc, label=hll, gid=gid, **kwargs)
# Plot the actual data.
a = x.plot(**fullkwargs)
# Generate the custom legend entry artists.
import matplotlib.lines as mlines
l1 = mlines.Line2D([], [], lw=hlw+2, c='k')
l2 = mlines.Line2D([], [], lw=hlw, c=hc)
# Return the unique identifier of this call, and the tuple of line styles to
# go to HandlerTuple (see
# http://matplotlib.org/users/legend_guide.html#legend-handlers). This
# makes it possible to update all "highlighter" legend entries with the
# correct color-on-black visual style produced here.
return {gid: (l1, l2)}
I manage to generate an accurate legend entry for the highlighting by making use of HandlerTuple when calling plt.legend() by looping over all legend entries and replacing legend keys where gid is one of the values returned by highlight.
handles,labels = a.get_legend_handles_labels()
newhandles = []
for h in handles:
if h.get_gid() in hi:
newhandles.append(hi[h.get_gid()])
else:
newhandles.append(h)
a.legend(newhandles, labels)
Is there a way in matplotlib to define a "compound" line style such that one plot command produces the desired appearance and has only one legend entry?

Add a new axis to the right/left/top-right of an axis

How do you add an axis to the outside of another axis, keeping it within the figure as a whole? legend and colorbar both have this capability, but implemented in rather complicated (and for me, hard to reproduce) ways.
You can use the subplots command to achieve this, this can be as simple as py.subplot(2,2,1) where the first two numbers describe the geometry of the plots (2x2) and the third is the current plot number. In general it is better to be explicit as in the following example
import pylab as py
# Make some data
x = py.linspace(0,10,1000)
cos_x = py.cos(x)
sin_x = py.sin(x)
# Initiate a figure, there are other options in addition to figsize
fig = py.figure(figsize=(6,6))
# Plot the first set of data on ax1
ax1 = fig.add_subplot(2,1,1)
ax1.plot(x,sin_x)
# Plot the second set of data on ax2
ax2 = fig.add_subplot(2,1,2)
ax2.plot(x,cos_x)
# This final line can be used to adjust the subplots, if uncommentted it will remove all white space
#fig.subplots_adjust(left=0.13, right=0.9, top=0.9, bottom=0.12,hspace=0.0,wspace=0.0)
Notice that this means things like py.xlabel may not work as expected since you have two axis. Instead you need to specify ax1.set_xlabel("..") this makes the code easier to read.
More examples can be found here.