Skip to content

Commit 1ed7c29

Browse files
committed
Support for interactive plots
1 parent e980620 commit 1ed7c29

File tree

3 files changed

+245
-15
lines changed

3 files changed

+245
-15
lines changed

eprpy/__init__.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1-
__version__ = "0.9.0"
2-
from eprpy.loader import *
3-
import numpy as np
1+
__version__ = "0.9.0a5"
2+
from eprpy.loader import load, EprData
3+
from eprpy.plotter import eprplot
4+
import numpy as np

eprpy/loader.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,7 @@ def get_DTA_datatype(dsc_parameter_dict):
240240
data_format_dict = {'C':'i1','S':'i2','I':'i4','F':'f4','D':'f8'}
241241
data_format_real = data_format_dict[dsc_parameter_dict['IRFMT']]
242242
else:
243-
raise ValueError(f'IRFMT keyword, which specifies the format of real values, could not be read from .DSC file.')
243+
raise ValueError('IRFMT keyword, which specifies the format of real values, could not be read from .DSC file.')
244244

245245
# get the byteorder, BIG for big endian, LIT for little endian
246246
if 'BSEQ' in dsc_parameter_dict:
@@ -414,9 +414,9 @@ def __init__(self,out_dict):
414414
self.history[0].append(deepcopy(self))
415415

416416

417-
def plot(self,g_scale=False,plot_type='stacked', slices='all', spacing=0.5,plot_imag=True):
417+
def plot(self,g_scale=False,plot_type='stacked', slices='all', spacing=0.5,plot_imag=True,interactive=False):
418418

419-
fig,ax = eprplot(self,plot_type,slices,spacing,plot_imag,g_scale=g_scale)
419+
fig,ax = eprplot(self,plot_type,slices,spacing,plot_imag,g_scale=g_scale,interactive=interactive)
420420
return fig,ax
421421

422422
def scale_between(self,min_val=None,max_val=None):

eprpy/plotter.py

Lines changed: 238 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import numpy as np
22
import matplotlib.pyplot as plt
33
from matplotlib import cm
4-
from matplotlib.widgets import Button
4+
from matplotlib.widgets import Button, Slider
55
import warnings
66
warnings.simplefilter('always')
77

88
color_cycle = plt.cm.tab10.colors[:10]
99

10-
def eprplot(eprdata_list, plot_type='stacked', slices='all', spacing=0.5,plot_imag=True,g_scale=False):
10+
def eprplot(eprdata_list, plot_type='stacked',
11+
slices='all', spacing=0.5,
12+
plot_imag=True,g_scale=False, interactive=False):
1113

1214
"""
1315
Plot one or multiple EPR data objects for visualization and comparison.
@@ -76,16 +78,20 @@ def eprplot(eprdata_list, plot_type='stacked', slices='all', spacing=0.5,plot_im
7678
elif ndim==2:
7779
fig,ax = plot_2d(eprdata_list,g_scale,plot_type, slices, spacing)
7880

79-
fig.set_size_inches((4,3))
80-
fig.tight_layout()
81+
if plot_type not in ['slider','surf']:
82+
fig.set_size_inches((4,3))
83+
fig.tight_layout()
84+
8185
if g_scale and eprdata_list[0].g is not None:
8286
ax.invert_xaxis()
8387

88+
if interactive:
89+
data_cursor(fig)
90+
8491
plt.show()
8592

8693
return fig,ax
8794

88-
8995
def plot_1d(eprdata_list,g_scale,plot_imag=True):
9096

9197
"""
@@ -142,7 +148,7 @@ def plot_1d(eprdata_list,g_scale,plot_imag=True):
142148
x = eprdata.x
143149

144150
if np.iscomplexobj(data):
145-
ax.plot(x,np.real(data), label=f'Real',color=color_cycle[c_idx])
151+
ax.plot(x,np.real(data), label='Real',color=color_cycle[c_idx])
146152
if plot_imag:
147153
ax.plot(x,np.imag(data), '--', alpha=0.5, label='Imaginary',color=color_cycle[c_idx])
148154
else:
@@ -225,7 +231,7 @@ def plot_2d(eprdata_list,g_scale,plot_type='stacked',slices='all', spacing=0.5):
225231
# Set color scheme for slices
226232
num_selected_slices = len(selected_slices)
227233

228-
slice_colors = cm.winter(np.linspace(0, 1, num_selected_slices)) # Gradual colors for larger slices
234+
slice_colors = cm.winter(np.linspace(0, 1, num_selected_slices))
229235

230236
if plot_type=='surf':
231237
fig,ax = surf_plot(data,x,y,slice_len,selected_slices)
@@ -235,7 +241,9 @@ def plot_2d(eprdata_list,g_scale,plot_type='stacked',slices='all', spacing=0.5):
235241
fig,ax = stack_plot(data,x,y,selected_slices,slice_colors,spacing)
236242
elif plot_type=='pcolor':
237243
fig,ax = pcolor_plot(data,x,y,slice_len,selected_slices)
238-
244+
elif plot_type=='slider':
245+
fig,ax = slider_plot(data,x,y,slice_len,selected_slices)
246+
239247
return fig,ax
240248

