8000 Bug Fix: DraggableBase not to move unselected object by fukatani · Pull Request #7894 · matplotlib/matplotlib · GitHub
[go: up one dir, main page]

Skip to content

Bug Fix: DraggableBase not to move unselected object #7894

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from

Conversation

fukatani
Copy link

See #5839

Current draggable object can be moved unintentionally.
The reason of the issue is that evt.artist changed during dragging and objects to be moved are added.

To fix it, self.got_artist must not change during dragging.
i.e. once evt.artist != self.ref_artist, self.got_artist will never become True.

Not to move unselected object
@anntzer
Copy link
Contributor
anntzer commented Jan 21, 2017

It would be nice to have a reproducible failure first (I cannot reproduce the issue following the instructions at #5839 (comment) either with 1.5.3 or 2.0).

@fukatani
Copy link
Author

I tried on 2.0.
Especially, it often occurs when annotation is dense.

import numpy as np
import matplotlib as mpl
mpl.use('tkagg')
import matplotlib.pyplot as plt
x = np.arange(50)
y = x**2
text = [str(i) for i in zip(x, y)]
plt.scatter(x, y)
for a, b, c in zip(x, y, text):
    plt.annotate(c, xy=(a, b), xytext=(a, b+2), arrowprops=dict(arrowstyle="->", color='r', lw=0.5)).draggable()
plt.show()

Please move (0,0) to right and cross other annotations.

@anntzer
Copy link
Contributor
anntzer commented Jan 21, 2017

I do not understand why this fix works (it does). In the example you give, when clicking in a dense zone, multiple pickevents are consecutively fired, one per artist in the vincinity of the click. Indeed, you'd prefer only one of them to be fired. But I do not see how your patch ensures that.

@fukatani
Copy link
Author

Multiple pickevents fired simultaneously, but each self.ref_artist is different.
The pickevent which satisfies evt.artist == self.ref_artist is only one.

And once self.got_artist and self.got_artist is decided, subsequent evt.artist changes are ignored until dragging.

The cause of the issue is evt.artist changing over time during dragging.
But the first evt.artist is unique.

@jklymak
Copy link
Member
jklymak commented Jul 25, 2019

Thanks for the PR. This hasn't seen action for a long time so marking stale. Note that PRs sometimes fall through the cracks, so if this is important, please ping for more reviews.

@tacaswell tacaswell added this to the needs sorting milestone Jul 25, 2019
@jklymak
Copy link
Member
jklymak commented Sep 27, 2019

Closing to due to lack of action. Not sure if this is still a bug or not, but feel free to re-open if you come back to it.

@jklymak jklymak closed this Sep 27, 2019
@anntzer
Copy link
Contributor
anntzer commented Sep 28, 2019

I can't repro the issue on master, bisecting says this was fixed in #10776 which seems at least possible.

@DexcTrack
Copy link

I pulled in this fix to stop the addition of unselected objects during the drag operation. It worked, but there was a strange side-effect. Sometimes, the annotation I tried to drag didn't move at all. I found that another annotation, so far removed that it was not even visible on the current display moved
instead.

My application has date + time-of-day on the Y axis, and a value on the X axis. I typically display one day of readings in my graph. The user can scroll backwards or forwards in time. I average about 5 annotations per day, and have more than a years worth of readings. To summarize, I have thousands of annotations, but only a small subset (maybe 5 - 20) is displayed to the user at any time.

Debugging revealed that the default selection routine, artist_picker(), operates on data which has been transformed from Data coordinates to Display coordinates. This transformation places every annotation within the current display space. As a result, when I tried to grab a particular annotation which is currently visible, artist_picker() would generate a large set of close annotations. In my testing, it sometimes found 50 annotations which passed the proximity test, even though the current display only showed 5 annotations, and only one of those was close. With fukatani's fix, a somewhat randomly selected first of these annotations would be the only annotation selected for dragging. It's not entirely random. The order of the set of close annotations tends to be consistent at any particular mouse position. If you just try again to drag your target annotation, it will usually fail in the same way. The more annotations you have in your data set, the less likely that one you care about dragging is actually moved.

After figuring out what was going wrong, I developed the following selection function with a fix for this matplotlib bug.

In addition, I made another modification to improve selection for my application. You may or may not want to include this change in your application.

#========================================================================================
#  The default implementation of artist_picker() has 2 issues which need to be overcome.
#========================================================================================
def draggable_anot_picker(self, artist, mouse_evt):
    ann = self.annotation
    if ann:
        # --------------------------------------------------------------------------------
        #   When looking for matching annotations, the default picker routine uses Display
        # coordinates. Unfortunately, the transformation from Data coordinates to Display
        # coordinates places EVERY annotation within the current display. So, even
        # annotations which are not currently visible on the screen can get accidentally
        # selected and dragged when the user is trying to drag an annotation which is
        # visible.
        #
        #   To fix this bug, we'll use the view limits of the current display to
        # filter out any annotation whose Data coordinates are outside those limits.
        # --------------------------------------------------------------------------------
        # Test whether the annotation is on the currently displayed axes view
        if (ann.axes.viewLim.x0 <= ann.xy[0] <= ann.axes.viewLim.x1) and \
           (ann.axes.viewLim.y0 <= ann.xy[1] <= ann.axes.viewLim.y1):
            pass
        else:
            return False, {}


        # --------------------------------------------------------------------------------
        #   If there are two or more annotations located near each other, the default
        # selection area for one annotation can completely eclipse another annotation.
        # For example:
        #
        #           'Annotation A Long String'
        #           /                        .
        #          /    'Annotation B'       .
        #          |   /             .       .
        #          |   |             . bbox  .
        #          |   V . . . . . . .       . bbox
        #          V . . . . . . . . . . . . .
        #
        #   The default selection area is the (bbox) rectangle including the entire arrow
        # and the text string. If the user is trying to drag 'Annotation B' and clicks the
        # mouse on top of that string, both 'Annotation A Long String' and 'Annotation B'
        # are within the selection group. If we're using fukatani's fix, then only the
        # randomly ordered, "first" of these elements in the group will be dragged. If
        # 'Annotation A Long String' is that first one, then the user will be unable to
        # drag 'Annotation B'. When they try to drag it, 'Annotation A Long String'
        # will move instead.
        #
        #   To fix this issue, we'll switch the selection area to a rectangle including
        # just the text string. The _get_xy_display() method provides the position of
        # the lower left corner of the string. The _get_rendered_text_width() method
        # provides the width, and get_size() provides the height.
        #
        #          ............................
        #          .'Annotation A Long String'.
        #          ./..........................
        #          /
        #          |   ................
        #          |   .'Annotation B'.       
        #          |   /...............
        #          |   |
        #          |   V
        #          V
        #
        #   If the user clicks on 'Annotation B', the mouse will be within that
        # text string, but outside of 'Annotation A Long String'. This greatly
        # reduces the area of possible collision.
        # --------------------------------------------------------------------------------

        # Find the location of the Text part of the annotation in Display coordinates
        #                +-----------------+ textX1,textY1
        #                | Annotation Text |
        #  textX0,textY0 +-----------------+
        textX0, textY0 = ann._get_xy_display()
        textX1 = textX0 + ann._get_rendered_text_width(ann.get_text())
        textY1 = textY0 + ann.get_size()

        # Test whether the mouse is within the Annotation Text area
        if (textX0 <= mouse_evt.x <= textX1) and \
           (textY0 <= mouse_evt.y <= textY1):
            pass
        else:
            return False, {}
    else:
        return False, {}
    return self.ref_artist.contains(mouse_evt)


import matplotlib as mpl

# For annotations, replace the default artist_picker method with a better one
mpl.offsetbox.DraggableAnnotation.artist_picker = draggable_anot_picker

@anntzer
Copy link
Contributor
anntzer commented Oct 14, 2019

A lot of this changed with #10776; can you check whether your patch is still needed with mpl 3.1?

@DexcTrack
Copy link

I tested with mpl 3.1.1, which includes (#10776), and found that this matplotlib release resolves fukatani's issue, and also the first part of my patch.

Selection response time seems a bit faster when the first part of my patch is included. In my test scenario, I had about 500 annotations. With my filter, roughly 495 of those were eliminated after a single X axis comparison, because they were located outside of the current view. When I disabled the first part of my patch, the time cost of calling the updated version of contains(), for each of those annotations outside of the current view, had to be payed.

The second part of my patch is not addressed within mpl 3.1.1. After disabling that second part, I tried to drag an inner annotation which was eclipsed by an outer annotation, and both annotations moved.

When the second part of my patch is included with mpl 3.1.1, the area of collision between two or more annotations is greatly reduced, so it's less likely to accidentally drag multiple annotations. However, it's still possible in the case that the Text part of multiple annotations are coincident.

This is where I really prefer the behavior change in fukatani's patch which restricts the selection down to a single annotation. Often, the reason I'm trying to drag an annotation is to eliminate a collision with another annotation. Moving both of them doesn't help resolve that situation. I prefer dragging just one annotation, even if it's the "wrong one". I can move it out of the way to separate it from the annotation I really want to move, and then separately drag them to their final positions.

In my application, all of the annotations have Text associated with them, but I've realized that it's possible that someone could have annotations with no Text, so I've slightly updated the code in my second part to handle this scenario.

        textX0, textY0 = ann._get_xy_display()
        try:
            textX1 = textX0 + ann._get_rendered_text_width(ann.get_text())
        except TypeError:
            textX1 = textX0

        if textX1 == textX0:
            # Annotation Text is empty, so don't require the mouse position
            # to be within the text area.
            pass
        else:
            textY1 = textY0 + ann.get_size()
            # Test whether the mouse position is within the Annotation Text area
            if (textX0 <= mouse_evt.x <= textX1) and \
               (textY0 <= mouse_evt.y <= textY1):
                pass
            else:
                return False, {}

This update will allow users to drag annotation arrows with no Text.

@anntzer
Copy link
Contributor
anntzer commented Oct 19, 2019

If I understand correctly, the first part of your patch is basically assuming that the user would not want to pick annotations which point to somewhere outside of the field of view, even if the annotation itself is visible (which happens if annotation_clip is False). Certainly that seems a reasonable optimization, but needs to handle various additional cases (annotation_clip being False, xy being in axes rather than data coordinates, etc.).

As for dragging multiple axes, I would rather have a more general mechanism for mouse events to be "swallowed" by a single event handler and not propagated to following ones, rather than having to code that separately in every handler.

@DexcTrack
Copy link

Good point. All of my annotations were using Data coordinates, and had clipping turned on. To generalize, the code would have to be modified to possibly transform the coordinate system, and to check for the clipping status. This would, of course, add overhead and reduce the level of time optimization. It's hard to know whether it's worthwhile, or not. I've been trying to come up with a way of measuring the time between the user click and the end of the selection, but haven't found one yet.

I agree that it would be great to have a general mechanism to swallow a mouse event by a single handler.

76B1
@story645 story645 removed this from the future releases milestone Oct 6, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants
0