Phlya / adjustText

A small library for automatically adjustment of text position in matplotlib plots to minimize overlaps.
https://adjusttext.readthedocs.io/
MIT License
1.48k stars 87 forks source link

avoid crossing of the connector lines #182

Open aminihamed opened 1 month ago

aminihamed commented 1 month ago

is it possible to constrain the optimization further to avoid crossing of the connector lines? its practical application is shown below where the current setup (allowing to move along y only) dose not preserve the order of the original labels along the y direction. image

ggrepel appears to have such a constraint? https://ggrepel.slowkow.com/articles/examples#align-labels-on-the-left-or-right-edge

Phlya commented 1 month ago

How did you generate the picture above?

At the moment there is no explicit way to have this constraint (and I don't see where in the ggrepel example it's explicitly set to avoid crossings? I think it just worked that way with the chosen arguments), but usually in practice there is a way to minimize the crossings... For example, if you switch off explode (explode_radius=0 and/or force_explode=(0,0)) and reduce forces it should help.

aminihamed commented 1 month ago

Thanks for your message.

Phlya commented 1 month ago

Can you share your data and code?

aminihamed commented 1 month ago
import matplotlib.pyplot as plt
import matplotlib.colors as cm
from adjustText import adjust_text
from matplotlib.lines import Line2D

fig, ax = plt.subplots(figsize=(5,10))

ys = [1,1.1,1.2,2,2.5,3, 15,16,17, 50,52,53,54, 60,61,62,63,64,65,66, 100,101,102,103,104]
xs = len(ys) * [0]
labels = [str(i) for i in range(1, len(x)+1)]

ax.set_xlim(-1.5,1.5)
cm_pastel2 = 10 * [cm.to_hex(plt.cm.tab10(i)) for i in range(10)]

ii = -1
#an empty list for text objects
texts = []
for label,y in zip(labels, ys):
    ii = ii+ 1
    ax.plot(xs[ii], ys[ii], "+", c=cm_pastel2[ii])
    texts.append(ax.text(x=1, y=y, s=label, c=cm_pastel2[ii], fontdict={'fontweight':10}, va='center'))

#adjust text labels
adjtexts, _ = adjust_text(
    texts,
    avoid_self=False,
    only_move="y",  # Only allow movement vertically
    max_move=None,
    ensure_inside_axes=True,
    explode_radius=0,
    force_explode=(0.0, 0.0),
)

#plot the connector lines
ii = -1
for text in adjtexts:
    ii = ii + 1
    _, y_adjusted = text.get_position()
    line = Line2D(
        [0.1, 0.3, 0.7, 0.9],
        [ys[ii], ys[ii], y_adjusted, y_adjusted],
        clip_on=False,
        color=cm_pastel2[ii],
        linewidth=0.75,
    )
    ax.add_line(line)

image

The indentation does not look correct in the code that I pasted above. image

Phlya commented 1 month ago