241249
def stack_plot(data,x,y,selected_slices,slice_colors,spacing):
@@ -317,7 +325,7 @@ def surf_plot(data,x,y,slice_len,selected_slices):
317325
fig,ax = plt.subplots(subplot_kw={"projection": "3d"})
318326
X, Y = np.meshgrid(x[range(slice_len)], y[selected_slices])
319327
Z = np.real(data[selected_slices, :]) if np.iscomplexobj(data) else data[selected_slices, :]
320-
surf = ax.plot_surface(X, Y, Z, cmap='viridis')
328+
surf = ax.plot_surface(X, Y, Z, cmap='jet',rstride=1, cstride=1)
321329
fig.colorbar(surf, ax=ax, shrink=0.5, aspect=10)
322330

323331
return fig,ax
@@ -404,6 +412,125 @@ def pcolor_plot(data,x,y,slice_len,selected_slices):
404412

405413
return fig,ax
406414

415+
416+
def slider_plot(data, x, y, slice_len, selected_slices):
417+
418+
"""
419+
Create a plot for selected slices of 2D EPR data with a slider.
420+
421+
Parameters
422+
----------
423+
data : ndarray
424+
The 2D data array from which the slices will be plotted.
425+
x : ndarray
426+
The x-axis values corresponding to the data.
427+
y : ndarray
428+
The y-axis values corresponding to the data.
429+
slice_len : int
430+
The length of each slice along the x-axis.
431+
selected_slices : list of int
432+
A list of slice indices to be plotted.
433+
434+
Returns
435+
-------
436+
fig : matplotlib.figure.Figure
437+
The figure object containing the pseudocolor plot.
438+
ax : matplotlib.axes._axes.Axes
439+
The axes object of the plot.
440+
441+
"""
442+
443+
fig, ax = plt.subplots(figsize=(8, 6))
444+
plt.subplots_adjust(left=0.15, bottom=0.3)
445+
446+
X, Y = x[:slice_len], y[selected_slices]
447+
Z = np.real(data[selected_slices, :]) if np.iscomplexobj(data) else data[selected_slices, :]
448+
449+
m, n = Z.shape
450+
swap_axes = [False]
451+
current_x_lim = None
452+
453+
# Initial plot (first row)
454+
line, = ax.plot(X, Z[0, :])
455+
ax.set_ylim(Z.min(), Z.max())
456+
ax.autoscale(False)
457+
458+
# Slider
459+
ax_slider = plt.axes([0.15, 0.15, 0.7, 0.03])
460+
slider = Slider(ax_slider, "Index", 0, m - 1, valinit=0, valstep=1,handle_style={'facecolor':'red','size':15},track_color='black')
461+
fig.slider = slider
462+
463+
# Swap Axes Button
464+
ax_button = plt.axes([0.15, 0.07, 0.2, 0.05])
465+
button = Button(ax_button, "Swap Axes")
466+
fig.button = button
467+
468+
# Value label
469+
ax_label = plt.axes([0.4, 0.07, 0.3, 0.05])
470+
ax_label.set_xticks([])
471+
ax_label.set_yticks([])
472+
label_text = ax_label.annotate(
473+
f"{Y[0]}", xy=(0.5, 0.5), ha="center", va="center", fontsize=10
474+
)
475+
476+
def update(val):
477+
"""Updates the plot based on slider value and axis mode."""
478+
index = int(slider.val)
479+
is_row_mode = not swap_axes[0]
480+
481+
if is_row_mode:
482+
x_data = X
483+
y_data = Z[index, :]
484+
ax.set_xlim(min(X), max(X))
485+
label_text.set_text(f"{Y[index]}")
486+
else:
487+
x_data = Y
488+
y_data = Z[:, index]
489+
ax.set_xlim(min(Y), max(Y))
490+
label_text.set_text(f"{X[index]}")
491+
492+
if current_x_lim:
493+
ax.set_xlim(current_x_lim)
494+
else:
495+
ax.autoscale(False)
496+
497+
line.set_xdata(x_data)
498+
line.set_ydata(y_data)
499+
500+
fig.canvas.draw_idle()
501+
502+
def swap_axes_clicked(event):
503+
"""Swaps row/column mode and updates slider limits."""
504+
swap_axes[0] = not swap_axes[0]
505+
new_max = n - 1 if swap_axes[0] else m - 1
506+
507+
slider.valmax = new_max
508+
slider.ax.set_xlim(slider.valmin, slider.valmax)
509+
slider.set_val(slider.valmin)
510+
update(0) # Force plot update
511+
512+
if swap_axes[0]:
513+
ax.set_xlim(min(Y), max(Y))
514+
else:
515+
ax.set_xlim(min(X), max(X))
516+
517+
fig.canvas.draw_idle()
518+
519+
def on_zoom(event):
520+
"""Handles zooming by the user."""
521+
nonlocal current_x_lim
522+
current_x_lim = ax.get_xlim()
523+
524+
# Attach event listeners
525+
slider.on_changed(update)
526+
button.on_clicked(swap_axes_clicked)
527+
528+
# Add zoom event listener
529+
fig.canvas.mpl_connect('button_release_event', on_zoom)
530+
531+
return fig, ax
532+
533+
407534
def interactive_points_selector(x,y):
408535

