Toggle labels in 3D graph by clicking on corresponding point

Dear Community,

I am trying to make it so that it is possible to click on a plot to toggle specific text labels for points. Below is an example code. I took some help from chatGPT to construct this but I am stuck at the fact that apparently the indices of the 3D scatter point artists and of the text artists are not aligned and I can’t go further than that. So my question is: how to make it so that by clicking on a given point, the associated text label gets toggled? (if it is displayed, it is removed, and if it is not there, then it is displayed?)

Thanks in advance.

Below is the code.

import PySimpleGUI as sg
import matplotlib.pyplot as plt
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
import numpy as np
import mpl_toolkits.mplot3d as mpl3d

class IndexedText:
    def __init__(self, text, index):
        self.text = text
        self.index = index
        self.visible = True

# Sample data
d_a = 10
d_b = 20
d_d = 15
solute = 'Sample Solute'
d_a_solvent_eff = np.random.rand(5) * 30
d_b_solvent_eff = np.random.rand(5) * 30
d_d_solvent = np.random.rand(5) * 10 + 12
solvents = [f'Solvent {i}' for i in range(1, 6)]

fig, ax = plt.subplots(figsize=(4.5, 3), dpi=80, subplot_kw={'projection': '3d'})
ax.set_xlabel(r"$\delta$$_{{A}}$ (effective) (MPa$^{{1/2}}$)")
ax.set_ylabel(r"$\delta$$_{{B}}$ (effective) (MPa$^{{1/2}}$)")
ax.set_zlabel(r"$\delta$$_{{D}}$ (MPa$^{{1/2}}$)")

# plot molecules to figure
scatter_solute = ax.scatter([d_a], [d_b], [d_d], color='blue', picker=True)
text_solute = ax.text(d_a, d_b, d_d, 'SOLUTE: %s' % (solute), size=8, zorder=1, color='blue', picker=True)
scatter_solvents = ax.scatter(d_a_solvent_eff, d_b_solvent_eff, d_d_solvent, label=solvents, color='black', picker=True)
text_solvents = [IndexedText(text, i) for i, text in enumerate(solvents)]
for i in range(len(text_solvents)):
    text = ax.text(d_a_solvent_eff[i], d_b_solvent_eff[i], d_d_solvent[i], '%s' % (text_solvents[i].text), size=5, zorder=1, color='black', picker=True)
    text_solvents[i].text_artist = text

ax.set_xlim([0, 30])
ax.set_ylim([0, 30])
ax.set_zlim([12, 22])
ax.mouse_init()  # make sure 3D plot is rotatable

# set up plot title
ax.set_title(solute)

# Embed figure in PySimpleGUI Canvas
layout = [[sg.Canvas(size=(450, 300), background_color='white', key='-CANVAS-', pad=(0, 0))]]
window = sg.Window('Matplotlib Plot in PySimpleGUI Canvas', layout, finalize=True)

# Embed figure in canvas and update interface to account for the change
canvas = FigureCanvasTkAgg(fig, master=window['-CANVAS-'].TKCanvas)
canvas.draw()
canvas.get_tk_widget().pack(side='top', fill='both', expand=1)

def on_pick(event):
    print(f'Type of the artist: {type(event.artist)}')
    print(f'Indices of picked points: {event.ind}')
    if isinstance(event.artist, mpl3d.art3d.Path3DCollection):
        # Access the data associated with the picked point using the index
        for index in event.ind:
            # Access the corresponding Text object using the loop index
            text_obj = text_solvents[index]

            # Get the 3D coordinates of the point
            x_points, y_points, z_points = event.artist._offsets3d
            
            x_point = x_points[index]
            y_point = y_points[index]
            z_point = z_points[index]

            # Convert 3D coordinates to screen space
            point_screen = ax.transData.transform_point((x_point, y_point))

            # Get the pixel coordinates
            x_screen, y_screen = point_screen[:2]

            # Toggle visibility of the text artist
            text_artist = text_obj.text_artist
            text_artist.set_visible(not text_artist.get_visible())

            print(f'Solvent {index + 1} is {text_obj.text}')
            print(f'2D Coordinates: ({x_point}, {y_point})')
            print(f'Screen Coordinates: ({x_screen}, {y_screen})')
            print("mouse coordinates", event.mouseevent.xdata, event.mouseevent.ydata, event.mouseevent.x, event.mouseevent.y)    
                
            # Redraw the canvas to reflect the changes
            canvas.draw()
        
        # Get the pixel coordinates of all text labels
        labels_screen = [text_obj.text_artist.get_position()
                         for text_obj in text_solvents]

        print('Screen Coordinates of Text Labels:')
        for i, label_screen in enumerate(labels_screen):
            print(f'{text_solvents[i].text}: {label_screen}')

        # Redraw the canvas to reflect the changes
        canvas.draw()
