0

I have two (or more) 3d scatter plots in subplots, each showing different 3 variables of a data set. When I hover over a data in one subplot, I'd like other subplots to also automatically show the same data sample. Currently, I am able to show annotation (on hover) for one plot using mplcursors module (in jupyter notebook), but I'd like the hover annotation to be linked between all subplots.

Below is the sample image generated with the current implementation: enter image description here

Minimal working code:

%matplotlib ipympl
import plotly.express as px
import matplotlib.pyplot as plt 
import mplcursors

df = px.data.iris()

fig = plt.figure(figsize=(12, 8))
ax = fig.add_subplot(121, projection='3d')

ax.scatter(df['sepal_length'], df['sepal_width'], df['petal_length'], marker='.')
ax.set_xlabel('Sepal Length')
ax.set_ylabel('Sepal Width')
ax.set_zlabel('Petal Length')
ax.set_title("Scatter plot of sepal length, sepal width, and petal length")

ax2 = fig.add_subplot(122, projection='3d')

ax2.scatter(df['sepal_length'], df['sepal_width'], df['petal_width'], marker='.')
ax2.set_xlabel('Sepal Length')
ax2.set_ylabel('Sepal Width')
ax2.set_zlabel('Petal Width')
ax2.set_title("Scatter plot of sepal length, sepal width, and petal width")

mplcursors.cursor(hover=True)

plt.show()

Thank you in advance.

3
  • I believe it may be covered here under the 'PS:' or here or here, but I haven't tried any yet. Commented Aug 3, 2024 at 5:14
  • Thank you, but none answers the question. I am looking for annotations to simultaneously display on all subplots that belong to the selected index in one plot. Commented Aug 5, 2024 at 13:18
  • Work in progress here. Doesn't yet work for 3D because of the complexities it adds. (mplcursors annotation seems to use projected data and not the original. So maybe I use mplcursors infrastructure and customize annotation for both. I was already customizing for opposite plot in case of much less complex 2D plot.) Commented Aug 6, 2024 at 17:37

1 Answer 1

0

Turns out that the 3D projection makes this so much harder for a lot of reasons.
It is fairly straightforward to trigger the corresponding annotation to show on two dimensional (2D) scatter subplots based on the mplcursor's example for "example of connecting to cursor events: when an artist is selected, also highlight its 'partner'":

%matplotlib ipympl
# originally based on https://mplcursors.readthedocs.io/en/stable/examples/paired_highlight.html , but to get nice mplcursors handling of 
# the tooltip-stye mplcursors annotation, add separate, basic matplotlib text annotation
import numpy as np
import matplotlib.pyplot as plt
import mplcursors

fig, axes = plt.subplots(ncols=2)
num = 5
xy = np.random.random((num, 2))
second_xy = xy.copy()
new_values_for_y = np.random.random(second_xy.shape[0])
second_xy[:, 1] = new_values_for_y

def which_subplot(artist, axes):
    for i, ax in enumerate(axes):
        if artist.axes == ax:
            return i
    return None  # If the artist is not in any of the given axes

def get_opposite_subplot(artist, axes):
    return 1 - which_subplot(artist, axes)

points = []
points2 = []
for indx, row in enumerate(xy):
    point, = axes[0].plot( row[0], row[1], linestyle="none", marker="o")
    points.append(point)
    point2, = axes[1].plot( second_xy[indx, 0], second_xy[indx, 1], linestyle="none", marker="o")
    points2.append(point2)

cursor = mplcursors.cursor(points + points2, hover=True)
pairs = dict(zip(points, points2))

a = list(pairs.keys())[0].get_data()
data = a
x, y = data[0][0], data[1][0]
xy_tuple = (x, y)
pairs.update(zip(points2, points))

ann_list = [] # set up list of text annotations to remove, based on https://stackoverflow.com/a/42315650/8508004

@cursor.connect("add")
def on_add(sel):
    global ann_list
    sel.extras.append(cursor.add_highlight(pairs[sel.artist]))
    if ann_list:
        ann_list[0].remove() # remove old text annotation, based on https://stackoverflow.com/a/42315650/8508004
        ann_list = [] # reset list of text annotations so they can be removed easily later, based on https://stackoverflow.com/a/42315650/8508004
    xydata = pairs[sel.artist].get_data()
    x, y = xydata[0][0], xydata[1][0]
    xy_tuple = (x, y)
    opposite_subplot_index_for_pair_item = which_subplot(pairs[sel.artist], axes)
    annotation_info4paired_point = f"x={x:.3f}\ny={y:.3f}"
    opppsite_annotation = axes[opposite_subplot_index_for_pair_item].annotate(annotation_info4paired_point,xy_tuple, xytext=(x+23, y+23), textcoords='offset pixels', bbox=dict(facecolor='lightyellow', edgecolor='black', boxstyle='round,pad=0.5'),arrowprops=dict(arrowstyle= '->',color='black',lw=2,ls='-'))
    ann_list.append(opppsite_annotation) # set up list of text annotations to remove, based on https://stackoverflow.com/a/42315650/8508004