409536
"""
@@ -457,3 +584,105 @@ def done(event):
457584
selected_points_sorted = sorted(selected_points, key=lambda idx: x[idx])
458585

459586
return np.unique(np.array(selected_points_sorted, dtype=int))
587+
588+
def data_cursor(fig=None):
589+
590+
"""
591+
Adds an interactive data cursor to a Matplotlib figure.
592+
593+
This function enables a crosshair cursor that tracks mouse movement
594+
within a given figure and displays the current x and y coordinates.
595+
Additionally, it supports measuring horizontal distances between
596+
two x-coordinates using right-click dragging or Ctrl + Left Click.
597+
598+
Parameters
599+
----------
600+
fig : matplotlib.figure.Figure, optional
601+
The Matplotlib figure to which the data cursor will be added.
602+
If None, the current active figure (`plt.gcf()`) is used.
603+
604+
Notes
605+
-----
606+
- A red dashed crosshair follows the mouse position.
607+
- Clicking (Left Click) sets a reference vertical line.
608+
- Right Click or Ctrl + Left Click enables measuring horizontal distance.
609+
- The measured distance (ΔX) is displayed in the bottom-left of the figure.
610+
- The reference vertical line is removed on releasing the mouse button.
611+
"""
612+
613+
if fig is None:
614+
fig = plt.gcf()
615+
616+
ax = fig.gca()
617+
xlim = ax.get_xlim()
618+
ylim = ax.get_ylim()
619+
620+
h_line, = ax.plot([xlim[0], xlim[1]], [(ylim[0] + ylim[1]) / 2] * 2, 'r--', lw=1)
621+
v_line, = ax.plot([(xlim[0] + xlim[1]) / 2] * 2, [ylim[0], ylim[1]], 'r--', lw=1)
622+
623+
coord_text = ax.text(0.02, 0.95, '', transform=ax.transAxes, fontsize=10, color='red')
624+
dist_text = ax.text(0.02, 0.02, '', transform=ax.transAxes, fontsize=10, color='blue')
625+
ref_v_line, = ax.plot([], [], 'b-', lw=1)
626+
drag_v_line, = ax.plot([], [], 'b-', lw=1)
627+
628+
ref_x = None
629+
ctrl_pressed = False
630+
631+
def on_mouse_move(event):
632+
nonlocal ref_x
633+
if event.inaxes:
634+
x, y = event.xdata, event.ydata
635+
h_line.set_ydata([y, y])
636+
v_line.set_xdata([x, x])
637+
coord_text.set_text(f'X: {x:.2f}, Y: {y:.2f}')
638+
639+
# Handling dragging for right-click OR Ctrl + Left Click
640+
if (event.button == 3 or (ctrl_pressed and event.button is None)):
641+
if ref_x is None:
642+
ref_x = x
643+
ref_v_line.set_xdata([ref_x, ref_x])
644+
ref_v_line.set_ydata(ax.get_ylim())
645+
646+
dist_text.set_text(f'ΔX: {abs(x - ref_x):.2f}')
647+
drag_v_line.set_xdata([x, x])
648+
drag_v_line.set_ydata(ax.get_ylim())
649+
else:
650+
dist_text.set_text('')
651+
652+
fig.canvas.draw_idle()
653+
654+
def on_mouse_press(event):
655+
nonlocal ref_x
656+
if event.button == 1 and event.inaxes and not ctrl_pressed:
657+
ref_x = event.xdata
658+
ref_v_line.set_xdata([ref_x, ref_x])
659+
ref_v_line.set_ydata(ax.get_ylim())
660+
661+
def on_mouse_release(event):
662+
nonlocal ref_x
663+
if event.button == 1 or not ctrl_pressed:
664+
ref_x = None
665+
ref_v_line.set_xdata([])
666+
ref_v_line.set_ydata([])
667+
drag_v_line.set_xdata([])
668+
drag_v_line.set_ydata([])
669+
dist_text.set_text('')
670+
fig.canvas.draw_idle()
671+
672+
def on_key_press(event):
673+
nonlocal ctrl_pressed
674+
if event.key == 'control':
675+
ctrl_pressed = True
676+
677+
def on_key_release(event):
678+
nonlocal ctrl_pressed
679+
if event.key == 'control':
680+
ctrl_pressed = False
681+
682+
fig.canvas.mpl_connect('motion_notify_event', on_mouse_move)
683+
fig.canvas.mpl_connect('button_press_event', on_mouse_press)
684+
fig.canvas.mpl_connect('button_release_event', on_mouse_release)
685+
fig.canvas.mpl_connect('key_press_event', on_key_press)
686+
fig.canvas.mpl_connect('key_release_event', on_key_release)
687+
688+
plt.show()

0 commit comments

Comments
 (0)