matplotlib / matplotlib

matplotlib: plotting with Python
https://matplotlib.org/stable/
20.2k stars 7.62k forks source link

[ENH]: Hyperlinks in legend #25567

Open HDembinski opened 1 year ago

HDembinski commented 1 year ago

Problem

tl;dr It would be great if URLs could be assigned to legend entries, so that a click on the legend entry in a SVG image brings you to the URL.

I am generating plots such as this one:

image

(Source: https://github.com/crdb-project/tutorial/blob/master/gallery.ipynb)

which includes a lot of data from other papers. The table in gray gives credit to those papers, which are listed via a key that is understood in the field (it is a key from this database https://ui.adsabs.harvard.edu, but that is an irrelevant detail).

It would be super nice if the entries in that table were clickable and would bring you to a page with that paper. I have the URLs, so I only need a way to make clickable legend entries, because this table is actually generated with a matplotlib legend object (this is also a detail, I could have programmed a proper table using VPacker, HPacker, and TextArea, but just misusing a legend was the simplest option).

Some searching made me aware of this feature here: https://matplotlib.org/stable/gallery/misc/hyperlinks_sgskip.html which works great to assign URLs to points. But I cannot get it to work for legend entries. Specifically, I tried this code:

from matplotlib import pyplot as plt
import numpy as np

fig = plt.figure()
plt.scatter([1, 2], [4, 6], label="BBC")
plt.scatter([1, 2, 3], [6, 5, 4], label="Google")
leg = plt.legend()
for entry in leg.get_children()[0].get_children()[1].get_children()[0].get_children():
    da, ta = entry.get_children()
    t = ta.get_text()
    if t == "BBC":
        url = 'https://www.bbc.com/news'
        ta.set_url(url)
        da.set_url(url)
        entry.set_url(url)
fig.savefig('scatter.svg')
ksunden commented 1 year ago

Well, for at least adding a link to the text, this works:

from matplotlib import pyplot as plt

fig = plt.figure()
plt.scatter([1, 2], [4, 6], label="BBC")
plt.scatter([1, 2, 3], [6, 5, 4], label="Google")
leg = plt.legend()
for ta in leg.texts:
    t = ta.get_text()
    if t == "BBC":
        url = 'https://www.bbc.com/news'
        ta.set_url(url)
fig.savefig('scatter.svg')

I may try a little more to add to the artist portion of the legend, possibly, but this is a start, at least.

HDembinski commented 1 year ago

Nice! But it is not ideal.

To click on the link you really need to be exactly on top of the text, which requires pixel-accurate pointing. It would be better if the whole TextArea was clickable instead.

Also, but this is a separate issue: I don't understand why your code works and my code does not.

jklymak commented 1 year ago

To click on the link you really need to be exactly on top of the text, which requires pixel-accurate pointing. It would be better if the whole TextArea was clickable instead.

I'm not clear what you mean here. The pointer has to be over the word, but you want the hit area to be larger somehow?

ksunden commented 1 year ago

@jklymak by default svg exports text as paths, and the link function requires you to click on the actual text itself (e.g. you cannot click in the middle of C, you have to click on the line.

Using:

plt.rcParams['svg.fonttype'] = 'none'

Causes the svg backend to use SVG text objects instead, which alleviates that particular problem. Setting to "svgfont" instead of "none" will embed the font, if that matters to you, rather than relying on the viewing machine to have the font installed/use a fallback font.

jklymak commented 1 year ago

OK, thanks, I can see that now. PDF doesn't seem to suffer from this behaviour.

ksunden commented 1 year ago

As to why your original code didn't work, what you get to is the TextArea object, not the Text object that leg.texts gets, the latter is actually a child, so you would need to do ta.get_children()[0].set_url(url) I suspect a similar thing is true of the DrawArea vs PathCollection of the original, though the thing that actually accepts a url may in fact be deeper yet. (May also need to be set_urls, plural, on the collection, but the obvious things didn't seem to work yet for the legend like they do the actual data. I suspect it may be being lost in redraw, as a new collection is made, possibly?)

HDembinski commented 1 year ago

@ksunden @jklymak Thank you for this feedback and the explanation, this all makes sense.

How do we go from here? After learning all this, I don't think that matplotlib needs a new API to make this easier, but it would be nice to add an example to the docs with this recipe, because there are some aspects which are not obvious, like setting plt.rcParams['svg.fonttype'] to 'none' or 'svgfont'.

Edit: I confirm that the amended recipe works as intended with SVG and PDF images and solves my problem. Thank you!

Edit2: I further confirm that this also works when the plots are embedded in a Jupyter notebook if

%config InlineBackend.figure_formats = ['svg']

is set.