OK, the main solution is actually switching off the pull force in this case (the force that tries to pull the labels back to their original positions, often useful to make sure they don't end up too far from them), and finding the right value for force_text. This works well for me:

import matplotlib.pyplot as plt
import matplotlib.colors as cm
from adjustText import adjust_text
from matplotlib.lines import Line2D

fig, ax = plt.subplots(figsize=(5,10))

ys = [1,1.1,1.2,2,2.5,3, 15,16,17, 50,52,53,54, 60,61,62,63,64,65,66, 100,101,102,103,104]
xs = len(ys) * [0]
labels = [str(i) for i in range(1, len(xs)+1)]

ax.set_xlim(-1.5,1.5)
cm_pastel2 = 10 * [cm.to_hex(plt.cm.tab10(i)) for i in range(10)]

ii = -1
#an empty list for text objects
texts = []
for label,y in zip(labels, ys):
    ii = ii+ 1
    ax.plot(xs[ii], ys[ii], "+", c=cm_pastel2[ii])
    texts.append(ax.text(x=1, y=y, s=label, c=cm_pastel2[ii], fontdict={'fontweight':10}, va='center'))

#adjust text labels
adjtexts, _ = adjust_text(
    texts,
    avoid_self=False,
    only_move="y",  # Only allow movement vertically
    max_move=None,
    ensure_inside_axes=True,
    force_pull=(0, 0),
    force_text=(0, 0.05),
)
#plot the connector lines
ii = -1
for text in adjtexts:
    ii = ii + 1
    _, y_adjusted = text.get_position()
    line = Line2D(
        [0.1, 0.3, 0.7, 0.9],
        [ys[ii], ys[ii], y_adjusted, y_adjusted],
        clip_on=False,
        color=cm_pastel2[ii],
        linewidth=0.75,
    )
    ax.add_line(line)

image

aminihamed commented 1 month ago

nice, however it seems that if the y axis is limited to the extend of the data i.e. 'ax.set_ylim(0, 105)' the crossing happens again.

Phlya commented 1 month ago

Yeah I guess that's because of ensure_inside_axes=True - it will force the labels outside axes limits inside the Axes, and then the crossing can occur.

Phlya commented 1 month ago

Or maybe there something else going on... Idk there is not specific mechanism to ensure this (yet - I have thought about adding something, but it's not trivial), so there is always tweaking involved, unfortunately.

aminihamed commented 1 month ago

sure, no problem. thanks for your support. I think I'll relax the y-axis limits on my plots for now :-)

ps: I'm not sure about the optimisation algorithm, but I was thinking in 1D application the constraint is to keep the order of distance from origin the same between the original and adjusted labels, and in 2D keep the order of distance (r) from origin and azimuth (alpha) the same. For example, in the figure below, the adjusted labels should satisfy the following conditions

pps: maybe the midpoint (center of gravity) of labels is a better choice than origin ....

image

Phlya commented 1 month ago

Generally wider axes limits give more space and usually the result of label placement looks better.

Seems like a nice idea, I'm not sure how to implement it with the current algorithm though... I was thinking something more naive like directly checking whether there are any overlaps between the lines connecting labels to their targets and if so, swapping the labels. It can get tricky since it's a lot of comparisons for all pairwise combinations, and there might be weird cases when more than two lines all overlap each other or smth... not sure how to deal with that, maybe a smarter approach like you suggest would work better...

Phlya commented 1 month ago

So I actually gave it a go, and I think it works... In the master branch the arrows shouldn't cross anymore, but it's not tested with complicated cases - i.e. not sure what will happen if multiple arrows all cross each.

ManavalanG commented 1 month ago

Thanks for both the question and script! This was super helpful.

Quick question. Changing the angle of text breaks it though. Is there a way to prevent this? Couple examples:

image
Phlya commented 1 week ago

Sorry for the late reply, I was on vacation. Did you install the version from github master branch to check the crossings? It's not perfect, but seems to generally work... Anyway, I am working on some improvements to that code too.

For the second point - there is just not enough space in the plot to accommodate such tall labels! So it breaks down trying to push them out of the Axes, while also ensuring they are inside the Axes limits... The result could be better, but I think it's simply impossible to expect a good result from such a setup.

Phlya commented 1 week ago

(It's also possible something about rotated texts causes issues in general, it's not something I have tested...)

ManavalanG commented 1 week ago

Thanks for the response!

  1. I used the most recent release (v1.2.0) available from conda.
  2. So if I make more room, for example, changing xaxis max to 4.0 instead of 1.5, it will likely work? Sorry, I haven't had a chance to put it to test.
Phlya commented 1 week ago

The code that explicitly removes overlaps is only available in the master branch so far, because it's very experimental.

In this case since you limit movement to only Y axis, you need to increase the Y axis limits, not X.

ManavalanG commented 1 week ago

Ah, that makes sense! Thanks!