plt.show()

2D Result:

2d multiple annotations on subplots

(Note that before hovering, the matching points were both green. The matching point you are not hovering over getting highlighted in yellow came from the original mplcursor code and I kept it as it really helped in troubleshooting because it generally worked as a nice indicator when adding features would otherwise break things.)

The real trick there was to adapt the paired handling to add matplotlib annotation to the side you are not hovering on.

Then going to 3D is much more difficult because of complexities with the projection scheme and what mplcursors seems to use to locate the data not being consistent with 3D. Then the way I added the basic matplotlib annotations also conflicts with 3D. (There are other things detailed in the companion Jupyter notebook I'll discuss below.)

Finally, I managed to get fairly close with more basic alternatives of all that and incorporating some code others had worked out to add arrows to 3D plots:

%matplotlib ipympl
import plotly.express as px
import matplotlib.pyplot as plt 
import mplcursors

df = px.data.iris()
df = df.iloc[:40].copy() #limit number of points for when in development stage, turns out using all breaks arrow on extra annotation somehow

fig, axes = plt.subplots(ncols=2,figsize=(12, 8),subplot_kw=dict(projection='3d'))# https://stackoverflow.com/a/71428840/8508004

points = []
dict_of_xyz_values_for_points = {} #the kets will be the artists info with the value the values for actual xyz, to use in mplcurose annotation text to work around
# mply cursors seeming to use 3D projected values by default.
points2 = []
labels_for_points2 = []
dict_of_xyz_values_for_points2 = {} #the kets will be the artists info with the value the values for actual xyz, to use in mplcurose annotation text to work around
# mply cursors seeming to use 3D projected values by default.
for row in df.itertuples():
    point, = axes[0].plot(row.sepal_length, row.sepal_width, row.petal_length, linestyle="none", marker="o",color="C0",alpha=0.6)
    points.append(point)
    dict_of_xyz_values_for_points[point]=(row.sepal_length, row.sepal_width, row.petal_length)
    point2, = axes[1].plot(row.sepal_length, row.sepal_width, row.petal_width, linestyle="none", marker="o", color="C0",alpha=0.6)
    points2.append(point2)
    dict_of_xyz_values_for_points2[point2]=(row.sepal_length, row.sepal_width, row.petal_width)

    
#axes[0].scatter(df['sepal_length'], df['sepal_width'], df['petal_length'], marker='.')
axes[0].set_xlabel('Sepal Length')
axes[0].set_ylabel('Sepal Width')
axes[0].set_zlabel('Petal Length')
axes[0].set_title("Scatter plot of sepal length, sepal width, and petal length")

#axes[1].scatter(df['sepal_length'], df['sepal_width'], df['petal_width'], marker='.')
axes[1].set_xlabel('Sepal Length')
axes[1].set_ylabel('Sepal Width')
axes[1].set_zlabel('Petal Width')
axes[1].set_title("Scatter plot of sepal length, sepal width, and petal width")

def which_subplot(artist, axes):
    for i, ax in enumerate(axes):
        if artist.axes == ax:
            return i
    return None  # If the artist is not in any of the given axes
def get_opposite_subplot(artist, axes):
    return 1 - which_subplot(artist, axes)

import numpy as np
from mpl_toolkits.mplot3d import Axes3D
from matplotlib.patches import FancyArrowPatch
from mpl_toolkits.mplot3d import proj3d

class Arrow3D(FancyArrowPatch):
    def __init__(self, xs, ys, zs, *args, **kwargs):
        super().__init__((0,0), (0,0), *args, **kwargs)
        self._verts3d = xs, ys, zs

    def do_3d_projection(self, renderer=None):
        xs3d, ys3d, zs3d = self._verts3d
        xs, ys, zs = proj3d.proj_transform(xs3d, ys3d, zs3d, self.axes.M)
        self.set_positions((xs[0],ys[0]),(xs[1],ys[1]))

        return np.min(zs)

# establish the pairs
cursor = mplcursors.cursor(points + points2, hover=True)
pairs = dict(zip(points, points2))
#print(list(pairs.keys())[0])
#print(list(pairs.keys())[0].get_data_3d()) #get xyz, based on https://stackoverflow.com/a/20131383/8508004 & https://stackoverflow.com/a/50797203/8508004
xyzdata = list(pairs.keys())[0].get_data_3d()
x, y , z= xyzdata[0][0], xyzdata[1][0], xyzdata[2][0]
xyz_tuple = (x, y, z)
#print(xyz_tuple)
pairs.update(zip(points2, points))

ann_list = [] # set up list of text annotations to remove, based on https://stackoverflow.com/a/42315650/8508004
arrow_list = []

@cursor.connect("add")
def on_add(sel):
    global ann_list
    global arrow_list
    # in order to set up overriding mplcursors default text with actual values,first determine which plot is being hovered on,
    # then override the mplcursor default annotation there with the appropriate xyz values from the dataframe
    if sel.artist in dict_of_xyz_values_for_points:
        (ox,oy,oz) = dict_of_xyz_values_for_points[sel.artist] # set overriding values
    else:
        (ox,oy,oz) = dict_of_xyz_values_for_points2[sel.artist] # set overriding values
    sel.annotation.set_text(f"x={ox:.2f}\ny={oy:.2f}\nz={oz:.2f}") # based on https://mplcursors.readthedocs.io/en/stable/examples/labeled_points.html    
    sel.extras.append(cursor.add_highlight(pairs[sel.artist]))
    # remove past 'extra' annotations
    if ann_list:
        ann_list[0].remove() # remove old text annotation, based on https://stackoverflow.com/a/42315650/8508004
        ann_list = [] # reset list of text annotations so they can be removed easily later, based on https://stackoverflow.com/a/42315650/8508004
    #remove past arrows, similarly
    if arrow_list:
        arrow_list[0].remove()
        arrow_list = []
    
    xyzdata = pairs[sel.artist].get_data_3d() #get xyz, based on https://stackoverflow.com/a/20131383/8508004 & https://stackoverflow.com/a/50797203/8508004
    x, y , z= xyzdata[0][0], xyzdata[1][0], xyzdata[2][0]
    xyz_tuple = (x, y, z)
    opposite_subplot_index_for_pair_item = which_subplot(pairs[sel.artist], axes)
    annotation_info4paired_point = f"x={x:.2f}\ny={y:.2f}\nz={z:.2f}"
    #opppsite_annotation = axes[opposite_subplot_index_for_pair_item].annotate(annotation_info4paired_point,xyz_tuple, xytext=(x+23, y+23), textcoords='offset pixels', bbox=dict(facecolor='lightyellow', edgecolor='black', boxstyle='round,pad=0.5'),arrowprops=dict(arrowstyle= '->',color='black',lw=2,ls='-'))
    offset = 0.06 # workaround idea because `doesn't sem to have `xytext`
    opppsite_annotation = axes[opposite_subplot_index_for_pair_item].text(x+offset, y+offset , z+offset, annotation_info4paired_point, bbox=dict(facecolor='lightyellow', edgecolor='black', boxstyle='round,pad=0.5')) # seems `arrowprops=` not allowed in combination with `axes.text()`? Or just not as straightforward as with `.annotate()`?
    arrow_prop_dict = dict(mutation_scale=11, arrowstyle='->', color='black',lw=1.01,ls='-')
    a = Arrow3D([x+offset, x], [y+offset,y], [z+offset, z], **arrow_prop_dict)
    axes[opposite_subplot_index_for_pair_item].add_artist(a) # based on https://stackoverflow.com/a/74122407/8508004
    ann_list.append(opppsite_annotation) # set up list of text annotations to remove, based on https://stackoverflow.com/a/42315650/8508004
    arrow_list.append(a)

plt.show()

3D RESULT:

3D multiple annotations showing result

My cursor was on the point on the left subplot at the time of this screen capture, but it works if you were hovering on a point on the right side, too.

There's still some minor rough edges like the arrow gets shorter/goes away with the current settings I implemented if you plot more 50 points of the example dataframe. However, I have drafted a detailed Jupyter Notebook with code detailing all this with links to related resources. The initial steps to the 2D version are there too, and the code can be run easily in sessions served by MyBinder.org using the directions in the top of the notebook. This will allow you to try out different settings without touching your own machine. (I also like this because it gives you another test environment as getting everything to work in Jupyter can be a challenge at the start.)

Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.