fig.canvas.mpl_connect('pick_event', on_pick)

while True:
    event, values = window.read()

    if event == sg.WIN_CLOSED:
        break

window.close()

After lots of trying, I solved my issue. Here is the general solution I found for introducing toggling of labels for a Matplotlib plot embedded in FigureCanvasTkAgg:

import matplotlib.pyplot as plt
import mpl_toolkits.mplot3d as mpl3d
import numpy as np
import PySimpleGUI as sg

from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
from mpl_toolkits.mplot3d              import proj3d

def toggle_3Dplot_labels_on_pick(event, ax, canvas):                                    #function that toggles points in matplotlib plots (with data stored in ax) embedded in a FigureCanvasTkAgg canvas

    #to use this function, you'll need to bind it to the figure, in the main code, using something like:
    # fig.canvas.mpl_connect('pick_event', lambda event: toggle_3Dplot_labels_on_pick(event, ax, canvas))
    
    if isinstance(event.artist, mpl3d.art3d.Path3DCollection):
        
        # Get the scatter plot data from the event artist
        scatter_data = event.artist._offsets3d

        # Extract x, y, and z coordinates
        x_coordinates = scatter_data[0].data
        y_coordinates = scatter_data[1].data
        z_coordinates = scatter_data[2]

        # Combine them into a NumPy array
        points = np.column_stack((x_coordinates, y_coordinates, z_coordinates))

        #for each point we check if it is closer to the mouse pointer
        min_distance = float('inf')
        closest_point = None
        for i in range(len(points)):
            # Convert 3D coordinates to screen space
            x_screen, y_screen, _ = proj3d.proj_transform(points[i][0], points[i][1], points[i][2], ax.get_proj())
            
            
            # Calculate Euclidean distance
            distance = np.sqrt((event.mouseevent.xdata - x_screen)**2 + (event.mouseevent.ydata - y_screen)**2)

            # Update closest point if the current point is closer
            if distance < min_distance:
                min_distance = distance
                closest_point = (points[i][0], points[i][1])
        
        #get text objects from the ax object
        text_objects = [artist for artist in ax.texts if isinstance(artist, mpl3d.art3d.Text3D)]
        
        
        # Get the coordinates of all text labels
        labels_screen = [text_obj.get_position()
                         for text_obj in text_objects]

        
        #for each label we check if it is closer to the clicked point
        min_distance_label = float('inf')
        closest_label = None
        closest_index = None
        
        
        for i, label_screen in enumerate(labels_screen):
            # Calculate Euclidean distance
            distance = np.sqrt((closest_point[0] - label_screen[0])**2 + (closest_point[1] - label_screen[1])**2)

            # Update closest point if the current point is closer
            if distance < min_distance_label:
                min_distance_label = distance
                closest_label = label_screen
                closest_index = i

        text_obj = text_objects[closest_index]
        
        # Toggle visibility of the text artist
        text_obj.set_visible(not text_obj.get_visible())
        
        # Redraw the canvas to reflect the changes
        canvas.draw()

Of course if you wish to use this, you’ll need to have the option picker=True when scattering the points, such as in the line:

ax.scatter(x, y, z, label = labs, color='black', picker=True)