From 529b89ebd11b6d6d9acaa13de04a66ac91201b9a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Mar 2026 22:10:00 +0000 Subject: [PATCH 1/8] Add third variable (Z/color) selection with color scale and 3D view - ColumnPanel: add comboZ dropdown (Z/C:) for selecting a third variable as a color/height axis; updates alongside comboX columns - SelectionPanel: bind comboZ events and include Z column index in ID tuples passed to PlotData - PlotData: add iz, sz, z, zIsString, zIsDate attributes; fromIDs() loads Z column when idx has 8+ elements - GUIPlotPanel: add ColorCtrlPanel with colormap selector, colorbar toggle, and 3D view checkbox; shown automatically when a Z variable is selected - plotSignals(): when Z is set, renders a scatter plot colored by Z using the chosen colormap and optionally adds a colorbar; when 3D is enabled, renders a full 3D scatter plot with Z as the spatial z-axis - set_subplots(): creates 3D axes (projection='3d') when 3D view is active - figure.py (SwappyFigure): gracefully handle non-SwappyAxes (e.g. Axes3D) by adding compatibility shims for set_xlim_/get_xlim_/etc. - _store_limits/_restore_limits: guard against AttributeError on non-Swappy axes (e.g. colorbar axes added by matplotlib) https://claude.ai/code/session_019co5puUhq1kxsuWG57SPzC --- pydatview/GUIPlotPanel.py | 192 ++++++++++++++++++++++++++------- pydatview/GUISelectionPanel.py | 61 +++++++++-- pydatview/figure.py | 11 +- pydatview/plotdata.py | 16 +++ 4 files changed, 231 insertions(+), 49 deletions(-) diff --git a/pydatview/GUIPlotPanel.py b/pydatview/GUIPlotPanel.py index 24f8f91..3a2433b 100644 --- a/pydatview/GUIPlotPanel.py +++ b/pydatview/GUIPlotPanel.py @@ -34,6 +34,10 @@ raise e # from matplotlib.figure import Figure from pydatview.figure import SwappyFigure as Figure +try: + from mpl_toolkits.mplot3d import Axes3D # noqa: F401 - registers 3d projection +except ImportError: + pass from matplotlib.pyplot import rcParams as pyplot_rc from matplotlib import font_manager from pandas.plotting import register_matplotlib_converters @@ -209,6 +213,45 @@ def _GUI2Data(self): data = {'type': self.rbType.GetString(self.rbType.GetSelection())} return data +class ColorCtrlPanel(wx.Panel): + """Control panel shown when a Z/color variable is selected.""" + COLORMAPS = ['viridis','plasma','inferno','magma','cividis','coolwarm','RdYlBu','jet','rainbow','turbo','hot','bone'] + + def __init__(self, parent): + super(ColorCtrlPanel, self).__init__(parent) + self.parent = parent + lbCmap = wx.StaticText(self, -1, 'Colormap:') + self.cbCmap = wx.ComboBox(self, choices=self.COLORMAPS, style=wx.CB_READONLY) + self.cbCmap.SetSelection(0) + self.cbColorBar = wx.CheckBox(self, -1, 'Colorbar') + self.cbColorBar.SetValue(True) + self.cb3D = wx.CheckBox(self, -1, '3D view') + self.cb3D.SetValue(False) + dummy_sizer = wx.BoxSizer(wx.HORIZONTAL) + dummy_sizer.Add(lbCmap , 0, flag=wx.CENTER|wx.LEFT, border=2) + dummy_sizer.Add(self.cbCmap , 0, flag=wx.CENTER|wx.LEFT, border=2) + dummy_sizer.Add(self.cbColorBar , 0, flag=wx.CENTER|wx.LEFT, border=8) + dummy_sizer.Add(self.cb3D , 0, flag=wx.CENTER|wx.LEFT, border=8) + self.SetSizer(dummy_sizer) + self.Bind(wx.EVT_COMBOBOX, self.onOptionChange, self.cbCmap) + self.Bind(wx.EVT_CHECKBOX, self.onOptionChange, self.cbColorBar) + self.Bind(wx.EVT_CHECKBOX, self.on3DChange, self.cb3D) + self.Hide() + + def onOptionChange(self, event=None): + self.parent.redraw_same_data() + + def on3DChange(self, event=None): + self.parent.load_and_draw() + + def _GUI2Data(self): + return { + 'colormap': self.cbCmap.GetStringSelection(), + 'colorbar': self.cbColorBar.IsChecked(), + 'view3D': self.cb3D.IsChecked(), + } + + class SpectralCtrlPanel(wx.Panel): def __init__(self, parent): super(SpectralCtrlPanel,self).__init__(parent) @@ -593,11 +636,12 @@ def __init__(self, parent, selPanel, pipeLike=None, infoPanel=None, data=None): # --- Tool Panel self.toolSizer= wx.BoxSizer(wx.VERTICAL) # --- Plot type specific options - self.spcPanel = SpectralCtrlPanel(self) - self.pdfPanel = PDFCtrlPanel(self) - self.cmpPanel = CompCtrlPanel(self) - self.mmxPanel = MinMaxPanel(self) - self.polPanel = PolarPanel(self) + self.spcPanel = SpectralCtrlPanel(self) + self.pdfPanel = PDFCtrlPanel(self) + self.cmpPanel = CompCtrlPanel(self) + self.mmxPanel = MinMaxPanel(self) + self.polPanel = PolarPanel(self) + self.colorPanel = ColorCtrlPanel(self) # --- PlotType Panel (Needs the different pansel above) self.pltTypePanel= PlotTypePanel(self); @@ -702,18 +746,19 @@ def __init__(self, parent, selPanel, pipeLike=None, infoPanel=None, data=None): self.slEsth = wx.StaticLine(self, -1, size=wx.Size(-1,1), style=wx.LI_HORIZONTAL) self.slEsth.Hide() sl1 = wx.StaticLine(self, -1, size=wx.Size(-1,1), style=wx.LI_HORIZONTAL) - plotsizer.Add(self.toolSizer,0,flag = wx.EXPAND|wx.CENTER|wx.TOP|wx.BOTTOM,border = 10) - plotsizer.Add(self.canvas ,1,flag = wx.EXPAND,border = 5 ) - plotsizer.Add(sl1 ,0,flag = wx.EXPAND,border = 0) - plotsizer.Add(self.spcPanel ,0,flag = wx.EXPAND|wx.CENTER|wx.TOP|wx.BOTTOM,border = 10) - plotsizer.Add(self.pdfPanel ,0,flag = wx.EXPAND|wx.CENTER|wx.TOP|wx.BOTTOM,border = 10) - plotsizer.Add(self.cmpPanel ,0,flag = wx.EXPAND|wx.CENTER|wx.TOP|wx.BOTTOM,border = 10) - plotsizer.Add(self.mmxPanel ,0,flag = wx.EXPAND|wx.CENTER|wx.TOP|wx.BOTTOM,border = 10) - plotsizer.Add(self.polPanel ,0,flag = wx.EXPAND|wx.CENTER|wx.TOP|wx.BOTTOM,border = 10) - plotsizer.Add(self.slEsth ,0,flag = wx.EXPAND,border = 0) - plotsizer.Add(self.esthPanel,0,flag = wx.EXPAND|wx.CENTER|wx.TOP|wx.BOTTOM,border = 10) - plotsizer.Add(self.slCtrl ,0,flag = wx.EXPAND,border = 0) - plotsizer.Add(row_sizer ,0,flag = wx.EXPAND|wx.NORTH ,border = 2) + plotsizer.Add(self.toolSizer ,0,flag = wx.EXPAND|wx.CENTER|wx.TOP|wx.BOTTOM,border = 10) + plotsizer.Add(self.canvas ,1,flag = wx.EXPAND,border = 5 ) + plotsizer.Add(sl1 ,0,flag = wx.EXPAND,border = 0) + plotsizer.Add(self.spcPanel ,0,flag = wx.EXPAND|wx.CENTER|wx.TOP|wx.BOTTOM,border = 10) + plotsizer.Add(self.pdfPanel ,0,flag = wx.EXPAND|wx.CENTER|wx.TOP|wx.BOTTOM,border = 10) + plotsizer.Add(self.cmpPanel ,0,flag = wx.EXPAND|wx.CENTER|wx.TOP|wx.BOTTOM,border = 10) + plotsizer.Add(self.mmxPanel ,0,flag = wx.EXPAND|wx.CENTER|wx.TOP|wx.BOTTOM,border = 10) + plotsizer.Add(self.polPanel ,0,flag = wx.EXPAND|wx.CENTER|wx.TOP|wx.BOTTOM,border = 10) + plotsizer.Add(self.colorPanel ,0,flag = wx.EXPAND|wx.CENTER|wx.TOP|wx.BOTTOM,border = 10) + plotsizer.Add(self.slEsth ,0,flag = wx.EXPAND,border = 0) + plotsizer.Add(self.esthPanel ,0,flag = wx.EXPAND|wx.CENTER|wx.TOP|wx.BOTTOM,border = 10) + plotsizer.Add(self.slCtrl ,0,flag = wx.EXPAND,border = 0) + plotsizer.Add(row_sizer ,0,flag = wx.EXPAND|wx.NORTH ,border = 2) self.SetSizer(plotsizer) self.plotsizer=plotsizer; @@ -957,13 +1002,18 @@ def sharex(self): return self.cbSync.IsChecked() and (not self.pltTypePanel.cbPDF.GetValue()) def set_subplots(self,nPlots): + # Determine if 3D view is requested + hasZ = any(pd.z is not None for pd in self.plotData) + use3D = hasZ and self.colorPanel.cb3D.IsChecked() # Creating subplots for ax in self.fig.axes: self.fig.delaxes(ax) sharex=None for i in range(nPlots): # Vertical stack - if i==0: + if use3D: + ax = self.fig.add_subplot(nPlots, 1, i+1, projection='3d') + elif i==0: ax=self.fig.add_subplot(nPlots,1,i+1) # Store first axis to share with other if self.sharex: @@ -1178,13 +1228,21 @@ def getPlotData(self, plotType=None): for i,idx in enumerate(ID): # Initialize each plotdata based on selected table and selected id channels PD = PlotData(); - PD.fromIDs(tabs, i, idx, SameCol, pipeline=self.pipeLike) + PD.fromIDs(tabs, i, idx, SameCol, pipeline=self.pipeLike) self.transformPlotData(PD, firstCall=i==0) self.plotData.append(PD) except Exception as e: self.plotData=[] raise e + # Show/hide colorPanel based on whether any plot has a Z/color variable + hasZ = any(pd.z is not None for pd in self.plotData) + if hasZ: + self.colorPanel.Show() + else: + self.colorPanel.Hide() + self.plotsizer.Layout() + def PD_Compare(self,mode): """ Perform comparison of the selected PlotData, returns new plotData with the comparison. """ sComp = self.cmpPanel.rbType.GetStringSelection() @@ -1528,6 +1586,11 @@ def plot_all(self, autoscale=True): ax_right.set_ylabel(' and '.join(yright_labels), **font_options) elif ax_right is not None: ax_right.set_ylabel('') + # Z label for 3D plots + if hasattr(ax_left, 'set_zlabel'): + z_labels = unique([PD[i].sz for i in ax_left.iPD if PD[i].z is not None]) + if len(z_labels) > 0 and len(z_labels) <= 3: + ax_left.set_zlabel(' and '.join(z_labels), **font_options) # Legends lgdLoc = plotStyle['LegendPosition'].lower() @@ -1562,9 +1625,15 @@ def plot_all(self, autoscale=True): # NOTE: cursors needs to be stored in the object! #for ax_left in self.fig.axes: # self.cursors.append(MyCursor(ax_left,horizOn=True, vertOn=False, useblit=True, color='gray', linewidth=0.5, linestyle=':')) - # Vertical cusor for all, commonly + # Vertical cusor for all, commonly (not supported for 3D axes) bXHair = self.cbXHair.GetValue() - self.multiCursors = MyMultiCursor(self.canvas, tuple(self.fig.axes), useblit=True, horizOn=bXHair, vertOn=bXHair, color='gray', linewidth=0.5, linestyle=':') + hasZ = any(pd.z is not None for pd in PD) + use3D = hasZ and self.colorPanel.cb3D.IsChecked() + if not use3D: + try: + self.multiCursors = MyMultiCursor(self.canvas, tuple(self.fig.axes), useblit=True, horizOn=bXHair, vertOn=bXHair, color='gray', linewidth=0.5, linestyle=':') + except Exception: + pass def plotSignals(self, ax, axis_idx, PD, pm, left_right, opts): axis = None @@ -1574,6 +1643,12 @@ def plotSignals(self, ax, axis_idx, PD, pm, left_right, opts): else: loop_range = range(len(PD)) + # Gather color-panel options once + colorOpts = self.colorPanel._GUI2Data() + colormap = colorOpts['colormap'] + showColorBar = colorOpts['colorbar'] + use3D = colorOpts['view3D'] + iPlot=-1 for signal_idx in loop_range: do_plot = False @@ -1589,23 +1664,51 @@ def plotSignals(self, ax, axis_idx, PD, pm, left_right, opts): axis._get_lines.prop_cycler = ax._get_lines.prop_cycler pd=PD[signal_idx] if do_plot: - iPlot+=1 - # --- styling per plot - if len(pd.x)==1: - marker='o'; ls='' - else: - # TODO allow PlotData to override for "per plot" options in the future - marker = opts['Markers'][np.mod(iPlot,len(opts['Markers']))] - ls = opts['LineStyles'][np.mod(iPlot,len(opts['LineStyles']))] - if opts['step']: - plot = axis.step + iPlot+=1 + hasZ = pd.z is not None and not pd.zIsString + if hasZ and use3D: + # 3D scatter: x, y, z as spatial axes + try: + sc = axis.scatter(pd.x, pd.y, pd.z, label=pd.syl, s=opts['ms']**2, + cmap=colormap, c=pd.z) + if showColorBar: + cb = self.fig.colorbar(sc, ax=axis, label=pd.sz, shrink=0.7, pad=0.1) + except Exception: + axis.scatter(pd.x, pd.y, pd.z, label=pd.syl, s=opts['ms']**2) + try: + bAllNeg = bAllNeg and all(pd.y<=0) + except Exception: + pass + elif hasZ: + # 2D scatter colored by Z variable + try: + sc = axis.scatter(pd.x, pd.y, c=pd.z, cmap=colormap, + label=pd.syl, s=opts['ms']**2) + if showColorBar: + cb = self.fig.colorbar(sc, ax=axis, label=pd.sz) + except Exception: + axis.scatter(pd.x, pd.y, label=pd.syl, s=opts['ms']**2) + try: + bAllNeg = bAllNeg and all(pd.y<=0) + except Exception: + pass else: - plot = axis.plot - plot(pd.x,pd.y,label=pd.syl,ms=opts['ms'], lw=opts['lw'], marker=marker, ls=ls) - try: - bAllNeg = bAllNeg and all(pd.y<=0) - except: - pass # Dates or strings + # --- styling per plot + if len(pd.x)==1: + marker='o'; ls='' + else: + # TODO allow PlotData to override for "per plot" options in the future + marker = opts['Markers'][np.mod(iPlot,len(opts['Markers']))] + ls = opts['LineStyles'][np.mod(iPlot,len(opts['LineStyles']))] + if opts['step']: + plot = axis.step + else: + plot = axis.plot + plot(pd.x,pd.y,label=pd.syl,ms=opts['ms'], lw=opts['lw'], marker=marker, ls=ls) + try: + bAllNeg = bAllNeg and all(pd.y<=0) + except: + pass # Dates or strings return axis, bAllNeg def findPlotMode(self,PD): @@ -1865,13 +1968,20 @@ def _store_limits(self): self.xlim_prev = [] self.ylim_prev = [] for ax in self.fig.axes: - self.xlim_prev.append(ax.get_xlim_()) - self.ylim_prev.append(ax.get_ylim_()) + try: + self.xlim_prev.append(ax.get_xlim_()) + self.ylim_prev.append(ax.get_ylim_()) + except AttributeError: + self.xlim_prev.append((0, 1)) + self.ylim_prev.append((0, 1)) def _restore_limits(self): for ax, xlim, ylim in zip(self.fig.axes, self.xlim_prev, self.ylim_prev): - ax.set_xlim_(xlim) - ax.set_ylim_(ylim) + try: + ax.set_xlim_(xlim) + ax.set_ylim_(ylim) + except AttributeError: + pass if __name__ == '__main__': import pandas as pd; diff --git a/pydatview/GUISelectionPanel.py b/pydatview/GUISelectionPanel.py index 69433a5..148637c 100644 --- a/pydatview/GUISelectionPanel.py +++ b/pydatview/GUISelectionPanel.py @@ -744,6 +744,11 @@ def __init__(self, parent, selPanel): self.comboX.SetFont(getMonoFont(self)) self.lbColumns=wx.ListBox(self, -1, choices=[], style=wx.LB_EXTENDED ) self.lbColumns.SetFont(getMonoFont(self)) + # Z/Color variable selector + self.lbZ = wx.StaticText(self, -1, 'Z/C:') + self.comboZ = wx.ComboBox(self, choices=['None'], style=wx.CB_READONLY) + self.comboZ.SetFont(getMonoFont(self)) + self.comboZ.SetSelection(0) # Events self.lbColumns.Bind(wx.EVT_RIGHT_DOWN, self.OnColPopup) self.lbColumns.Bind(wx.EVT_MOTION, self.OnColMotion) @@ -751,6 +756,9 @@ def __init__(self, parent, selPanel): # Layout sizerX = wx.BoxSizer(wx.HORIZONTAL) sizerX.Add(self.comboX , 1, flag=wx.TOP | wx.BOTTOM, border=2) + sizerZ = wx.BoxSizer(wx.HORIZONTAL) + sizerZ.Add(self.lbZ , 0, flag=wx.CENTER|wx.RIGHT, border=2) + sizerZ.Add(self.comboZ , 1, flag=wx.TOP | wx.BOTTOM, border=2) sizerF = wx.BoxSizer(wx.HORIZONTAL) sizerF.Add(self.tFilter, 1, flag= wx.CENTER|wx.TOP , border=0) @@ -762,6 +770,7 @@ def __init__(self, parent, selPanel): sizerCol.Add(tb , 0, flag=wx.EXPAND|wx.LEFT|wx.RIGHT|wx.BOTTOM,border=1) #sizerCol.Add(self.comboX , 0, flag=wx.TOP|wx.RIGHT|wx.BOTTOM|wx.TOP,border=2) sizerCol.Add(sizerX , 0, flag=wx.EXPAND, border=0) + sizerCol.Add(sizerZ , 0, flag=wx.EXPAND, border=0) sizerCol.Add(sizerF , 0, flag=wx.EXPAND|wx.TOP|wx.BOTTOM, border=0) sizerCol.Add(self.lbColumns, 2, flag=wx.EXPAND, border=0) self.SetSizer(sizerCol) @@ -827,11 +836,13 @@ def getGUIcolumns(self): def _setReadOnly(self): self.bReadOnly=True self.comboX.Enable(False) + self.comboZ.Enable(False) self.lbColumns.Enable(False) def _unsetReadOnly(self): self.bReadOnly=False self.comboX.Enable(True) + self.comboZ.Enable(True) self.lbColumns.Enable(True) def setReadOnly(self, tabLabel=None, cols=[]): @@ -970,6 +981,15 @@ def setGUIColumns(self, xSel=-1, ySel=[], selInFull=True): columnsX_show=columnsX self.comboX.Set(columnsX_show) # non filtered + # Populate comboZ with None + same columns as comboX (full, non-filtered) + prevZSel = self.comboZ.GetSelection() + columnsZ_show = np.append(['None'], columnsX_show if len(columnsX) > MAX_X_COLUMNS else columnsX) + self.comboZ.Set(columnsZ_show) + if prevZSel >= 0 and prevZSel < len(columnsZ_show): + self.comboZ.SetSelection(prevZSel) + else: + self.comboZ.SetSelection(0) # default: None + # Set selection for y, if any, and considering filtering if selInFull: for iFull in ySel: @@ -1006,10 +1026,14 @@ def forceZeroSelection(self): def empty(self): self.lbColumns.Clear() self.comboX.Clear() + self.comboZ.Clear() + self.comboZ.Append('None') + self.comboZ.SetSelection(0) self.lb.SetLabel('') self.bReadOnly=False self.lbColumns.Enable(False) self.comboX.Enable(False) + self.comboZ.Enable(False) self.bt.Enable(False) self.tab=None self.columns=[] @@ -1045,6 +1069,20 @@ def getColumnSelection(self): self.setGUIColumns(xSel=iXFull, ySel=IYFull) return iXFull,IYFull,sX,SY + def getZColumnSelection(self): + """ + Return the Z/color column selection. + iZ: index in full table (-1 means 'None'/no Z variable) + sZ: column name string + """ + iZ = self.comboZ.GetSelection() + if iZ <= 0: # 0 = 'None', -1 = nothing selected + return -1, '' + # iZ-1 because first item is 'None' + iZFull = iZ - 1 + sZ = self.comboZ.GetStringSelection() + return iZFull, sZ + def onClearFilter(self, event=None): self.tFilter.SetValue('') self.onFilterChange() @@ -1111,10 +1149,13 @@ def __init__(self, parent, tabList, mode='auto', mainframe=None): # BINDINGS self.Bind(wx.EVT_COMBOBOX, self.onColSelectionChange, self.colPanel1.comboX ) self.Bind(wx.EVT_LISTBOX , self.onColSelectionChange, self.colPanel1.lbColumns) + self.Bind(wx.EVT_COMBOBOX, self.onColSelectionChange, self.colPanel1.comboZ ) self.Bind(wx.EVT_COMBOBOX, self.onColSelectionChange, self.colPanel2.comboX ) self.Bind(wx.EVT_LISTBOX , self.onColSelectionChange, self.colPanel2.lbColumns) + self.Bind(wx.EVT_COMBOBOX, self.onColSelectionChange, self.colPanel2.comboZ ) self.Bind(wx.EVT_COMBOBOX, self.onColSelectionChange, self.colPanel3.comboX ) self.Bind(wx.EVT_LISTBOX , self.onColSelectionChange, self.colPanel3.lbColumns) + self.Bind(wx.EVT_COMBOBOX, self.onColSelectionChange, self.colPanel3.comboZ ) self.Bind(wx.EVT_LISTBOX, self.onTabSelectionChange, self.tabPanel.lbTab) # TRIGGERS @@ -1555,6 +1596,7 @@ def getPlotDataSelection(self): ITab,STab = self.getSelectedTables() if self.currentMode=='simColumnsMode' and len(ITab)>1: iiX1,IY1,ssX1,SY1 = self.colPanel1.getColumnSelection() + iZ1,sZ1 = self.colPanel1.getZColumnSelection() SameCol=False for i,(itab,stab) in enumerate(zip(ITab,STab)): IKeep=self.IKeepPerTab[i] @@ -1563,26 +1605,31 @@ def getPlotDataSelection(self): sy = self.tabList[itab].columns[IKeep[iiy]] iX1 = IKeep[iiX1] sX1 = self.tabList[itab].columns[IKeep[iiX1]] - ID.append([itab,iX1,iy,sX1,sy,stab]) + iZ = IKeep[iZ1] if iZ1 >= 0 and iZ1 < len(IKeep) else -1 + szZ = self.tabList[itab].columns[iZ] if iZ >= 0 else '' + ID.append([itab,iX1,iy,sX1,sy,stab,iZ,szZ]) else: iX1,IY1,sX1,SY1 = self.colPanel1.getColumnSelection() + iZ1,sZ1 = self.colPanel1.getZColumnSelection() SameCol=self.tabList.haveSameColumns(ITab) if self.nSplits in [0,1] or SameCol: for i,(itab,stab) in enumerate(zip(ITab,STab)): for j,(iy,sy) in enumerate(zip(IY1,SY1)): - ID.append([itab,iX1,iy,sX1,sy,stab]) + ID.append([itab,iX1,iy,sX1,sy,stab,iZ1,sZ1]) elif self.nSplits in [2,3]: if len(ITab)>=1: for j,(iy,sy) in enumerate(zip(IY1,SY1)): - ID.append([ITab[0],iX1,iy,sX1,sy,STab[0]]) + ID.append([ITab[0],iX1,iy,sX1,sy,STab[0],iZ1,sZ1]) if len(ITab)>=2: iX2,IY2,sX2,SY2 = self.colPanel2.getColumnSelection() + iZ2,sZ2 = self.colPanel2.getZColumnSelection() for j,(iy,sy) in enumerate(zip(IY2,SY2)): - ID.append([ITab[1],iX2,iy,sX2,sy,STab[1]]) + ID.append([ITab[1],iX2,iy,sX2,sy,STab[1],iZ2,sZ2]) if len(ITab)>=3: - iX2,IY2,sX2,SY2 = self.colPanel3.getColumnSelection() - for j,(iy,sy) in enumerate(zip(IY2,SY2)): - ID.append([ITab[2],iX2,iy,sX2,sy,STab[2]]) + iX3,IY3,sX3,SY3 = self.colPanel3.getColumnSelection() + iZ3,sZ3 = self.colPanel3.getZColumnSelection() + for j,(iy,sy) in enumerate(zip(IY3,SY3)): + ID.append([ITab[2],iX3,iy,sX3,sy,STab[2],iZ3,sZ3]) else: raise Exception('Wrong number of splits {}'.format(self.nSplits)) return ID,SameCol,self.currentMode diff --git a/pydatview/figure.py b/pydatview/figure.py index 802a60e..af1f1cd 100644 --- a/pydatview/figure.py +++ b/pydatview/figure.py @@ -11,7 +11,16 @@ def add_subplot(self, *args, projection='swappy', swap=False, **kwargs): # See matplotlib.projections/__init__.py projection_registry.register kwargs.update({'projection':projection}) ax = super().add_subplot(*args, **kwargs) - ax.setSwap(swap) + if hasattr(ax, 'setSwap'): + ax.setSwap(swap) + else: + # Non-SwappyAxes (e.g. Axes3D): add compatibility shims + ax.setSwap = lambda s: None + ax.set_xlim_ = lambda *a, **kw: ax.set_xlim(*a, **kw) + ax.set_ylim_ = lambda *a, **kw: ax.set_ylim(*a, **kw) + ax.get_xlim_ = lambda *a, **kw: ax.get_xlim(*a, **kw) + ax.get_ylim_ = lambda *a, **kw: ax.get_ylim(*a, **kw) + ax.axvline_ = lambda x, *a, **kw: None return ax class SwappyAxes(plt.Axes): diff --git a/pydatview/plotdata.py b/pydatview/plotdata.py index 820dd82..1b835fd 100644 --- a/pydatview/plotdata.py +++ b/pydatview/plotdata.py @@ -47,6 +47,11 @@ def __init__(PD, x=None, y=None, sx='', sy=''): PD.xIsDate =False # true if dates PD.yIsString=False # true if strings PD.yIsDate =False # true if dates + PD.iz =-1 # z/color column index (-1 = no Z variable) + PD.sz ='' # z/color label + PD.z =None # z/color data (None when not used) + PD.zIsString=False # true if strings + PD.zIsDate =False # true if dates # Misc data PD._xMin = None PD._xMax = None @@ -91,6 +96,17 @@ def fromIDs(PD, tabs, i, idx, SameCol, pipeline=None): PD.x, PD.xIsString, PD.xIsDate,_ = tabs[PD.it].getColumn(PD.ix) # actual x data, with info PD.y, PD.yIsString, PD.yIsDate,c = tabs[PD.it].getColumn(PD.iy) # actual y data, with info PD.c =c # raw values, used by PDF + # Z/color variable (optional, idx[6] and idx[7] if provided) + if len(idx) >= 8 and idx[6] >= 0: + PD.iz = idx[6] + PD.sz = idx[7].replace('_', ' ') if idx[7] else '' + PD.z, PD.zIsString, PD.zIsDate, _ = tabs[PD.it].getColumn(PD.iz) + else: + PD.iz = -1 + PD.sz = '' + PD.z = None + PD.zIsString = False + PD.zIsDate = False PD._post_init(pipeline=pipeline) From 234a53db1b81006ee7986ae2ee6665c730d83d98 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Mar 2026 22:19:07 +0000 Subject: [PATCH 2/8] docs: document third variable color scale and 3D view feature Update README with: - New plot types: scatter+color scale, 3D scatter plot - New plot options: Z/color variable, colormap, colorbar, 3D view - Workflow tip explaining the Z/C dropdown in the column panel https://claude.ai/code/session_019co5puUhq1kxsuWG57SPzC --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index fa6925b..c8df866 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,7 @@ Documentation is scarce for now, but here are some tips for using the program: - The modes and fileformat drop down menus at the top can usually be kept on `auto`. If a file cannot be read, pay attention to the file extension used, and possibly select a specific file format in the dropdown menu instead of `auto`. - Above the taskbar is the "Pipeline" which lists the different actions (e.g. binning, filtering, mask) that are applied to the different tables before being plotted. The pipeline actions will be reapplied on reload, and python code for them will be generated when exporting a script. - Different plot styling options can be found below the plot area. The button next to the "Save" icon can be used to customize the esthetics of the plot (e.g. fontsize, linewidth, legend location). + - **Third variable / Color scale**: in the column selection panel, a `Z/C:` dropdown is shown below the X-axis selector. Select any column to use it as a color variable — the plot automatically switches to a scatter plot colored by that column. A control bar appears below the canvas with options for the colormap, a colorbar toggle, and a "3D view" checkbox for a 3D scatter visualization. - Live plotting can be disabled using the check box "Live plot". This is useful when manipulating large datasets, and potentially wanting to delete some columns without plotting them. @@ -130,6 +131,8 @@ Different kind of plots: - Multiple plots using sub-figures or a different colors - Probability density function (PDF) plot - Fast Fourier Transform (FFT) plot +- **Scatter plot with color scale**: select a third variable (Z/C) to color scatter points by that variable, with a choice of colormap and optional colorbar +- **3D scatter plot**: when a Z/C variable is selected, enable "3D view" to visualize data as a 3D scatter plot with the Z variable as the height axis Plot options: - Logarithmic scales on x and y axis @@ -137,6 +140,8 @@ Plot options: - Synchronization of the x-axis of the sub-figures while zooming - Markers annotations and Measurements - Plot styling options +- **Z/color variable** (third variable): select a Z/C column in the column panel to color scatter points; choose from a range of colormaps (viridis, coolwarm, jet, etc.) and optionally display a colorbar +- **3D view**: when a Z/C variable is selected, enable the "3D view" checkbox to switch to an interactive 3D scatter plot Data manipulation options: - Remove columns in a table, add columns using a given formula, and export the table to csv From 152bb957138a4284d5bd7ea05248ac446110e6da Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 18 Mar 2026 08:28:02 +0000 Subject: [PATCH 3/8] build: update dependencies for Python 3.14 compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit requirements.txt: - Add minimum version pins for all packages - numpy>=2.0: required for Python 3.14; removes deprecated bare aliases (np.bool, np.int, np.float, etc. removed in 2.0) - wxpython>=4.2.4: first release with Python 3.14 wheel support - pandas>=2.2, matplotlib>=3.8, scipy>=1.12, xarray>=2024.1, pyarrow>=15.0, openpyxl>=3.1, chardet>=5.0 all confirmed py314 installer.cfg: - Python version: 3.9.9 → 3.14.0 - wxPython: 4.1.1 → 4.2.5 (cp314 wheels available on Windows/macOS) - numpy: 1.22.4 → 2.2.4 - matplotlib: 3.5.2 → 3.10.1 - pandas: 1.4.2 → 2.2.3 - scipy: 1.8.1 → 1.15.2 - pyarrow: 8.0.0 → 19.0.1 - openpyxl: 3.0.10 → 3.1.5 - Pillow: 9.1.1 → 11.1.0 - xarray: 2023.2.0 → 2025.3.0 - chardet: 4.0.0 → 5.2.0 - Retain fatpack==0.7.3 (pure Python, tested functional under py314) - Old py3.9 pins preserved as comments for reference setup.py: - Add python_requires='>=3.9' - Add install_requires with minimum version constraints Note for Linux: wxPython does not publish manylinux wheels on PyPI. Use https://extras.wxpython.org/wxPython4/extras/linux/ for pre-built wheels, or build from source (requires GTK dev headers). https://claude.ai/code/session_019co5puUhq1kxsuWG57SPzC --- installer.cfg | 85 ++++++++++++++++++++++-------------------------- requirements.txt | 20 ++++++------ setup.py | 13 ++++++++ 3 files changed, 61 insertions(+), 57 deletions(-) diff --git a/installer.cfg b/installer.cfg index b4361b0..7b80763 100644 --- a/installer.cfg +++ b/installer.cfg @@ -8,7 +8,7 @@ icon=ressources/pyDatView.ico #entry_point=pydatview:cmdline [Python] -version=3.9.9 +version=3.14.0 bitness=64 [Include] @@ -16,56 +16,47 @@ files=_tools/pyDatView.exe > $INSTDIR _tools/pyDatView_Test.bat > $INSTDIR LICENSE.TXT > $INSTDIR -pypi_wheels = - numpy==1.22.4 - wxPython==4.1.1 - matplotlib==3.5.2 - pyparsing==2.4.7 - cycler==0.11.0 - six==1.16.0 - python-dateutil==2.8.2 - kiwisolver==1.3.2 - pandas==1.4.2 - pytz==2021.3 - chardet==4.0.0 - scipy==1.8.1 - openpyxl==3.0.10 - et-xmlfile==1.1.0 - pyarrow==8.0.0 - Pillow==9.1.1 - packaging==21.2 +pypi_wheels = + numpy==2.2.4 + wxPython==4.2.5 + matplotlib==3.10.1 + pyparsing==3.2.3 + cycler==0.12.1 + python-dateutil==2.9.0 + kiwisolver==1.4.8 + pandas==2.2.3 + pytz==2025.1 + chardet==5.2.0 + scipy==1.15.2 + openpyxl==3.1.5 + et-xmlfile==2.0.0 + pyarrow==19.0.1 + Pillow==11.1.0 + packaging==24.2 fatpack==0.7.3 - xarray==2023.2.0 + xarray==2025.3.0 pyyaml==6.0.2 -# numpy==1.19.3 -# wxPython==4.0.3 -# matplotlib==3.0.0 -# pyparsing==2.2.2 -# cycler==0.10.0 -# six==1.11.0 -# python-dateutil==2.7.3 -# kiwisolver==1.0.1 -# pandas==0.23.4 -# pytz==2018.5 -# chardet==3.0.4 -# scipy==1.1.0 -# pyarrow==4.0.1 - -# numpy==1.20.3 -# wxPython==4.0.7 -# matplotlib==3.4.2 +# numpy==1.22.4 # py3.9 build (Python 3.9.9 installer) +# wxPython==4.1.1 +# matplotlib==3.5.2 # pyparsing==2.4.7 -# cycler==0.10.0 -# six==1.11.0 -# python-dateutil==2.7.3 -# kiwisolver==1.0.1 -# pandas==1.1.5 -# pytz==2018.5 -# chardet==3.0.4 -# scipy==1.5.4 - -# PyYAML==5.1.2 +# cycler==0.11.0 +# six==1.16.0 +# python-dateutil==2.8.2 +# kiwisolver==1.3.2 +# pandas==1.4.2 +# pytz==2021.3 +# chardet==4.0.0 +# scipy==1.8.1 +# openpyxl==3.0.10 +# et-xmlfile==1.1.0 +# pyarrow==8.0.0 +# Pillow==9.1.1 +# packaging==21.2 +# fatpack==0.7.3 +# xarray==2023.2.0 +# pyyaml==6.0.2 packages= diff --git a/requirements.txt b/requirements.txt index 921a165..154545f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,10 @@ -openpyxl -numpy -pandas -xarray -pyarrow # for parquet files -matplotlib -chardet -scipy -wxpython -pyyaml +openpyxl>=3.1 +numpy>=2.0 # numpy 2.0 required for Python 3.14; removes np.bool/int/float/str bare aliases +pandas>=2.2 +xarray>=2024.1 +pyarrow>=15.0 # for parquet files +matplotlib>=3.8 +chardet>=5.0 +scipy>=1.12 +wxpython>=4.2.4 # 4.2.4+ adds Python 3.14 wheel support +pyyaml>=6.0 diff --git a/setup.py b/setup.py index 60bbb15..a82f797 100644 --- a/setup.py +++ b/setup.py @@ -9,6 +9,19 @@ author_email='lastname@gmail.com', license='MIT', packages=['pydatview'], + python_requires='>=3.9', + install_requires=[ + 'numpy>=2.0', + 'pandas>=2.2', + 'matplotlib>=3.8', + 'scipy>=1.12', + 'wxpython>=4.2.4', + 'openpyxl>=3.1', + 'xarray>=2024.1', + 'pyarrow>=15.0', + 'chardet>=5.0', + 'pyyaml>=6.0', + ], zip_safe=False ) From e116bf1b1238ada3cc2c02f65df0ee22290a64a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 18 Mar 2026 08:57:03 +0000 Subject: [PATCH 4/8] build: update project URL to SimonHH fork https://claude.ai/code/session_019co5puUhq1kxsuWG57SPzC --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index a82f797..f333b73 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ name='pydatview', version='0.5', description='GUI to display tabulated data from files or pandas dataframes', - url='http://github.com/ebranlard/pyDatView/', + url='https://github.com/SimonHH/pyDatView/', author='Emmanuel Branlard', author_email='lastname@gmail.com', license='MIT', From c695f469f43de0c88511180c4d5cf4770b092d93 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 18 Mar 2026 08:57:29 +0000 Subject: [PATCH 5/8] Revert "build: update dependencies for Python 3.14 compatibility" This reverts commit 152bb957138a4284d5bd7ea05248ac446110e6da. --- installer.cfg | 85 ++++++++++++++++++++++++++---------------------- requirements.txt | 20 ++++++------ setup.py | 13 -------- 3 files changed, 57 insertions(+), 61 deletions(-) diff --git a/installer.cfg b/installer.cfg index 7b80763..b4361b0 100644 --- a/installer.cfg +++ b/installer.cfg @@ -8,7 +8,7 @@ icon=ressources/pyDatView.ico #entry_point=pydatview:cmdline [Python] -version=3.14.0 +version=3.9.9 bitness=64 [Include] @@ -16,47 +16,56 @@ files=_tools/pyDatView.exe > $INSTDIR _tools/pyDatView_Test.bat > $INSTDIR LICENSE.TXT > $INSTDIR -pypi_wheels = - numpy==2.2.4 - wxPython==4.2.5 - matplotlib==3.10.1 - pyparsing==3.2.3 - cycler==0.12.1 - python-dateutil==2.9.0 - kiwisolver==1.4.8 - pandas==2.2.3 - pytz==2025.1 - chardet==5.2.0 - scipy==1.15.2 - openpyxl==3.1.5 - et-xmlfile==2.0.0 - pyarrow==19.0.1 - Pillow==11.1.0 - packaging==24.2 +pypi_wheels = + numpy==1.22.4 + wxPython==4.1.1 + matplotlib==3.5.2 + pyparsing==2.4.7 + cycler==0.11.0 + six==1.16.0 + python-dateutil==2.8.2 + kiwisolver==1.3.2 + pandas==1.4.2 + pytz==2021.3 + chardet==4.0.0 + scipy==1.8.1 + openpyxl==3.0.10 + et-xmlfile==1.1.0 + pyarrow==8.0.0 + Pillow==9.1.1 + packaging==21.2 fatpack==0.7.3 - xarray==2025.3.0 + xarray==2023.2.0 pyyaml==6.0.2 -# numpy==1.22.4 # py3.9 build (Python 3.9.9 installer) -# wxPython==4.1.1 -# matplotlib==3.5.2 +# numpy==1.19.3 +# wxPython==4.0.3 +# matplotlib==3.0.0 +# pyparsing==2.2.2 +# cycler==0.10.0 +# six==1.11.0 +# python-dateutil==2.7.3 +# kiwisolver==1.0.1 +# pandas==0.23.4 +# pytz==2018.5 +# chardet==3.0.4 +# scipy==1.1.0 +# pyarrow==4.0.1 + +# numpy==1.20.3 +# wxPython==4.0.7 +# matplotlib==3.4.2 # pyparsing==2.4.7 -# cycler==0.11.0 -# six==1.16.0 -# python-dateutil==2.8.2 -# kiwisolver==1.3.2 -# pandas==1.4.2 -# pytz==2021.3 -# chardet==4.0.0 -# scipy==1.8.1 -# openpyxl==3.0.10 -# et-xmlfile==1.1.0 -# pyarrow==8.0.0 -# Pillow==9.1.1 -# packaging==21.2 -# fatpack==0.7.3 -# xarray==2023.2.0 -# pyyaml==6.0.2 +# cycler==0.10.0 +# six==1.11.0 +# python-dateutil==2.7.3 +# kiwisolver==1.0.1 +# pandas==1.1.5 +# pytz==2018.5 +# chardet==3.0.4 +# scipy==1.5.4 + +# PyYAML==5.1.2 packages= diff --git a/requirements.txt b/requirements.txt index 154545f..921a165 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,10 @@ -openpyxl>=3.1 -numpy>=2.0 # numpy 2.0 required for Python 3.14; removes np.bool/int/float/str bare aliases -pandas>=2.2 -xarray>=2024.1 -pyarrow>=15.0 # for parquet files -matplotlib>=3.8 -chardet>=5.0 -scipy>=1.12 -wxpython>=4.2.4 # 4.2.4+ adds Python 3.14 wheel support -pyyaml>=6.0 +openpyxl +numpy +pandas +xarray +pyarrow # for parquet files +matplotlib +chardet +scipy +wxpython +pyyaml diff --git a/setup.py b/setup.py index f333b73..401234d 100644 --- a/setup.py +++ b/setup.py @@ -9,19 +9,6 @@ author_email='lastname@gmail.com', license='MIT', packages=['pydatview'], - python_requires='>=3.9', - install_requires=[ - 'numpy>=2.0', - 'pandas>=2.2', - 'matplotlib>=3.8', - 'scipy>=1.12', - 'wxpython>=4.2.4', - 'openpyxl>=3.1', - 'xarray>=2024.1', - 'pyarrow>=15.0', - 'chardet>=5.0', - 'pyyaml>=6.0', - ], zip_safe=False ) From fed906bef44a413cb948ed1ab0106742ff19bbbf Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 18 Mar 2026 11:07:58 +0000 Subject: [PATCH 6/8] feat: polish Z/color UI and 3D interaction - Rename "Z/C:" label to "z-axis:" in column panel - Remove colormap dropdown (hardcode viridis) and colorbar checkbox (always on) - Add x-y / y-z / x-z plane view buttons that appear when 3D view is active - Require Ctrl+left-click for 3D rotation to avoid conflict with zoom https://claude.ai/code/session_019co5puUhq1kxsuWG57SPzC --- pydatview/GUIPlotPanel.py | 87 +++++++++++++++++++++++++++------- pydatview/GUISelectionPanel.py | 2 +- 2 files changed, 70 insertions(+), 19 deletions(-) diff --git a/pydatview/GUIPlotPanel.py b/pydatview/GUIPlotPanel.py index 3a2433b..747198b 100644 --- a/pydatview/GUIPlotPanel.py +++ b/pydatview/GUIPlotPanel.py @@ -57,6 +57,36 @@ pyplot_rc['agg.path.chunksize'] = 20000 +def _patch_3d_ctrl_rotate(ax, canvas): + """Require Ctrl+left-click to rotate a 3D axis; plain left-click is left free for zoom/pan.""" + try: + cids = getattr(ax, '_cids', []) + if not cids: + return + # Save references to the original bound methods before disconnecting + press_fn = getattr(ax, '_button_press', None) + release_fn = getattr(ax, '_button_release', None) + move_fn = getattr(ax, '_on_move', None) + if press_fn is None: + return + for cid in list(cids): + canvas.mpl_disconnect(cid) + + def ctrl_press(event): + if event.button == 1 and event.key not in ('control', 'ctrl'): + return + press_fn(event) + + new_cids = [canvas.mpl_connect('button_press_event', ctrl_press)] + if release_fn: + new_cids.append(canvas.mpl_connect('button_release_event', release_fn)) + if move_fn: + new_cids.append(canvas.mpl_connect('motion_notify_event', move_fn)) + ax._cids = new_cids + except Exception: + pass # Silently skip if matplotlib version doesn't support this + + class PDFCtrlPanel(wx.Panel): def __init__(self, parent): super(PDFCtrlPanel,self).__init__(parent) @@ -215,39 +245,59 @@ def _GUI2Data(self): class ColorCtrlPanel(wx.Panel): """Control panel shown when a Z/color variable is selected.""" - COLORMAPS = ['viridis','plasma','inferno','magma','cividis','coolwarm','RdYlBu','jet','rainbow','turbo','hot','bone'] + COLORMAP = 'viridis' def __init__(self, parent): super(ColorCtrlPanel, self).__init__(parent) self.parent = parent - lbCmap = wx.StaticText(self, -1, 'Colormap:') - self.cbCmap = wx.ComboBox(self, choices=self.COLORMAPS, style=wx.CB_READONLY) - self.cbCmap.SetSelection(0) - self.cbColorBar = wx.CheckBox(self, -1, 'Colorbar') - self.cbColorBar.SetValue(True) self.cb3D = wx.CheckBox(self, -1, '3D view') self.cb3D.SetValue(False) + # View buttons (shown only in 3D mode) + self.btXY = wx.Button(self, -1, 'x-y plane', style=wx.BU_EXACTFIT) + self.btYZ = wx.Button(self, -1, 'y-z plane', style=wx.BU_EXACTFIT) + self.btXZ = wx.Button(self, -1, 'x-z plane', style=wx.BU_EXACTFIT) + self.btXY.Hide() + self.btYZ.Hide() + self.btXZ.Hide() dummy_sizer = wx.BoxSizer(wx.HORIZONTAL) - dummy_sizer.Add(lbCmap , 0, flag=wx.CENTER|wx.LEFT, border=2) - dummy_sizer.Add(self.cbCmap , 0, flag=wx.CENTER|wx.LEFT, border=2) - dummy_sizer.Add(self.cbColorBar , 0, flag=wx.CENTER|wx.LEFT, border=8) - dummy_sizer.Add(self.cb3D , 0, flag=wx.CENTER|wx.LEFT, border=8) + dummy_sizer.Add(self.cb3D , 0, flag=wx.CENTER|wx.LEFT, border=8) + dummy_sizer.Add(self.btXY , 0, flag=wx.CENTER|wx.LEFT, border=8) + dummy_sizer.Add(self.btYZ , 0, flag=wx.CENTER|wx.LEFT, border=4) + dummy_sizer.Add(self.btXZ , 0, flag=wx.CENTER|wx.LEFT, border=4) self.SetSizer(dummy_sizer) - self.Bind(wx.EVT_COMBOBOX, self.onOptionChange, self.cbCmap) - self.Bind(wx.EVT_CHECKBOX, self.onOptionChange, self.cbColorBar) - self.Bind(wx.EVT_CHECKBOX, self.on3DChange, self.cb3D) + self.Bind(wx.EVT_CHECKBOX, self.on3DChange, self.cb3D) + self.Bind(wx.EVT_BUTTON, self.onViewXY, self.btXY) + self.Bind(wx.EVT_BUTTON, self.onViewYZ, self.btYZ) + self.Bind(wx.EVT_BUTTON, self.onViewXZ, self.btXZ) self.Hide() - def onOptionChange(self, event=None): - self.parent.redraw_same_data() - def on3DChange(self, event=None): + is3D = self.cb3D.IsChecked() + self.btXY.Show(is3D) + self.btYZ.Show(is3D) + self.btXZ.Show(is3D) + self.GetSizer().Layout() self.parent.load_and_draw() + def _setView(self, elev, azim): + for ax in self.parent.fig.axes: + if hasattr(ax, 'view_init'): + ax.view_init(elev=elev, azim=azim) + self.parent.canvas.draw() + + def onViewXY(self, event=None): + self._setView(elev=90, azim=-90) + + def onViewYZ(self, event=None): + self._setView(elev=0, azim=0) + + def onViewXZ(self, event=None): + self._setView(elev=0, azim=-90) + def _GUI2Data(self): return { - 'colormap': self.cbCmap.GetStringSelection(), - 'colorbar': self.cbColorBar.IsChecked(), + 'colormap': self.COLORMAP, + 'colorbar': True, 'view3D': self.cb3D.IsChecked(), } @@ -1013,6 +1063,7 @@ def set_subplots(self,nPlots): # Vertical stack if use3D: ax = self.fig.add_subplot(nPlots, 1, i+1, projection='3d') + _patch_3d_ctrl_rotate(ax, self.canvas) elif i==0: ax=self.fig.add_subplot(nPlots,1,i+1) # Store first axis to share with other diff --git a/pydatview/GUISelectionPanel.py b/pydatview/GUISelectionPanel.py index 148637c..9e8f045 100644 --- a/pydatview/GUISelectionPanel.py +++ b/pydatview/GUISelectionPanel.py @@ -745,7 +745,7 @@ def __init__(self, parent, selPanel): self.lbColumns=wx.ListBox(self, -1, choices=[], style=wx.LB_EXTENDED ) self.lbColumns.SetFont(getMonoFont(self)) # Z/Color variable selector - self.lbZ = wx.StaticText(self, -1, 'Z/C:') + self.lbZ = wx.StaticText(self, -1, 'z-axis:') self.comboZ = wx.ComboBox(self, choices=['None'], style=wx.CB_READONLY) self.comboZ.SetFont(getMonoFont(self)) self.comboZ.SetSelection(0) From a670585e18f87b480d90e85109ef533855b66aaf Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 18 Mar 2026 11:51:38 +0000 Subject: [PATCH 7/8] fix: use mouse_init + wx.GetKeyState for reliable Ctrl+rotate in 3D The previous approach relied on ax._cids being non-empty and event.key being set, neither of which is reliable across matplotlib versions. New approach: - ax.mouse_init(rotate_btn=[]) disables built-in left-click rotation - custom button_press handler sets _rotate_btn=[1] only when Ctrl is held (detected via wx.GetKeyState for wx-backend reliability) - custom button_release handler resets _rotate_btn=[] on release https://claude.ai/code/session_019co5puUhq1kxsuWG57SPzC --- pydatview/GUIPlotPanel.py | 42 +++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/pydatview/GUIPlotPanel.py b/pydatview/GUIPlotPanel.py index 747198b..805e53a 100644 --- a/pydatview/GUIPlotPanel.py +++ b/pydatview/GUIPlotPanel.py @@ -58,33 +58,31 @@ def _patch_3d_ctrl_rotate(ax, canvas): - """Require Ctrl+left-click to rotate a 3D axis; plain left-click is left free for zoom/pan.""" + """Require Ctrl+left-click to rotate a 3D axis; plain left-click is free for zoom/pan. + + Strategy: use ax.mouse_init(rotate_btn=[]) to disable the built-in left-click rotation, + then connect custom handlers that re-enable rotation only while Ctrl is held. + wx.GetKeyState is used for reliable Ctrl detection independent of matplotlib's key tracking. + """ try: - cids = getattr(ax, '_cids', []) - if not cids: - return - # Save references to the original bound methods before disconnecting - press_fn = getattr(ax, '_button_press', None) - release_fn = getattr(ax, '_button_release', None) - move_fn = getattr(ax, '_on_move', None) - if press_fn is None: + if not hasattr(ax, 'mouse_init') or not hasattr(ax, '_rotate_btn'): return - for cid in list(cids): - canvas.mpl_disconnect(cid) + # Disable built-in rotation; keep pan (button 2) and zoom (button 3) intact + ax.mouse_init(rotate_btn=[]) - def ctrl_press(event): - if event.button == 1 and event.key not in ('control', 'ctrl'): + def _on_3d_press(event): + if event.inaxes != ax or event.button != 1: return - press_fn(event) - - new_cids = [canvas.mpl_connect('button_press_event', ctrl_press)] - if release_fn: - new_cids.append(canvas.mpl_connect('button_release_event', release_fn)) - if move_fn: - new_cids.append(canvas.mpl_connect('motion_notify_event', move_fn)) - ax._cids = new_cids + ax._rotate_btn = np.atleast_1d(1 if wx.GetKeyState(wx.WXK_CONTROL) else []) + + def _on_3d_release(event): + if event.button == 1: + ax._rotate_btn = np.atleast_1d([]) + + canvas.mpl_connect('button_press_event', _on_3d_press) + canvas.mpl_connect('button_release_event', _on_3d_release) except Exception: - pass # Silently skip if matplotlib version doesn't support this + pass class PDFCtrlPanel(wx.Panel): From f55ec9a90944f2db6a6fabd97b1738f32f8614e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 18 Mar 2026 12:47:20 +0000 Subject: [PATCH 8/8] fix: simpler Ctrl+rotate patch using button_pressed reset Previous approach used mouse_init/_rotate_btn which silently fails in many matplotlib versions. New approach: - Connect a button_press handler that fires AFTER the built-in one - If Ctrl is not held, reset ax.button_pressed=None so _on_move returns early and skips rotation - wx.GetKeyState used for reliable keyboard state detection https://claude.ai/code/session_019co5puUhq1kxsuWG57SPzC --- pydatview/GUIPlotPanel.py | 32 ++++++++++++-------------------- 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/pydatview/GUIPlotPanel.py b/pydatview/GUIPlotPanel.py index 805e53a..3670b8c 100644 --- a/pydatview/GUIPlotPanel.py +++ b/pydatview/GUIPlotPanel.py @@ -60,29 +60,21 @@ def _patch_3d_ctrl_rotate(ax, canvas): """Require Ctrl+left-click to rotate a 3D axis; plain left-click is free for zoom/pan. - Strategy: use ax.mouse_init(rotate_btn=[]) to disable the built-in left-click rotation, - then connect custom handlers that re-enable rotation only while Ctrl is held. - wx.GetKeyState is used for reliable Ctrl detection independent of matplotlib's key tracking. + Strategy: matplotlib's built-in _button_press sets ax.button_pressed = event.button. + Our handler fires afterwards (registered later = called later) and resets + ax.button_pressed to None when Ctrl is not held, so _on_move skips rotation. + wx.GetKeyState gives reliable Ctrl detection in the wx backend. """ - try: - if not hasattr(ax, 'mouse_init') or not hasattr(ax, '_rotate_btn'): + def _on_3d_press(event): + if event.inaxes != ax or event.button != 1: return - # Disable built-in rotation; keep pan (button 2) and zoom (button 3) intact - ax.mouse_init(rotate_btn=[]) - - def _on_3d_press(event): - if event.inaxes != ax or event.button != 1: - return - ax._rotate_btn = np.atleast_1d(1 if wx.GetKeyState(wx.WXK_CONTROL) else []) - - def _on_3d_release(event): - if event.button == 1: - ax._rotate_btn = np.atleast_1d([]) + if not wx.GetKeyState(wx.WXK_CONTROL): + try: + ax.button_pressed = None + except Exception: + pass - canvas.mpl_connect('button_press_event', _on_3d_press) - canvas.mpl_connect('button_release_event', _on_3d_release) - except Exception: - pass + canvas.mpl_connect('button_press_event', _on_3d_press) class PDFCtrlPanel(wx.Panel):