mwaskom / seaborn

Statistical data visualization in Python
https://seaborn.pydata.org
BSD 3-Clause "New" or "Revised" License
12.58k stars 1.93k forks source link

Plot legend needs more customizability #2994

Open mwaskom opened 2 years ago

mwaskom commented 2 years ago

There's not much ability to customize how the Plot legend appears, beyond what's available through Plot.theme. Desiderata include:

y9c commented 1 year ago

Hi @mwaskom. Thank you for developing the power Plot object. Any method for adjusting legend position in the latest version?

albertogomcas commented 1 year ago

For a lot of use cases it would just be enough to at least be able to silence the legend of a specific property. So, I want the dots of a scatterplot to have different hue based on property "a", but I don't want to see the legend, however I still want to see the legend for style, which is mapped to property "b". At the moment is all in, so you need to gut the matplotlib legend, or nothing, so you need to gut the matplotlib axes content to iterate over the styles and construct the legend by hand. Both are really painful.

Instead, the legend parameter of scatterplot (to keep the example) could also accept a dictionary, so instead of "auto”, “brief”, “full”, or False you could also pass, dict(hue=False, style="full", size="brief") and selectively change the behavior for each of the properties.

mwaskom commented 1 year ago

This issue is about the objects interface (where what you want actually is possible), so that comment is off topic. You may want to read through #2231

JeppeKlitgaard commented 1 year ago

I have run into this limitation as well. @thuiop has a work-around written in https://github.com/mwaskom/seaborn/issues/3247#issuecomment-1420731692 that I will copy over since I think people might end up finding this issue looking for a temporary solution:

# Credit: @thuiop 
def move_legend_fig_to_ax(fig, ax, loc, bbox_to_anchor=None, **kwargs):
    if fig.legends:
        old_legend = fig.legends[-1]
    else:
        raise ValueError("Figure has no legend attached.")

    old_boxes = old_legend.get_children()[0].get_children()

    legend_kws = inspect.signature(mpl.legend.Legend).parameters
    props = {
        k: v for k, v in old_legend.properties().items() if k in legend_kws
    }

    props.pop("bbox_to_anchor")
    title = props.pop("title")
    if "title" in kwargs:
        title.set_text(kwargs.pop("title"))
    title_kwargs = {k: v for k, v in kwargs.items() if k.startswith("title_")}
    for key, val in title_kwargs.items():
        title.set(**{key[6:]: val})
        kwargs.pop(key)
    kwargs.setdefault("frameon", old_legend.legendPatch.get_visible())

    # Remove the old legend and create the new one
    props.update(kwargs)
    fig.legends = []
    new_legend = ax.legend(
        [], [], loc=loc, bbox_to_anchor=bbox_to_anchor, **props
    )
    new_legend.get_children()[0].get_children().extend(old_boxes)

@thuiop could you elaborate on how to use this function? I can't get it to work with a

fig, ax = plt.subplots()
plot = so.Plot(...).on(ax)
plot.show()  # Needed otherwise `ValueError: Figure has no legend attached.`
move_legend_fig_to_ax(fig, ax, loc="center right")  # Doesn't do anything, but doesn't error
FischyM commented 1 year ago

@JeppeKlitgaard I was running into the same problem, except I was getting a plot with two legends where one was improperly placed. Here's a working example I got running that has two changes to the code you posted. The first was using plot.plot() instead of plot.show(), and the second was setting the first (and original?) legend visibility to False with fig.legends[0].set(visible=False).

import matplotlib as mpl
import inspect
import seaborn as sns
import seaborn.objects as so

def move_legend_fig_to_ax(fig, ax, loc, bbox_to_anchor=None, **kwargs):
    if fig.legends:
        fig.legends[0].set(visible=False)
        old_legend = fig.legends[-1]
    else:
        raise ValueError("Figure has no legend attached.")

    old_boxes = old_legend.get_children()[0].get_children()

    legend_kws = inspect.signature(mpl.legend.Legend).parameters
    props = {
        k: v for k, v in old_legend.properties().items() if k in legend_kws
    }

    props.pop("bbox_to_anchor")
    title = props.pop("title")
    if "title" in kwargs:
        title.set_text(kwargs.pop("title"))
    title_kwargs = {k: v for k, v in kwargs.items() if k.startswith("title_")}
    for key, val in title_kwargs.items():
        title.set(**{key[6:]: val})
        kwargs.pop(key)
    kwargs.setdefault("frameon", old_legend.legendPatch.get_visible())

    # Remove the old legend and create the new one
    props.update(kwargs)
    fig.legends = []
    new_legend = ax.legend(
        [], [], loc=loc, bbox_to_anchor=bbox_to_anchor, **props
    )
    new_legend.get_children()[0].get_children().extend(old_boxes)

penguins = sns.load_dataset("penguins")
fig, ax = plt.subplots()
plot = (
    so.Plot(penguins, x="bill_length_mm", y="bill_depth_mm",
            color="species", pointsize="body_mass_g",
            )
    .add(so.Dot())
    ).on(ax)
# plot.show()  # Needed otherwise `ValueError: Figure has no legend attached.`
plot.plot()
move_legend_fig_to_ax(fig, ax, loc="center", bbox_to_anchor=(0.7, 0.0, 0.9, 1))

Here are 3 figures showing the differences before and after my update to move_legend_fig_to_ax(). The first is the original code before move_legend_fig_to_ax(), the second is after running move_legend_fig_to_ax(), and the third is after my changes.

seaborn-legend-1 seaborn-legend-2 seaborn-legends-3

Full disclosure, I don't fully understand how or why this works, but in the end I got the plot legend looking how I wanted. It seems like calling plot.show() uses pyplot as the backend renderer but calling plot.show() uses whatever you have set. In my case, I'm using a Jupyter notebook in vscode, and after a little testing, my legends show up better using plot.plot() then plot.show(). Hope this helps!

thuiop commented 1 year ago

My bad for only seeing this now @JeppeKlitgaard. This is intended to be used after plotting using p.on(ax).plot() ; .show() actually calls plt.show() so the trick will not work (don't forget to call plt.show() manually later though). I am unsure of why @FischyM had to manually set thevisibility of the original to false though as it should not exist anymore.

jlec commented 3 months ago

Would be great to get this closed. Any update?