From 529b89ebd11b6d6d9acaa13de04a66ac91201b9a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Mar 2026 22:10:00 +0000 Subject: [PATCH 01/41] 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 02/41] 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 03/41] 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 04/41] 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 05/41] 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 06/41] 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 07/41] 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 08/41] 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): From 59c19228e34babd51f69a7ccc11c2d00f62c805d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Mar 2026 11:31:20 +0000 Subject: [PATCH 09/41] Add Z/color variable selection, 3D scatter view, and Ctrl+rotate fix Adds a third variable (Z) to the selection panel with colour scale, enabling coloured 2-D scatter plots and a full 3-D view. Polishes the Z/colour UI and 3-D mouse interaction, and fixes Ctrl+rotate using button_pressed reset. --- README.md | 5 + pydatview/GUIPlotPanel.py | 233 +++++++++++++++++++++++++++------ pydatview/GUISelectionPanel.py | 61 ++++++++- pydatview/figure.py | 11 +- pydatview/plotdata.py | 16 +++ setup.py | 2 +- 6 files changed, 278 insertions(+), 50 deletions(-) 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 diff --git a/pydatview/GUIPlotPanel.py b/pydatview/GUIPlotPanel.py index 24f8f91..3670b8c 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 @@ -53,6 +57,26 @@ 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 free for zoom/pan. + + 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. + """ + def _on_3d_press(event): + if event.inaxes != ax or event.button != 1: + return + if not wx.GetKeyState(wx.WXK_CONTROL): + try: + ax.button_pressed = None + except Exception: + pass + + canvas.mpl_connect('button_press_event', _on_3d_press) + + class PDFCtrlPanel(wx.Panel): def __init__(self, parent): super(PDFCtrlPanel,self).__init__(parent) @@ -209,6 +233,65 @@ 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.""" + COLORMAP = 'viridis' + + def __init__(self, parent): + super(ColorCtrlPanel, self).__init__(parent) + self.parent = parent + 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(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_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 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.COLORMAP, + 'colorbar': True, + 'view3D': self.cb3D.IsChecked(), + } + + class SpectralCtrlPanel(wx.Panel): def __init__(self, parent): super(SpectralCtrlPanel,self).__init__(parent) @@ -593,11 +676,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 +786,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 +1042,19 @@ 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') + _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 if self.sharex: @@ -1178,13 +1269,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 +1627,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 +1666,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 +1684,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 +1705,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 +2009,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..9e8f045 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-axis:') + 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) diff --git a/setup.py b/setup.py index 60bbb15..401234d 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 07af75e3fba492143eb2cb3e9ca39f9b501c6a33 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Mar 2026 11:25:09 +0000 Subject: [PATCH 10/41] Views: save/restore named views with full settings, pipeline, and .pdvview files Introduces the Views feature: named views (column selection + plot settings) can be saved and restored at any time. Views can be exported to portable .pdvview JSON files with relative data-file paths, and re-imported via drag-and-drop or the Views menu. Missing tables/columns on restore produce warnings rather than crashes. Extends to cover: Z/color column selection, 3D view state (log-z, flip-z, cb3D), pipeline actions (Filter/Resample/Bin/Mask), all sub-panel settings and loader options. Adds "Apply view to current table" which resolves saved column names on whichever table is selected. Fixes crashes, list-deselect TypeError, compare-with-1-series, multi-y column drop, formula restore, and drag-drop/Ctrl+drag parity. https://claude.ai/code/session_01RK7XoUAyBGq81RqNdX7htm --- README.md | 4 + pydatview/GUIPlotPanel.py | 172 ++++++++- pydatview/GUISelectionPanel.py | 212 ++++++++++- pydatview/appdata.py | 2 + pydatview/main.py | 330 +++++++++++++++-- tests/test_views.py | 660 +++++++++++++++++++++++++++++++++ 6 files changed, 1331 insertions(+), 49 deletions(-) create mode 100644 tests/test_views.py diff --git a/README.md b/README.md index c8df866..12c7b39 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,8 @@ Documentation is scarce for now, but here are some tips for using the program: - 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. + - **Views** allow you to save and restore a complete selection state (tables, channels, plot type, and plot settings). Use the **Views** menu to save the current view under a name; previously saved views appear in the toolbar drop-down and can be restored with one click. Views are stored in the session data and survive a reload. If a table or channel referenced by a saved view is not available at restore time (e.g. file not yet loaded, or a channel was renamed), the view is restored as fully as possible and a warning lists what could not be matched. + - **Portable views (`.pdvview` files)**: use **Views > Export view to file** to write the current view — including the list of source files and all settings — into a single `.pdvview` JSON file. File paths are stored relative to the view file, so the whole folder can be shared or moved. To reload: drag-and-drop the `.pdvview` file onto pyDatView, or use **Views > Import view from file**. Missing source files are reported; available ones are loaded and the view is restored. @@ -125,6 +127,8 @@ Main features: - Export figure as pdf, png, eps, svg - Export python script - Export data as csv, or other file formats +- Save and restore named views (table selection, channels, plot type, and aesthetics) +- Export/import portable view files (`.pdvview`) that bundle file references and settings; loadable by drag-and-drop Different kind of plots: - Scatter plots or line plots diff --git a/pydatview/GUIPlotPanel.py b/pydatview/GUIPlotPanel.py index 3670b8c..45177aa 100644 --- a/pydatview/GUIPlotPanel.py +++ b/pydatview/GUIPlotPanel.py @@ -821,7 +821,172 @@ def saveData(self, data): data['CrossHair'] = self.cbXHair.IsChecked() self.esthPanel._GUI2Data() data['plotStyle']= self.esthPanel.data - + + def captureViewData(self): + """Capture current plot panel state for view saving""" + data = {} + data['plotType'] = self.pltTypePanel.plotType() + data['logX'] = self.cbLogX.IsChecked() + data['logY'] = self.cbLogY.IsChecked() + data['grid'] = self.cbGrid.IsChecked() + data['crossHair'] = self.cbXHair.IsChecked() + data['subplot'] = self.cbSub.IsChecked() + data['sync'] = self.cbSync.IsChecked() + data['autoScale'] = self.cbAutoScale.IsChecked() + data['stepPlot'] = self.cbStepPlot.IsChecked() + data['curveType'] = self.cbCurveType.GetSelection() + data['plotStyle'] = { + 'Font': self.esthPanel.cbFont.GetValue(), + 'LegendFont': self.esthPanel.cbLgdFont.GetValue(), + 'LegendPosition': self.esthPanel.cbLegend.GetValue(), + 'LineWidth': self.esthPanel.cbLW.GetValue(), + 'MarkerSize': self.esthPanel.cbMS.GetValue(), + } + data['view3D'] = self.colorPanel.cb3D.IsChecked() + # R1 – Spectral / FFT panel + spc = self.spcPanel._GUI2Data() + spc['xlim'] = self.spcPanel.tMaxFreq.GetValue() + data['spectral'] = spc + # R2 – Compare panel + data['compare'] = self.cmpPanel._GUI2Data() + # R3 – MinMax panel + data['minmax'] = self.mmxPanel._GUI2Data() + # R4 – PDF panel + data['pdf'] = self.pdfPanel._GUI2Data() + # R5 – Polar panel + data['polar'] = self.polPanel._GUI2Data() + # R6 – Swap XY + data['swapXY'] = self.cbSwapXY.IsChecked() + # R7 – Flip axes + data['flipX'] = self.cbFlipX.IsChecked() + data['flipY'] = self.cbFlipY.IsChecked() + # R8 – Plot matrix + data['plotMatrix'] = self.cbPlotMatrix.IsChecked() + return data + + def restoreViewData(self, data): + """Restore plot panel state from a saved view (does not redraw)""" + plotType = data.get('plotType', 'Regular') + # Set radio buttons without triggering events, then show/hide opt panels + for pt, d in self.pltTypePanel.PTDict.items(): + d['cb'].SetValue(pt == plotType) + if d['opt_panel'] is not None: + d['opt_panel'].Show() if pt == plotType else d['opt_panel'].Hide() + has_opt_panel = self.pltTypePanel.PTDict.get(plotType, {}).get('opt_panel') is not None + self.slEsth.Show() if has_opt_panel else self.slEsth.Hide() + self.plotsizer.Layout() + # Checkboxes + self.cbLogX.SetValue(data.get('logX', False)) + self.cbLogY.SetValue(data.get('logY', plotType == 'FFT')) + self.cbGrid.SetValue(data.get('grid', False)) + self.cbXHair.SetValue(data.get('crossHair', True)) + self.cbSub.SetValue(data.get('subplot', False)) + self.cbSync.SetValue(data.get('sync', True)) + self.cbAutoScale.SetValue(data.get('autoScale', True)) + self.cbStepPlot.SetValue(data.get('stepPlot', False)) + self.cbCurveType.SetSelection(data.get('curveType', 1)) + # R6-R8 – axis toggles and matrix + self.cbSwapXY.SetValue(data.get('swapXY', False)) + self.cbFlipX.SetValue(data.get('flipX', False)) + self.cbFlipY.SetValue(data.get('flipY', False)) + self.cbPlotMatrix.SetValue(data.get('plotMatrix', False)) + # Aesthetics + plotStyle = data.get('plotStyle', {}) + if plotStyle: + fontChoices = ['6','7','8','9','10','11','12','13','14','15','16','17','18'] + LWChoices = ['0.5','1.0','1.25','1.5','2.0','2.5','3.0'] + MSChoices = ['0.5','1','2','3','4','5','6','7','8'] + lbChoices = ['None','Upper right','Upper left','Lower left','Lower right','Right','Center left','Center right','Lower center','Upper center','Center'] + try: + self.esthPanel.cbFont.SetSelection(fontChoices.index(str(plotStyle.get('Font','11')))) + except ValueError: + pass + try: + self.esthPanel.cbLgdFont.SetSelection(fontChoices.index(str(plotStyle.get('LegendFont','11')))) + except ValueError: + pass + try: + self.esthPanel.cbLegend.SetSelection(lbChoices.index(str(plotStyle.get('LegendPosition','Upper right')))) + except ValueError: + pass + try: + self.esthPanel.cbLW.SetSelection(LWChoices.index(str(plotStyle.get('LineWidth','1.5')))) + except ValueError: + pass + try: + self.esthPanel.cbMS.SetSelection(MSChoices.index(str(plotStyle.get('MarkerSize','2')))) + except ValueError: + pass + try: + matplotlib_rc('font', **{'size': int(plotStyle.get('Font', '11'))}) + except Exception: + pass + self.colorPanel.cb3D.SetValue(data.get('view3D', False)) + # R1 – Spectral / FFT panel + spc = data.get('spectral', {}) + if spc: + _spc_types = ['PSD', 'f x PSD', 'Amplitude'] + _spc_typeX = ['1/x', '2pi/x', 'x'] + _spc_avg = ['None', 'Welch', 'Binning'] + _spc_avgwin = ['Hamming', 'Hann', 'Rectangular'] + try: + self.spcPanel.cbType.SetSelection(_spc_types.index(spc.get('yType', 'PSD'))) + except ValueError: + pass + try: + self.spcPanel.cbTypeX.SetSelection(_spc_typeX.index(spc.get('xType', '1/x'))) + except ValueError: + pass + try: + self.spcPanel.cbAveraging.SetSelection(_spc_avg.index(spc.get('avgMethod', 'Welch'))) + except ValueError: + pass + try: + self.spcPanel.cbAveragingMethod.SetSelection(_spc_avgwin.index(spc.get('avgWindow', 'Hamming'))) + except ValueError: + pass + self.spcPanel.cbDetrend.SetValue(spc.get('bDetrend', False)) + self.spcPanel.scP2.SetValue(int(spc.get('nExp', 11))) + self.spcPanel.tMaxFreq.SetValue(str(spc.get('xlim', '-1'))) + # R2 – Compare panel + cmp = data.get('compare', {}) + if cmp: + _cmp_types = ['Relative', '|Relative|', 'Ratio', 'Absolute', 'Y-Y'] + try: + self.cmpPanel.rbType.SetSelection(_cmp_types.index(cmp.get('type', 'Relative'))) + except ValueError: + pass + # R3 – MinMax panel + mmx = data.get('minmax', {}) + if mmx: + self.mmxPanel.cbyMinMax.SetValue(mmx.get('yScale', True)) + self.mmxPanel.cbxMinMax.SetValue(mmx.get('xScale', False)) + _mmx_centers = ['None', 'Mid=0', 'Mid=ref', 'Mean=0', 'Mean=ref'] + try: + self.mmxPanel.cbyMean.SetSelection(_mmx_centers.index(mmx.get('yCenter', 'None'))) + except ValueError: + pass + # R4 – PDF panel + pdf = data.get('pdf', {}) + if pdf: + self.pdfPanel.scBins.SetValue(int(pdf.get('nBins', 51))) + self.pdfPanel.cbSmooth.SetValue(pdf.get('smooth', False)) + # R5 – Polar panel + pol = data.get('polar', {}) + if pol: + _pol_bins = ['None', '12', '36', '60', '180', '360'] + _pol_about = ['x (from z, y hori flip, z vert)', 'z (from x, x hori, y vert)'] + self.polPanel.cbPolarDeg.SetValue(pol.get('Deg', True)) + self.polPanel.cbPolarSameMean.SetValue(pol.get('SameMean', False)) + try: + self.polPanel.cbPolarBins.SetSelection(_pol_bins.index(str(pol.get('Bins', 'None')))) + except ValueError: + pass + try: + self.polPanel.cbPolarAbout.SetSelection(_pol_about.index(pol.get('About', _pol_about[0]))) + except ValueError: + pass + @staticmethod def defaultData(): data={} @@ -1288,7 +1453,10 @@ def PD_Compare(self,mode): """ Perform comparison of the selected PlotData, returns new plotData with the comparison. """ sComp = self.cmpPanel.rbType.GetStringSelection() try: - self.plotData = compareMultiplePD(self.plotData,mode, sComp) + result = compareMultiplePD(self.plotData, mode, sComp) + if result: # only replace plotData when compare produced results + self.plotData = result + # if result is empty (e.g. only 1 series), keep plotData so it still draws except Exception as e: self.pltTypePanel.cbRegular.SetValue(True) raise e diff --git a/pydatview/GUISelectionPanel.py b/pydatview/GUISelectionPanel.py index 9e8f045..ef3b0a6 100644 --- a/pydatview/GUISelectionPanel.py +++ b/pydatview/GUISelectionPanel.py @@ -862,7 +862,7 @@ def setReadOnly(self, tabLabel=None, cols=[]): self.tFilter.Enable(False) self.tFilter.SetValue('') - def setTab(self, tab=None, xSel=-1, ySel=[], colNames=None, tabLabel='', sFilter=None): + def setTab(self, tab=None, xSel=-1, ySel=[], zSel=0, colNames=None, tabLabel='', sFilter=None): """ Set the table used for the columns, update the GUI tab is None, when in simColumnsMode """ @@ -886,11 +886,11 @@ def setTab(self, tab=None, xSel=-1, ySel=[], colNames=None, tabLabel='', sFilter self.lb.SetLabel(' '+tab.active_name) # Setting raw columns from raw table (self.tab) self.setColumns() - self.setGUIColumns(xSel=xSel, ySel=ySel, selInFull=selInFull) # Filt2Full will be created if a filter is present + self.setGUIColumns(xSel=xSel, ySel=ySel, zSel=zSel, selInFull=selInFull) # Filt2Full will be created if a filter is present else: self.lb.SetLabel(tabLabel) self.setColumns(columnNames=colNames) - self.setGUIColumns(xSel=xSel, ySel=ySel, selInFull=selInFull) # Filt2Full will be created if a filter is present + self.setGUIColumns(xSel=xSel, ySel=ySel, zSel=zSel, selInFull=selInFull) # Filt2Full will be created if a filter is present def updateColumn(self,i,newName): """ Update of one column name @@ -926,7 +926,7 @@ def setColumns(self, columnNames=None): # Storing columns, considered as "Full" self.columns=np.array(columns) - def setGUIColumns(self, xSel=-1, ySel=[], selInFull=True): + def setGUIColumns(self, xSel=-1, ySel=[], zSel=0, selInFull=True): """ Set columns actually shown on the GUI based on self.columns and potential filter if selInFull is True, the selection is assumed to be in the full/raw columns Otherwise, the selection is assumed to be in the filtered column @@ -982,11 +982,10 @@ def setGUIColumns(self, xSel=-1, ySel=[], selInFull=True): 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) + if 0 <= zSel < len(columnsZ_show): + self.comboZ.SetSelection(zSel) else: self.comboZ.SetSelection(0) # default: None @@ -1008,7 +1007,7 @@ def setGUIColumns(self, xSel=-1, ySel=[], selInFull=True): self.lbColumns.SetSelection(self.getDefaultColumnY(self.tab,len(columnsY)-1)) # Set selection for x, if any, NOTE x is not filtered, alwasy in full! - if (xSel<0) or xSel>len(columnsX): + if (xSel<0) or xSel>=len(columnsX): self.comboX.SetSelection(self.getDefaultColumnX(self.tab,len(columnsX)-1)) else: self.comboX.SetSelection(xSel) @@ -1089,7 +1088,8 @@ def onClearFilter(self, event=None): def onFilterChange(self, event=None): xSel,ySel,_,_ = self.getColumnSelection() # (indices in full) - self.setGUIColumns(xSel=xSel, ySel=ySel) # <<< Filtering done here + zSel = self.comboZ.GetSelection() # preserve current Z selection + self.setGUIColumns(xSel=xSel, ySel=ySel, zSel=zSel) # Filtering done here self.triggerPlot() # Trigger a col selection event def onFilterKey(self, event=None): @@ -1326,7 +1326,7 @@ def setTables(self,tabList,update=False): self.tabPanel.updateTabNames() for tn in tabnames: if tn not in self.tabSelections.keys(): - self.tabSelections[tn]={'xSel':-1,'ySel':[]} + self.tabSelections[tn]={'xSel':-1,'ySel':[],'zSel':0} else: pass # do nothing @@ -1368,11 +1368,11 @@ def setTabForCol(self,iTabSel,iPanel): t = self.tabList[iTabSel] ts = self.tabSelections[t.name] if iPanel==1: - self.colPanel1.setTab(t,ts['xSel'],ts['ySel'], sFilter=self.filterSelection[0]) + self.colPanel1.setTab(t,ts['xSel'],ts['ySel'],ts.get('zSel',0), sFilter=self.filterSelection[0]) elif iPanel==2: - self.colPanel2.setTab(t,ts['xSel'],ts['ySel'], sFilter=self.filterSelection[1]) + self.colPanel2.setTab(t,ts['xSel'],ts['ySel'],ts.get('zSel',0), sFilter=self.filterSelection[1]) elif iPanel==3: - self.colPanel3.setTab(t,ts['xSel'],ts['ySel'], sFilter=self.filterSelection[2]) + self.colPanel3.setTab(t,ts['xSel'],ts['ySel'],ts.get('zSel',0), sFilter=self.filterSelection[2]) else: raise Exception('Wrong ipanel') @@ -1446,15 +1446,34 @@ def setColForSimTab(self,ISel): colNames = [columnsPerTab[0][i] for i in IKeepPerTab[0]] - # restore selection + # restore selection, resolving by column name first xSel = -1 ySel = [] + zSel = 0 sFilter = self.filterSelection[0] if 'xSel' in self.simTabSelection: xSel = self.simTabSelection['xSel'] - ySel = self.simTabSelection['ySel'] + ySel = list(self.simTabSelection.get('ySel', [])) + zSel = self.simTabSelection.get('zSel', 0) + xName = self.simTabSelection.get('xName') + yNames = self.simTabSelection.get('yNames', []) + zName = self.simTabSelection.get('zName') + if xName is not None: + xSel = colNames.index(xName) if xName in colNames else -1 + elif xSel >= len(colNames): + xSel = -1 + if yNames: + ySel_new = [colNames.index(yn) for yn in yNames if yn in colNames] + ySel = ySel_new if ySel_new else [iy for iy in ySel if 0 <= iy < len(colNames)] + else: + ySel = [iy for iy in ySel if 0 <= iy < len(colNames)] + # Resolve Z: comboZ index 0=None, 1+=col + if zName is not None: + zSel = colNames.index(zName) + 1 if zName in colNames else 0 + elif zSel > len(colNames): # comboZ has len+1 entries + zSel = 0 # Set the colPanels - self.colPanel1.setTab(tab=None, colNames=colNames, tabLabel=' Tab. Intersection', xSel=xSel, ySel=ySel, sFilter=sFilter) + self.colPanel1.setTab(tab=None, colNames=colNames, tabLabel=' Tab. Intersection', xSel=xSel, ySel=ySel, zSel=zSel, sFilter=sFilter) self.colPanel2.setReadOnly(' Tab. Difference', ColInfo) self.IKeepPerTab=IKeepPerTab @@ -1468,10 +1487,11 @@ def selectDefaultTable(self): else: self.tabSelected=[] - def tabSelectionChanged(self): + def tabSelectionChanged(self, save=True): # TODO This can be cleaned-up and merged with updateLayout - # Storing the previous selection - self.saveSelection() # + # Storing the previous selection + if save: + self.saveSelection() # ISel=self.tabPanel.lbTab.GetSelections() if len(ISel)>0: if self.modeRequested=='auto': @@ -1541,9 +1561,156 @@ def renameTable(self,iTab, oldName, newName): self.tabPanel.updateTabNames() #self.printSelection() + def captureViewState(self): + """Capture current selection state for view saving""" + self.saveSelection() # ensure internal state is up-to-date + ISel = self.tabSelected + state = {} + # Save which tables are selected by name (robust across reloads) + state['tabSelectedNames'] = [self.tabList[i].name for i in ISel if i < self.tabList.len()] + # Save column selections keyed by table name, storing BOTH index and name + tabSelectionsFull = {} + for k, v in self.tabSelections.items(): + tab = next((t for t in self.tabList if t.name == k), None) + xName = None + yNames = [] + zName = None + if tab is not None: + cols = list(tab.columns) + xSel = v['xSel'] + if 0 <= xSel < len(cols): + xName = cols[xSel] + yNames = [cols[iy] for iy in v['ySel'] if 0 <= iy < len(cols)] + # zSel: comboZ index (0=None, 1+=column); zName is the column name + zSel = v.get('zSel', 0) + zColIdx = zSel - 1 # convert comboZ index to column index + zName = cols[zColIdx] if 0 <= zColIdx < len(cols) else None + tabSelectionsFull[k] = { + 'xSel': v['xSel'], + 'ySel': list(v['ySel']), + 'zSel': v.get('zSel', 0), + 'xName': xName, + 'yNames': yNames, + 'zName': zName, + } + state['tabSelections'] = tabSelectionsFull + simSel = dict(self.simTabSelection) + if 'ySel' in simSel: + simSel['ySel'] = list(simSel['ySel']) + # Add column-name backup for sim tab (resolution happens in setColForSimTab) + if self.currentMode == 'simColumnsMode': + simCols = list(self.colPanel1.columns) + xSim = simSel.get('xSel', -1) + zSim = simSel.get('zSel', 0) + simSel['xName'] = simCols[xSim] if 0 <= xSim < len(simCols) else None + simSel['yNames'] = [simCols[iy] for iy in simSel.get('ySel', []) if 0 <= iy < len(simCols)] + # zSim is comboZ index (0=None, 1+=col); store the column name + zColIdx = zSim - 1 + simSel['zName'] = simCols[zColIdx] if 0 <= zColIdx < len(simCols) else None + state['simTabSelection'] = simSel + state['filterSelection'] = list(self.filterSelection) + state['mode'] = self.currentMode + # Save per-table formulas so added columns can be recreated on restore + formulas_state = {} + for tab in self.tabList: + if tab.formulas: + formulas_state[tab.name] = sorted(tab.formulas, key=lambda f: f['pos']) + state['formulas'] = formulas_state + return state + + def restoreViewState(self, state): + """Restore selection state from a saved view (does not redraw). + Returns a list of warning strings for any missing tables or columns.""" + warnings = [] + # Re-apply saved formulas (added columns) before resolving column names + saved_formulas = state.get('formulas', {}) + if saved_formulas: + self.tabList.applyFormulas(saved_formulas) + # Refresh column panel so new columns are visible + ISel = self.tabPanel.lbTab.GetSelections() + for i in ISel: + if i < self.tabList.len(): + tab = self.tabList[i] + if tab.name in self.tabSelections: + ts = self.tabSelections[tab.name] + self.colPanel1.setTab(tab, ts['xSel'], ts['ySel'], ts.get('zSel', 0)) + # Restore column selections by table name, matching by column name first + selectedNames = set(state.get('tabSelectedNames', [])) + for k, v in state.get('tabSelections', {}).items(): + if k not in self.tabSelections: + continue + tab = next((t for t in self.tabList if t.name == k), None) + if tab is None: + # Table not currently loaded; restore raw indices so they're + # ready if the table is loaded later, but don't overwrite with + # anything that could lose information. + self.tabSelections[k] = {'xSel': v.get('xSel', -1), 'ySel': tuple(v.get('ySel', [])), 'zSel': v.get('zSel', 0)} + continue + xSel = v.get('xSel', -1) + ySel = list(v.get('ySel', [])) + cols = list(tab.columns) + # Resolve X by name first, then fall back to stored index + xName = v.get('xName') + if xName is not None: + if xName in cols: + xSel = cols.index(xName) + else: + if k in selectedNames: + warnings.append(' Table "{}": x-column "{}" not found, using default'.format(k, xName)) + xSel = -1 + elif xSel >= len(cols): + xSel = -1 + # Resolve Y by name first, then fall back to stored indices + yNames = v.get('yNames', []) + if yNames: + ySel_new = [cols.index(yn) for yn in yNames if yn in cols] + missing_y = [yn for yn in yNames if yn not in cols] + if missing_y and k in selectedNames: + warnings.append(' Table "{}": column(s) not found: {}'.format(k, ', '.join('"{}"'.format(n) for n in missing_y))) + ySel = ySel_new if ySel_new else [iy for iy in ySel if 0 <= iy < len(cols)] + else: + ySel = [iy for iy in ySel if 0 <= iy < len(cols)] + # Resolve Z by name first (zSel is comboZ index: 0=None, 1+=col) + zSel = v.get('zSel', 0) + zName = v.get('zName') + if zName is not None: + if zName in cols: + zSel = cols.index(zName) + 1 # +1 because comboZ[0]='None' + else: + if k in selectedNames: + warnings.append(' Table "{}": z-column "{}" not found, using None'.format(k, zName)) + zSel = 0 + elif zSel > len(cols): # comboZ length = len(cols)+1 + zSel = 0 + self.tabSelections[k] = {'xSel': xSel, 'ySel': tuple(ySel), 'zSel': zSel} + # Restore sim tab selection + simSel = state.get('simTabSelection', {}) + self.simTabSelection = dict(simSel) + if 'ySel' in self.simTabSelection: + self.simTabSelection['ySel'] = tuple(self.simTabSelection['ySel']) + # Restore filters + self.filterSelection = list(state.get('filterSelection', ['', '', ''])) + # Restore table selection by name, track which names are missing + tabSelectedNames = state.get('tabSelectedNames', []) + loadedNames = {t.name for t in self.tabList} + missing_tables = [n for n in tabSelectedNames if n not in loadedNames] + if missing_tables: + warnings.append('Table(s) not found: {}'.format(', '.join('"{}"'.format(n) for n in missing_tables))) + self.tabSelected = [i for i, t in enumerate(self.tabList) if t.name in tabSelectedNames] + # Apply selection to the list box + for i in range(self.tabPanel.lbTab.GetCount()): + self.tabPanel.lbTab.Deselect(i) + for i in self.tabSelected: + if i < self.tabPanel.lbTab.GetCount(): + self.tabPanel.lbTab.SetSelection(i) + # Update columns based on selection; skip saveSelection so the restored + # tabSelections are not overwritten by the current (stale) GUI state. + self.tabSelectionChanged(save=False) + return warnings + def saveSelection(self): #self.ISel=self.tabPanel.lbTab.GetSelections() - ISel=self.tabSelected # + ISel=self.tabSelected # # --- Save filters self.filterSelection = [self.colPanel1.tFilter.GetLineText(0).strip()] @@ -1554,6 +1721,7 @@ def saveSelection(self): if self.currentMode=='simColumnsMode': self.simTabSelection['xSel'] = self.colPanel1.comboX.GetSelection() self.simTabSelection['ySel'] = self.colPanel1.lbColumns.GetSelections() + self.simTabSelection['zSel'] = self.colPanel1.comboZ.GetSelection() else: #self.simTabSelection = {} # We do not erase it # --- Save selected columns for each tab @@ -1562,19 +1730,23 @@ def saveSelection(self): t=self.tabList[ii] self.tabSelections[t.name]['xSel'] = self.colPanel1.comboX.GetSelection() self.tabSelections[t.name]['ySel'] = self.colPanel1.lbColumns.GetSelections() + self.tabSelections[t.name]['zSel'] = self.colPanel1.comboZ.GetSelection() else: if len(ISel)>=1: t=self.tabList[ISel[0]] self.tabSelections[t.name]['xSel'] = self.colPanel1.comboX.GetSelection() self.tabSelections[t.name]['ySel'] = self.colPanel1.lbColumns.GetSelections() + self.tabSelections[t.name]['zSel'] = self.colPanel1.comboZ.GetSelection() if len(ISel)>=2: t=self.tabList[ISel[1]] self.tabSelections[t.name]['xSel'] = self.colPanel2.comboX.GetSelection() self.tabSelections[t.name]['ySel'] = self.colPanel2.lbColumns.GetSelections() + self.tabSelections[t.name]['zSel'] = self.colPanel2.comboZ.GetSelection() if len(ISel)>=3: t=self.tabList[ISel[2]] self.tabSelections[t.name]['xSel'] = self.colPanel3.comboX.GetSelection() self.tabSelections[t.name]['ySel'] = self.colPanel3.lbColumns.GetSelections() + self.tabSelections[t.name]['zSel'] = self.colPanel3.comboZ.GetSelection() self.tabSelected = self.tabPanel.lbTab.GetSelections(); #self.printSelection() diff --git a/pydatview/appdata.py b/pydatview/appdata.py index 03d4339..c343fbb 100644 --- a/pydatview/appdata.py +++ b/pydatview/appdata.py @@ -132,6 +132,8 @@ def defaultAppData(mainframe): # GUI data['plotPanel']=PlotPanel.defaultData() data['infoPanel']=InfoPanel.defaultData() + # Saved views + data['views'] = [] return data diff --git a/pydatview/main.py b/pydatview/main.py index b6d361d..2656449 100644 --- a/pydatview/main.py +++ b/pydatview/main.py @@ -1,8 +1,10 @@ import numpy as np -import os.path +import os +import os.path import sys -import traceback +import traceback import gc +import json try: import pandas as pd except: @@ -48,6 +50,7 @@ PROG_NAME='pyDatView' PROG_VERSION='v0.5-local' ISTAT = 0 # Index of Status bar where main status info is provided +VIEW_FILE_EXT = '.pdvview' # Extension for exported view files #matplotlib.rcParams['text.usetex'] = False # matplotlib.rcParams['font.sans-serif'] = 'DejaVu Sans' @@ -74,15 +77,18 @@ def __init__(self, parent): def OnDropFiles(self, x, y, filenames): filenames = [f for f in filenames if not os.path.isdir(f)] filenames.sort() - if len(filenames)>0: - # If Ctrl is pressed we add - bAdd= wx.GetKeyState(wx.WXK_CONTROL); - iFormat=self.parent.comboFormats.GetSelection() - if iFormat==0: # auto-format - Format = None - else: - Format = self.parent.FILE_FORMATS[iFormat-1] - self.parent.load_files(filenames, fileformats=[Format]*len(filenames), bAdd=bAdd, bPlot=True) + if len(filenames) == 0: + return True + # View files are handled separately + view_files = [f for f in filenames if f.lower().endswith(VIEW_FILE_EXT)] + data_files = [f for f in filenames if not f.lower().endswith(VIEW_FILE_EXT)] + if view_files: + self.parent.load_view_file(view_files[0]) + elif data_files: + bAdd = wx.GetKeyState(wx.WXK_CONTROL) + iFormat = self.parent.comboFormats.GetSelection() + Format = None if iFormat == 0 else self.parent.FILE_FORMATS[iFormat-1] + self.parent.load_files(data_files, fileformats=[Format]*len(data_files), bAdd=bAdd, bPlot=True) return True @@ -167,6 +173,17 @@ def __init__(self, data=None): for toolName in TOOLS.keys(): self.Bind(wx.EVT_MENU, lambda e, s_loc=toolName: self.onShowTool(e, s_loc), toolMenu.Append(wx.ID_ANY, toolName)) + # --- Views Menu + self.viewsMenu = wx.Menu() + saveViewMenuItem = self.viewsMenu.Append(wx.ID_ANY, 'Save current view...', 'Save the current selection and plot settings as a named view') + exportViewMenuItem = self.viewsMenu.Append(wx.ID_ANY, 'Export view to file...', 'Export the current view to a .pdvview file (includes file list and settings)') + importViewMenuItem = self.viewsMenu.Append(wx.ID_ANY, 'Import view from file...', 'Load a .pdvview file, open its data files, and restore the view') + self.viewsMenu.AppendSeparator() + menuBar.Append(self.viewsMenu, "&Views") + self.Bind(wx.EVT_MENU, self.onSaveView, saveViewMenuItem) + self.Bind(wx.EVT_MENU, self.onExportView, exportViewMenuItem) + self.Bind(wx.EVT_MENU, self.onImportView, importViewMenuItem) + # --- OpenFAST Plugins ofMenu = wx.Menu() menuBar.Append(ofMenu, "&OpenFAST") @@ -212,9 +229,9 @@ def __init__(self, data=None): tb.AddControl( self.cbLivePlot ) tb.AddStretchableSpace() tb.AddControl( wx.StaticText(tb, -1, 'Format: ' ) ) - self.comboFormats = wx.ComboBox(tb, choices = self.FILE_FORMATS_NAMEXT, style=wx.CB_READONLY) + self.comboFormats = wx.ComboBox(tb, choices = self.FILE_FORMATS_NAMEXT, style=wx.CB_READONLY) self.comboFormats.SetSelection(0) - tb.AddControl(self.comboFormats ) + tb.AddControl(self.comboFormats ) # Menu for loader options self.btLoaderMenu = wx.Button(tb, wx.ID_ANY, CHAR['menu'], style=wx.BU_EXACTFIT) tb.AddControl(self.btLoaderMenu) @@ -223,16 +240,23 @@ def __init__(self, data=None): TBAddTool(tb, "Open" , 'ART_FILE_OPEN', self.onLoad) TBAddTool(tb, "Reload", 'ART_REDO' , self.onReload) TBAddTool(tb, "Add" , 'ART_PLUS' , self.onAdd) - #bmp = wx.Bitmap('help.png') #wx.Bitmap("NEW.BMP", wx.BITMAP_TYPE_BMP) - #self.AddTBBitmapTool(tb,"Debug" ,wx.ArtProvider.GetBitmap(wx.ART_ERROR),self.onDEBUG) + # Views combobox + tb.AddSeparator() + tb.AddControl( wx.StaticText(tb, -1, 'View: ' ) ) + self.comboViews = wx.ComboBox(tb, choices=[], style=wx.CB_READONLY, size=(120,-1)) + self.comboViews.SetToolTip('Select a saved view to restore it') + tb.AddControl(self.comboViews) + self.Bind(wx.EVT_COMBOBOX, self.onRestoreViewFromCombo, self.comboViews) tb.AddStretchableSpace() - tb.Realize() - self.toolBar = tb + tb.Realize() + self.toolBar = tb # Bind Toolbox Events self.Bind(wx.EVT_COMBOBOX, self.onModeChange, self.comboMode ) self.Bind(wx.EVT_COMBOBOX, self.onFormatChange, self.comboFormats ) tb.Bind(wx.EVT_BUTTON, self.onShowLoaderMenu, self.btLoaderMenu) tb.Bind(wx.EVT_CHECKBOX, self.onLivePlotChange, self.cbLivePlot) + # Populate the views combobox and menu from saved data + self._populateViewsUI() # --- Status bar self.statusbar=self.CreateStatusBar(3, style=0) @@ -753,20 +777,22 @@ def onScript(self, event=None): def onLoad(self, event=None): - self.selectFile(bAdd=False) + # Check CTRL state: if held, add to existing tables (same as CTRL+drag-drop) + bAdd = wx.GetKeyState(wx.WXK_CONTROL) and self.tabList.len() > 0 + self.selectFile(bAdd=bAdd) def onAdd(self, event=None): self.selectFile(bAdd=self.tabList.len()>0) - def selectFile(self,bAdd=False): + def selectFile(self, bAdd=False): # --- File Format extension iFormat=self.comboFormats.GetSelection() sFormat=self.comboFormats.GetStringSelection() if iFormat==0: # auto-format Format = None - #wildcard = 'all (*.*)|*.*' - wildcard='|'.join([n+'|*'+';*'.join(e) for n,e in zip(self.FILE_FORMATS_NAMEXT,self.FILE_FORMATS_EXTENSIONS)]) - #wildcard = sFormat + extensions+'|all (*.*)|*.*' + view_wc = 'pyDatView views (*{})|*{}'.format(VIEW_FILE_EXT, VIEW_FILE_EXT) + wildcard = '|'.join([n+'|*'+';*'.join(e) for n,e in zip(self.FILE_FORMATS_NAMEXT,self.FILE_FORMATS_EXTENSIONS)]) + wildcard = view_wc + '|' + wildcard else: Format = self.FILE_FORMATS[iFormat-1] extensions = '|*'+';*'.join(self.FILE_FORMATS[iFormat-1].extensions) @@ -775,12 +801,16 @@ def selectFile(self,bAdd=False): with wx.FileDialog(self, "Open file", wildcard=wildcard, style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST | wx.FD_MULTIPLE) as dlg: #other options: wx.CHANGE_DIR - #dlg.SetSize((100,100)) - #dlg.Center() - if dlg.ShowModal() == wx.ID_CANCEL: + if dlg.ShowModal() == wx.ID_CANCEL: return # the user changed their mind - filenames = dlg.GetPaths() - self.load_files(filenames,fileformats=[Format]*len(filenames),bAdd=bAdd, bPlot=True) + filenames = dlg.GetPaths() + # Route view files the same way as drag-and-drop does + view_files = [f for f in filenames if f.lower().endswith(VIEW_FILE_EXT)] + data_files = [f for f in filenames if not f.lower().endswith(VIEW_FILE_EXT)] + if view_files: + self.load_view_file(view_files[0]) + elif data_files: + self.load_files(data_files, fileformats=[Format]*len(data_files), bAdd=bAdd, bPlot=True) def onModeChange(self, event=None): if hasattr(self,'selPanel'): @@ -801,6 +831,252 @@ def onShowLoaderMenu(self, event=None): self.PopupMenu(self.loaderMenu) #, pos) + # --- Views: save / restore + def _populateViewsUI(self): + """Rebuild the Views menu items and toolbar combobox from saved views list""" + views = self.data.get('views', []) + # Update combobox + names = [v['name'] for v in views] + self.comboViews.Set(names) + # Rebuild dynamic menu items (keep 'Save...' and separator at top) + # Remove all items after the separator (index 2+) + while self.viewsMenu.GetMenuItemCount() > 2: + item = self.viewsMenu.FindItemByPosition(2) + self.viewsMenu.Delete(item) + for v in views: + item = self.viewsMenu.Append(wx.ID_ANY, v['name'], 'Restore view: ' + v['name']) + self.Bind(wx.EVT_MENU, lambda e, name=v['name']: self.onRestoreView(name), item) + + def _capturePipelineState(self): + """Return {action_name: data_dict} for every action currently in the pipeline.""" + if not hasattr(self, 'pipePanel'): + return {} + state = {} + for action in list(self.pipePanel.actionsData) + list(self.pipePanel.actionsPlotFilters): + state[action.name] = dict(action.data) + return state + + def _restorePipelineState(self, pipeline_state): + """Restore pipeline actions from a saved state dict. + + Strategy + -------- + * PlotDataActions (Filter, Remove Outliers, Resample, Bin data): + Non-destructive — safe to cancel and re-apply at any time. + * ReversibleTableAction (Mask): + cancel() calls clearMask() on each table, so the original rows + are recovered before the saved mask is re-applied. + * IrreversibleTableAction (Standardize Units etc.): + Cannot be undone — left in place, not overwritten. + """ + if not hasattr(self, 'pipePanel') or not pipeline_state: + return + from pydatview.plugins import DATA_PLUGINS_WITH_EDITOR, OF_DATA_PLUGINS_WITH_EDITOR + from pydatview.pipeline import IrreversibleTableAction, AdderAction + all_restorable = {} + all_restorable.update(DATA_PLUGINS_WITH_EDITOR) + all_restorable.update(OF_DATA_PLUGINS_WITH_EDITOR) + + # Step 1 – remove every restorable action that is currently active. + # IrreversibleTableAction and AdderAction are excluded: they can't + # be undone without a data reload so we leave them untouched. + for name in list(all_restorable.keys()): + existing = self.pipePanel.find(name) + if existing is not None and not isinstance(existing, (IrreversibleTableAction, AdderAction)): + self.pipePanel.remove(existing, cancel=True, tabList=self.tabList, updateGUI=False) + + # Step 2 – recreate each action that was active when the view was saved + for name, saved_data in pipeline_state.items(): + if name not in all_restorable: + continue # Unknown or irreversible plugin — skip + if not saved_data.get('active', False): + continue # Only restore actions that were active + constructor = all_restorable[name] + action = constructor(label=name, mainframe=self) + # Skip AdderAction and IrreversibleTableAction — restoring these + # without a data reload would produce duplicate or inconsistent tables. + if isinstance(action, (IrreversibleTableAction, AdderAction)): + continue + action.data.update(saved_data) + self.pipePanel.append(action, overwrite=False, apply=True, + updateGUI=True, tabList=self.tabList) + + def onSaveView(self, event=None): + """Prompt for a view name and save current state""" + if not hasattr(self, 'selPanel') or not hasattr(self, 'plotPanel'): + from .GUICommon import Error + Error(self, 'Load some data and plot it before saving a view.') + return + dlg = wx.TextEntryDialog(self, 'Enter a name for this view:', 'Save View', '') + if dlg.ShowModal() != wx.ID_OK: + dlg.Destroy() + return + name = dlg.GetValue().strip() + dlg.Destroy() + if not name: + return + view = { + 'name': name, + 'selection': self.selPanel.captureViewState(), + 'plotPanel': self.plotPanel.captureViewData(), + 'modeIndex': self.comboMode.GetSelection(), + 'loaderOptions': dict(self.data['loaderOptions']), + 'pipeline': self._capturePipelineState(), + } + # Replace existing view with same name, otherwise append + views = self.data.get('views', []) + for i, v in enumerate(views): + if v['name'] == name: + views[i] = view + break + else: + views.append(view) + self.data['views'] = views + self._populateViewsUI() + self.statusbar.SetStatusText('View "{}" saved.'.format(name), ISTAT) + + def onRestoreViewFromCombo(self, event=None): + """Restore the view selected in the toolbar combobox""" + name = self.comboViews.GetStringSelection() + if name: + self.onRestoreView(name) + + def onRestoreView(self, name): + """Restore the named view""" + if not hasattr(self, 'selPanel') or not hasattr(self, 'plotPanel'): + return + views = self.data.get('views', []) + view = next((v for v in views if v['name'] == name), None) + if view is None: + return + # R9 – Restore loader options (e.g. dayfirst) stored in the view + loader_opts = view.get('loaderOptions', {}) + if loader_opts: + self.data['loaderOptions'].update(loader_opts) + # Restore selection mode + modeIndex = view.get('modeIndex', 0) + self.comboMode.SetSelection(modeIndex) + self.selPanel.updateLayout(SEL_MODES_ID[modeIndex]) + # Restore selection state (tables + columns); collect any warnings + warnings = self.selPanel.restoreViewState(view.get('selection', {})) + # Restore plot settings + self.plotPanel.restoreViewData(view.get('plotPanel', {})) + # Restore pipeline actions (Mask, Filter, Resample, Bin data, etc.) + self._restorePipelineState(view.get('pipeline', {})) + # Trigger a full redraw + self.plotPanel.load_and_draw() + if warnings: + Warn(self, 'View "{}" was partially restored:\n\n{}'.format(name, '\n'.join(warnings))) + self.statusbar.SetStatusText('View "{}" partially restored.'.format(name), ISTAT) + else: + self.statusbar.SetStatusText('View "{}" restored.'.format(name), ISTAT) + + def onExportView(self, event=None): + """Export the current view (files + selection + plot settings) to a .pdvview file""" + if not hasattr(self, 'selPanel') or not hasattr(self, 'plotPanel'): + Error(self, 'Load some data and plot it before exporting a view.') + return + if self.tabList.len() == 0: + Error(self, 'No files are loaded.') + return + wildcard = 'pyDatView view (*{})|*{}'.format(VIEW_FILE_EXT, VIEW_FILE_EXT) + with wx.FileDialog(self, 'Export view to file', wildcard=wildcard, + style=wx.FD_SAVE | wx.FD_OVERWRITE_PROMPT) as dlg: + if dlg.ShowModal() == wx.ID_CANCEL: + return + path = dlg.GetPath() + if not path.lower().endswith(VIEW_FILE_EXT): + path += VIEW_FILE_EXT + base_dir = os.path.dirname(os.path.abspath(path)) + # Build file list with relative paths + filenames, fileformats = self.tabList.filenames_and_formats + files = [] + for fn, ff in zip(filenames, fileformats): + try: + rel = os.path.relpath(fn, base_dir) + except ValueError: + rel = fn # Different drive on Windows: fall back to absolute + files.append({'path': rel, 'format': ff.name if ff is not None else ''}) + view_data = { + 'version': 1, + 'name': os.path.splitext(os.path.basename(path))[0], + 'files': files, + 'loaderOptions': dict(self.data['loaderOptions']), + 'modeIndex': self.comboMode.GetSelection(), + 'selection': self.selPanel.captureViewState(), + 'plotPanel': self.plotPanel.captureViewData(), + 'pipeline': self._capturePipelineState(), + } + try: + with open(path, 'w') as f: + json.dump(view_data, f, indent=2) + self.statusbar.SetStatusText('View exported to: {}'.format(path), ISTAT) + except Exception as e: + Error(self, 'Failed to export view:\n{}'.format(str(e))) + + def onImportView(self, event=None): + """Open a file dialog to pick a .pdvview file and load it""" + wildcard = 'pyDatView view (*{})|*{}|All files (*.*)|*.*'.format(VIEW_FILE_EXT, VIEW_FILE_EXT) + with wx.FileDialog(self, 'Import view from file', wildcard=wildcard, + style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST) as dlg: + if dlg.ShowModal() == wx.ID_CANCEL: + return + path = dlg.GetPath() + self.load_view_file(path) + + def load_view_file(self, path): + """Load a .pdvview file: open its data files then restore the saved view state""" + try: + with open(path, 'r') as f: + view_data = json.load(f) + except Exception as e: + Error(self, 'Failed to read view file:\n{}'.format(str(e))) + return + base_dir = os.path.dirname(os.path.abspath(path)) + # Resolve file paths (relative to the view file) and match formats + filenames = [] + fileformats = [] + missing = [] + for entry in view_data.get('files', []): + rel = entry.get('path', '') + abs_path = os.path.normpath(os.path.join(base_dir, rel)) + if not os.path.isfile(abs_path): + missing.append(abs_path) + continue + fmt_name = entry.get('format', '') + ff = next((f for f in self.FILE_FORMATS if f.name == fmt_name), None) + filenames.append(abs_path) + fileformats.append(ff) + if missing: + Warn(self, 'The following file(s) from the view could not be found:\n\n' + + '\n'.join(missing)) + if not filenames: + Error(self, 'No loadable files found in the view.') + return + # Restore loader options stored in the view + loader_opts = view_data.get('loaderOptions', {}) + if loader_opts: + self.data['loaderOptions'].update(loader_opts) + # Load the data files (bPlot=False so we can restore settings first) + self.load_files(filenames, fileformats=fileformats, bAdd=False, bPlot=False) + if not hasattr(self, 'selPanel') or not hasattr(self, 'plotPanel'): + return + # Restore view state + modeIndex = view_data.get('modeIndex', 0) + self.comboMode.SetSelection(modeIndex) + self.selPanel.updateLayout(SEL_MODES_ID[modeIndex]) + warnings = self.selPanel.restoreViewState(view_data.get('selection', {})) + self.plotPanel.restoreViewData(view_data.get('plotPanel', {})) + # Restore pipeline actions (Mask, Filter, Resample, Bin data, etc.) + self._restorePipelineState(view_data.get('pipeline', {})) + self.plotPanel.load_and_draw() + view_name = view_data.get('name', os.path.basename(path)) + if warnings: + Warn(self, 'View "{}" was partially restored:\n\n{}'.format(view_name, '\n'.join(warnings))) + self.statusbar.SetStatusText('View "{}" partially restored.'.format(view_name), ISTAT) + else: + self.statusbar.SetStatusText('View "{}" loaded from file.'.format(view_name), ISTAT) + def mainFrameUpdateLayout(self, event=None): if hasattr(self.nb,'fields_1d_tab'): try: diff --git a/tests/test_views.py b/tests/test_views.py new file mode 100644 index 0000000..ce5e571 --- /dev/null +++ b/tests/test_views.py @@ -0,0 +1,660 @@ +""" +Tests for the view save/restore feature: + - view state dict structure (keys present, types correct) + - z-column name resolution logic + - .pdvview JSON round-trip (export -> import path resolution) + - view3D field in plotPanel state +""" +import os +import json +import tempfile +import unittest +import numpy as np +import pandas as pd + + +# --------------------------------------------------------------------------- +# Helpers that mirror the resolution logic in GUISelectionPanel +# --------------------------------------------------------------------------- + +def resolve_x(v, cols, selected=True): + """Resolve xSel from a saved tabSelections entry. Returns (xSel, warnings).""" + warnings = [] + xSel = v.get('xSel', -1) + xName = v.get('xName') + if xName is not None: + if xName in cols: + xSel = cols.index(xName) + else: + if selected: + warnings.append('x-column "{}" not found'.format(xName)) + xSel = -1 + elif xSel >= len(cols): + xSel = -1 + return xSel, warnings + + +def resolve_y(v, cols, selected=True): + """Resolve ySel from a saved tabSelections entry. Returns (ySel, warnings).""" + warnings = [] + ySel = list(v.get('ySel', [])) + yNames = v.get('yNames', []) + if yNames: + ySel_new = [cols.index(yn) for yn in yNames if yn in cols] + missing = [yn for yn in yNames if yn not in cols] + if missing and selected: + warnings.append('column(s) not found: {}'.format(missing)) + ySel = ySel_new if ySel_new else [iy for iy in ySel if 0 <= iy < len(cols)] + else: + ySel = [iy for iy in ySel if 0 <= iy < len(cols)] + return ySel, warnings + + +def resolve_z(v, cols, selected=True): + """Resolve zSel (comboZ index: 0=None, 1+=col). Returns (zSel, warnings).""" + warnings = [] + zSel = v.get('zSel', 0) + zName = v.get('zName') + if zName is not None: + if zName in cols: + zSel = cols.index(zName) + 1 # +1 because comboZ[0]='None' + else: + if selected: + warnings.append('z-column "{}" not found'.format(zName)) + zSel = 0 + elif zSel > len(cols): # comboZ has len(cols)+1 entries + zSel = 0 + return zSel, warnings + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +class TestViewStateStructure(unittest.TestCase): + """Verify that the expected keys are present in view state dicts.""" + + def _make_tab_entry(self, xSel, ySel, zSel, cols): + """Build a tabSelections entry with name backups.""" + xName = cols[xSel] if 0 <= xSel < len(cols) else None + yNames = [cols[i] for i in ySel if 0 <= i < len(cols)] + zColIdx = zSel - 1 + zName = cols[zColIdx] if 0 <= zColIdx < len(cols) else None + return { + 'xSel': xSel, + 'ySel': list(ySel), + 'zSel': zSel, + 'xName': xName, + 'yNames': yNames, + 'zName': zName, + } + + def test_tab_entry_keys(self): + cols = ['Time', 'Speed', 'Power', 'Torque'] + entry = self._make_tab_entry(xSel=0, ySel=[2], zSel=3, cols=cols) + for key in ('xSel', 'ySel', 'zSel', 'xName', 'yNames', 'zName'): + self.assertIn(key, entry) + + def test_tab_entry_values(self): + cols = ['Time', 'Speed', 'Power', 'Torque'] + entry = self._make_tab_entry(xSel=0, ySel=[2], zSel=3, cols=cols) + self.assertEqual(entry['xName'], 'Time') + self.assertEqual(entry['yNames'], ['Power']) + self.assertEqual(entry['zName'], 'Power') # zSel=3 → zColIdx=2 → cols[2] + + def test_z_none_stored_as_zero(self): + """zSel=0 means 'None' (no Z column selected).""" + cols = ['Time', 'Speed', 'Power'] + entry = self._make_tab_entry(xSel=0, ySel=[1], zSel=0, cols=cols) + self.assertEqual(entry['zSel'], 0) + self.assertIsNone(entry['zName']) + + def test_plot_panel_view3d_key(self): + """plotPanel state must include view3D.""" + plot_state = { + 'plotType': 'Regular', + 'logX': False, + 'logY': False, + 'grid': False, + 'crossHair': True, + 'subplot': False, + 'sync': True, + 'autoScale': True, + 'stepPlot': False, + 'curveType': 1, + 'plotStyle': {}, + 'view3D': True, + } + self.assertIn('view3D', plot_state) + self.assertTrue(plot_state['view3D']) + + +class TestZColumnResolution(unittest.TestCase): + """Test z-column resolution: name-first, index fallback, out-of-range.""" + + def setUp(self): + self.cols = ['Time', 'Speed', 'Power', 'Torque'] + + def test_resolve_by_name(self): + v = {'zSel': 99, 'zName': 'Power'} # index wrong, name correct + zSel, w = resolve_z(v, self.cols) + self.assertEqual(zSel, 3) # cols.index('Power')+1 = 3 + self.assertEqual(w, []) + + def test_resolve_by_index_when_no_name(self): + v = {'zSel': 2, 'zName': None} # comboZ index 2 → col index 1 → 'Speed' + zSel, w = resolve_z(v, self.cols) + self.assertEqual(zSel, 2) + self.assertEqual(w, []) + + def test_missing_name_falls_back_to_none(self): + v = {'zSel': 2, 'zName': 'DoesNotExist'} + zSel, w = resolve_z(v, self.cols) + self.assertEqual(zSel, 0) # None + self.assertEqual(len(w), 1) + self.assertIn('DoesNotExist', w[0]) + + def test_missing_name_no_warning_for_unselected_table(self): + v = {'zSel': 2, 'zName': 'DoesNotExist'} + zSel, w = resolve_z(v, self.cols, selected=False) + self.assertEqual(zSel, 0) + self.assertEqual(w, []) # no warning for unselected table + + def test_index_out_of_range_resets_to_none(self): + v = {'zSel': 100, 'zName': None} # way out of range + zSel, w = resolve_z(v, self.cols) + self.assertEqual(zSel, 0) + + def test_none_selection_preserved(self): + v = {'zSel': 0, 'zName': None} + zSel, w = resolve_z(v, self.cols) + self.assertEqual(zSel, 0) # still None + self.assertEqual(w, []) + + def test_reordered_columns(self): + """Name lookup must survive column reordering.""" + v = {'zSel': 1, 'zName': 'Power'} # was first col; now third + new_cols = ['Time', 'Torque', 'Speed', 'Power'] + zSel, w = resolve_z(v, new_cols) + self.assertEqual(zSel, 4) # new_cols.index('Power')+1 = 4 + self.assertEqual(w, []) + + +class TestXYResolution(unittest.TestCase): + """Test x- and y-column resolution (same strategy as z).""" + + def setUp(self): + self.cols = ['Time', 'Speed', 'Power', 'Torque'] + + def test_x_resolved_by_name(self): + v = {'xSel': 99, 'xName': 'Speed'} + xSel, w = resolve_x(v, self.cols) + self.assertEqual(xSel, 1) + self.assertEqual(w, []) + + def test_x_missing_name_returns_minus_one(self): + v = {'xSel': 1, 'xName': 'Gone'} + xSel, w = resolve_x(v, self.cols) + self.assertEqual(xSel, -1) + self.assertEqual(len(w), 1) + + def test_y_resolved_by_name(self): + v = {'ySel': [99], 'yNames': ['Power', 'Torque']} + ySel, w = resolve_y(v, self.cols) + self.assertEqual(ySel, [2, 3]) + self.assertEqual(w, []) + + def test_y_partial_missing(self): + v = {'ySel': [], 'yNames': ['Power', 'Missing']} + ySel, w = resolve_y(v, self.cols) + self.assertEqual(ySel, [2]) # only Power resolved + self.assertEqual(len(w), 1) + self.assertIn('Missing', w[0]) + + def test_y_all_missing_falls_back_to_index(self): + v = {'ySel': [1, 2], 'yNames': ['Gone']} + ySel, w = resolve_y(v, self.cols) + self.assertEqual(ySel, [1, 2]) # index fallback + + +class TestViewFilePersistence(unittest.TestCase): + """Test the .pdvview JSON file format: write -> read round-trip.""" + + def _make_view_data(self, base_dir, data_path): + rel = os.path.relpath(data_path, base_dir) + return { + 'version': 1, + 'name': 'test_view', + 'files': [{'path': rel, 'format': 'CSV'}], + 'loaderOptions': {'dayfirst': False, 'naming': 'Ellude'}, + 'modeIndex': 0, + 'selection': { + 'tabSelectedNames': ['default'], + 'tabSelections': { + 'default': { + 'xSel': 0, 'ySel': [1], 'zSel': 2, + 'xName': 'Time', 'yNames': ['Speed'], 'zName': 'Power', + } + }, + 'simTabSelection': {}, + 'filterSelection': ['', '', ''], + 'mode': 'Regular', + }, + 'plotPanel': { + 'plotType': 'Regular', + 'view3D': True, + }, + } + + def test_json_round_trip(self): + with tempfile.TemporaryDirectory() as tmpdir: + data_file = os.path.join(tmpdir, 'data', 'run.csv') + os.makedirs(os.path.dirname(data_file)) + pd.DataFrame({'Time': [0, 1], 'Speed': [1, 2], 'Power': [3, 4]}).to_csv(data_file, index=False) + + view_path = os.path.join(tmpdir, 'my_view.pdvview') + view_data = self._make_view_data(tmpdir, data_file) + + with open(view_path, 'w') as f: + json.dump(view_data, f, indent=2) + + with open(view_path, 'r') as f: + loaded = json.load(f) + + self.assertEqual(loaded['name'], 'test_view') + self.assertEqual(loaded['version'], 1) + self.assertTrue(loaded['plotPanel']['view3D']) + + # File path should be relative + stored_path = loaded['files'][0]['path'] + self.assertFalse(os.path.isabs(stored_path)) + + # Resolve back to absolute + base_dir = os.path.dirname(os.path.abspath(view_path)) + abs_path = os.path.normpath(os.path.join(base_dir, stored_path)) + self.assertTrue(os.path.isfile(abs_path)) + + def test_z_selection_survives_round_trip(self): + with tempfile.TemporaryDirectory() as tmpdir: + view_path = os.path.join(tmpdir, 'v.pdvview') + view_data = self._make_view_data(tmpdir, os.path.join(tmpdir, 'f.csv')) + with open(view_path, 'w') as f: + json.dump(view_data, f) + with open(view_path, 'r') as f: + loaded = json.load(f) + sel = loaded['selection']['tabSelections']['default'] + self.assertEqual(sel['zSel'], 2) + self.assertEqual(sel['zName'], 'Power') + + def test_missing_file_reported_not_crash(self): + """A stored path that does not exist should be detectable.""" + with tempfile.TemporaryDirectory() as tmpdir: + view_path = os.path.join(tmpdir, 'v.pdvview') + view_data = self._make_view_data(tmpdir, os.path.join(tmpdir, 'nonexistent.csv')) + with open(view_path, 'w') as f: + json.dump(view_data, f) + with open(view_path, 'r') as f: + loaded = json.load(f) + base_dir = os.path.dirname(os.path.abspath(view_path)) + for entry in loaded['files']: + abs_path = os.path.normpath(os.path.join(base_dir, entry['path'])) + self.assertFalse(os.path.isfile(abs_path)) + + +class TestViewStateCompatibility(unittest.TestCase): + """Old views without zSel/zName must load cleanly (backward compat).""" + + def test_old_entry_no_z_keys(self): + """resolve_z on an entry with no z keys must return 0 (None).""" + v = {'xSel': 0, 'ySel': [1], 'xName': 'Time', 'yNames': ['Speed']} + zSel, w = resolve_z(v, ['Time', 'Speed', 'Power']) + self.assertEqual(zSel, 0) + self.assertEqual(w, []) + + def test_old_plot_panel_no_view3d(self): + """plotPanel without view3D key must default to False.""" + plot_state = {'plotType': 'Regular'} + view3D = plot_state.get('view3D', False) + self.assertFalse(view3D) + + +class TestBugRegressions(unittest.TestCase): + """Regression tests for specific bugs found and fixed.""" + + # --- Bug 1: onFilterChange dropped zSel ------------------------------------ + # The fix: pass zSel=self.comboZ.GetSelection() to setGUIColumns. + # We can't test the wx widget directly, but we can assert that + # setGUIColumns is called WITH a zSel argument and that the resolution + # logic handles a preserved zSel correctly. + + def test_filter_change_preserves_z_by_existing_index(self): + """After a filter, a valid zSel in range must be kept as-is.""" + cols = ['Time', 'Speed', 'Power'] + # zSel=2 (comboZ index 2 → 'Speed'); still in range after filter + v = {'zSel': 2, 'zName': None} + zSel, w = resolve_z(v, cols) + self.assertEqual(zSel, 2) # preserved + self.assertEqual(w, []) + + def test_filter_change_resets_out_of_range_z(self): + """After a filter that removes columns, an out-of-range zSel resets to 0.""" + # Simulate: table had 5 cols, now only 2 remain (filter applied) + cols_after_filter = ['Time', 'Speed'] + v = {'zSel': 4, 'zName': None} # was valid before, now out-of-range + zSel, w = resolve_z(v, cols_after_filter) + self.assertEqual(zSel, 0) # reset to None + + # --- Bug 2: zName NameError when tab is None in captureViewState ---------- + # The fix: initialize zName = None alongside xName/yNames before the + # `if tab is not None:` block. + + def test_capture_state_tab_not_in_tablist(self): + """captureViewState: stale tabSelections entry (tab not in tabList) + must produce zName=None rather than a NameError.""" + # Simulate the logic of the fixed captureViewState loop body + # when tab is None (stale entry) + cols = None # tab not available + xName = None + yNames = [] + zName = None # <<< the fix: must be initialized here + # tab is None → skip the if block + result = { + 'xSel': -1, + 'ySel': [], + 'zSel': 0, + 'xName': xName, + 'yNames': yNames, + 'zName': zName, + } + self.assertIsNone(result['zName']) # no NameError, value is None + self.assertIsNone(result['xName']) + self.assertEqual(result['yNames'], []) + + def test_capture_state_z_none_selected(self): + """When zSel=0 (None), zColIdx=-1 → zName must be None, not raise.""" + cols = ['Time', 'Speed', 'Power'] + zSel = 0 + zColIdx = zSel - 1 # = -1 + zName = cols[zColIdx] if 0 <= zColIdx < len(cols) else None + self.assertIsNone(zName) + + +class TestSubPanelState(unittest.TestCase): + """Verify that sub-panel state dicts have the expected keys and types (R1-R5).""" + + def _make_spectral(self, yType='PSD', xType='1/x', avgMethod='Welch', + avgWindow='Hamming', bDetrend=False, nExp=11, xlim='-1'): + return {'yType': yType, 'xType': xType, 'avgMethod': avgMethod, + 'avgWindow': avgWindow, 'bDetrend': bDetrend, + 'nExp': nExp, 'nPerDecade': nExp, 'xlim': xlim} + + def _make_compare(self, type_='Relative'): + return {'type': type_} + + def _make_minmax(self, yScale=True, xScale=False, yCenter='None', yRef=None): + return {'yScale': yScale, 'xScale': xScale, 'yCenter': yCenter, 'yRef': yRef} + + def _make_pdf(self, nBins=51, smooth=False): + return {'nBins': nBins, 'smooth': smooth} + + def _make_polar(self, Bins='None', Deg=True, About='x (from z, y hori flip, z vert)', + SameMean=False, rRef=None): + return {'Bins': Bins, 'Deg': Deg, 'About': About, 'SameMean': SameMean, 'rRef': rRef} + + # R1 – Spectral + def test_spectral_keys(self): + s = self._make_spectral() + for k in ('yType', 'xType', 'avgMethod', 'avgWindow', 'bDetrend', 'nExp', 'xlim'): + self.assertIn(k, s) + + def test_spectral_defaults(self): + s = self._make_spectral() + self.assertEqual(s['yType'], 'PSD') + self.assertEqual(s['avgMethod'], 'Welch') + self.assertFalse(s['bDetrend']) + self.assertEqual(s['xlim'], '-1') + + def test_spectral_custom(self): + s = self._make_spectral(yType='Amplitude', avgMethod='Binning', bDetrend=True, nExp=8, xlim='10.5') + self.assertEqual(s['yType'], 'Amplitude') + self.assertEqual(s['avgMethod'], 'Binning') + self.assertTrue(s['bDetrend']) + self.assertEqual(s['nExp'], 8) + self.assertEqual(s['xlim'], '10.5') + + # R2 – Compare + def test_compare_keys(self): + c = self._make_compare() + self.assertIn('type', c) + + def test_compare_type_values(self): + for t in ('Relative', '|Relative|', 'Ratio', 'Absolute', 'Y-Y'): + c = self._make_compare(type_=t) + self.assertEqual(c['type'], t) + + # R3 – MinMax + def test_minmax_keys(self): + m = self._make_minmax() + for k in ('yScale', 'xScale', 'yCenter', 'yRef'): + self.assertIn(k, m) + + def test_minmax_defaults(self): + m = self._make_minmax() + self.assertTrue(m['yScale']) + self.assertFalse(m['xScale']) + self.assertEqual(m['yCenter'], 'None') + self.assertIsNone(m['yRef']) + + # R4 – PDF + def test_pdf_keys(self): + p = self._make_pdf() + for k in ('nBins', 'smooth'): + self.assertIn(k, p) + + def test_pdf_defaults(self): + p = self._make_pdf() + self.assertEqual(p['nBins'], 51) + self.assertFalse(p['smooth']) + + # R5 – Polar + def test_polar_keys(self): + p = self._make_polar() + for k in ('Bins', 'Deg', 'About', 'SameMean'): + self.assertIn(k, p) + + def test_polar_defaults(self): + p = self._make_polar() + self.assertTrue(p['Deg']) + self.assertFalse(p['SameMean']) + self.assertEqual(p['Bins'], 'None') + + +class TestToggleState(unittest.TestCase): + """Verify save/restore of axis toggle fields R6-R8.""" + + def _make_plot_state(self, swapXY=False, flipX=False, flipY=False, plotMatrix=False): + return {'swapXY': swapXY, 'flipX': flipX, 'flipY': flipY, 'plotMatrix': plotMatrix} + + def test_toggle_keys_present(self): + s = self._make_plot_state() + for k in ('swapXY', 'flipX', 'flipY', 'plotMatrix'): + self.assertIn(k, s) + + def test_toggle_defaults_are_false(self): + s = self._make_plot_state() + self.assertFalse(s['swapXY']) + self.assertFalse(s['flipX']) + self.assertFalse(s['flipY']) + self.assertFalse(s['plotMatrix']) + + def test_toggle_values_preserved(self): + s = self._make_plot_state(swapXY=True, flipX=True) + self.assertTrue(s['swapXY']) + self.assertTrue(s['flipX']) + self.assertFalse(s['flipY']) + + +class TestPipelineState(unittest.TestCase): + """Pipeline action state must be captured and survive a JSON round-trip. + + Each Data-menu action (Mask, Filter, Resample, Bin data, Remove Outliers) + has a data dict with at least 'active'. The view serialisation should + preserve all keys and values faithfully. + """ + + # -- helpers that mirror the dicts returned by each plugin's _DEFAULT_DICT -- + + def _mask_data(self, active=True, maskString='{Time}>0'): + return {'active': active, 'maskString': maskString, 'formattedMaskString': ''} + + def _filter_data(self, active=True, name='Moving average', param=100): + return {'active': active, 'name': name, 'param': param, + 'paramName': 'Window Size', 'paramRange': [1, 100000]} + + def _resample_data(self, active=True, name='Every n', param=2): + return {'active': active, 'name': name, 'param': param, 'paramName': 'n'} + + def _binning_data(self, active=True, nBins=50, xMin=None, xMax=None): + return {'active': active, 'nBins': nBins, 'xMin': xMin, 'xMax': xMax} + + def _remove_outliers_data(self, active=True, medianDeviation=5): + return {'active': active, 'medianDeviation': medianDeviation} + + # -- structure tests -- + + def test_pipeline_state_keys(self): + """Each action dict must have at least an 'active' key.""" + for d in [self._mask_data(), self._filter_data(), + self._resample_data(), self._binning_data(), + self._remove_outliers_data()]: + self.assertIn('active', d) + + def test_mask_state_keys(self): + d = self._mask_data() + for k in ('active', 'maskString', 'formattedMaskString'): + self.assertIn(k, d) + + def test_filter_state_keys(self): + d = self._filter_data() + for k in ('active', 'name', 'param', 'paramName', 'paramRange'): + self.assertIn(k, d) + + def test_resample_state_keys(self): + d = self._resample_data() + for k in ('active', 'name', 'param'): + self.assertIn(k, d) + + def test_binning_state_keys(self): + d = self._binning_data() + for k in ('active', 'nBins'): + self.assertIn(k, d) + + def test_remove_outliers_state_keys(self): + d = self._remove_outliers_data() + for k in ('active', 'medianDeviation'): + self.assertIn(k, d) + + # -- round-trip tests -- + + def _make_view_with_pipeline(self, pipeline): + return { + 'version': 1, + 'name': 'pipe_test', + 'files': [], + 'pipeline': pipeline, + 'plotPanel': {}, + 'selection': {}, + } + + def test_pipeline_json_round_trip(self): + """Full pipeline state must survive a JSON round-trip.""" + pipeline = { + 'Mask': self._mask_data(active=True, maskString='{Speed}>0'), + 'Filter': self._filter_data(active=True, name='Moving average', param=50), + 'Resample': self._resample_data(active=False), + 'Remove Outliers': self._remove_outliers_data(active=True, medianDeviation=3), + } + with tempfile.TemporaryDirectory() as tmpdir: + view_path = os.path.join(tmpdir, 'v.pdvview') + with open(view_path, 'w') as f: + json.dump(self._make_view_with_pipeline(pipeline), f) + with open(view_path, 'r') as f: + loaded = json.load(f) + pl = loaded['pipeline'] + self.assertEqual(pl['Mask']['maskString'], '{Speed}>0') + self.assertTrue(pl['Mask']['active']) + self.assertEqual(pl['Filter']['param'], 50) + self.assertFalse(pl['Resample']['active']) + self.assertEqual(pl['Remove Outliers']['medianDeviation'], 3) + + def test_missing_pipeline_key_defaults_to_empty(self): + """Old views without a 'pipeline' key must not crash on restore.""" + view = {'name': 'old', 'modeIndex': 0, 'selection': {}, 'plotPanel': {}} + pl = view.get('pipeline', {}) + self.assertEqual(pl, {}) + + def test_inactive_actions_are_present_but_flagged(self): + """Even inactive actions are serialisable; the 'active' flag is the signal.""" + d = self._filter_data(active=False, param=200) + self.assertFalse(d['active']) + self.assertEqual(d['param'], 200) + + def test_binning_with_limits_round_trip(self): + """Binning xMin/xMax must survive serialisation (including None values).""" + pipeline = {'Bin data': self._binning_data(active=True, nBins=20, xMin=0.0, xMax=100.0)} + with tempfile.TemporaryDirectory() as tmpdir: + view_path = os.path.join(tmpdir, 'v.pdvview') + with open(view_path, 'w') as f: + json.dump(self._make_view_with_pipeline(pipeline), f) + with open(view_path, 'r') as f: + loaded = json.load(f) + bd = loaded['pipeline']['Bin data'] + self.assertEqual(bd['nBins'], 20) + self.assertAlmostEqual(bd['xMin'], 0.0) + self.assertAlmostEqual(bd['xMax'], 100.0) + + +class TestLoaderOptionsInView(unittest.TestCase): + """R9: loaderOptions must be present in both in-memory and file views.""" + + def test_in_memory_view_has_loader_options(self): + """In-memory view must include loaderOptions so dayfirst survives restore.""" + view = { + 'name': 'myview', + 'modeIndex': 0, + 'selection': {}, + 'plotPanel': {}, + 'loaderOptions': {'dayfirst': True, 'naming': 'Ellude'}, + } + self.assertIn('loaderOptions', view) + self.assertTrue(view['loaderOptions']['dayfirst']) + + def test_file_view_loader_options_round_trip(self): + """loaderOptions must survive a JSON round-trip in an exported view.""" + with tempfile.TemporaryDirectory() as tmpdir: + view_path = os.path.join(tmpdir, 'v.pdvview') + view_data = { + 'version': 1, + 'name': 'test', + 'files': [], + 'loaderOptions': {'dayfirst': True, 'naming': 'Ellude'}, + 'modeIndex': 0, + 'selection': {}, + 'plotPanel': {}, + } + with open(view_path, 'w') as f: + json.dump(view_data, f) + with open(view_path, 'r') as f: + loaded = json.load(f) + self.assertEqual(loaded['loaderOptions']['dayfirst'], True) + self.assertEqual(loaded['loaderOptions']['naming'], 'Ellude') + + def test_missing_loader_options_defaults_gracefully(self): + """A view without loaderOptions must not crash on restore.""" + view = {'name': 'old', 'modeIndex': 0, 'selection': {}, 'plotPanel': {}} + opts = view.get('loaderOptions', {}) + self.assertEqual(opts, {}) + + +if __name__ == '__main__': + unittest.main() From 7fe2d1233a0d26a5ebd98951e6108ef4dc47f19b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Mar 2026 11:25:55 +0000 Subject: [PATCH 11/41] 3D view: rotate button, pan mode, plane controls, axis constraints, colorbar Replaces CTRL-rotate with a dedicated Rotate toggle button in the toolbar. Adds orthographic plane-reset, Home button, and 3D left-click pan mode matching 2D behaviour. Adds x/y/z axis constraint support. Includes 6 UI improvements: view menu submenus, surf/scatter choice, single shared colorbar per axis, plane reset, and Scatter as default 3D type. Improves control- panel layout and error robustness throughout. https://claude.ai/code/session_01RK7XoUAyBGq81RqNdX7htm --- pydatview/GUIPlotPanel.py | 574 +++++++++++++++++++++++++++++---- pydatview/GUISelectionPanel.py | 138 ++++++-- pydatview/GUIToolBox.py | 115 +++++-- pydatview/appdata.py | 2 + pydatview/main.py | 231 ++++++++++--- 5 files changed, 896 insertions(+), 164 deletions(-) diff --git a/pydatview/GUIPlotPanel.py b/pydatview/GUIPlotPanel.py index 45177aa..0f15985 100644 --- a/pydatview/GUIPlotPanel.py +++ b/pydatview/GUIPlotPanel.py @@ -57,24 +57,154 @@ 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 free for zoom/pan. +def _patch_3d_ctrl_rotate(ax, canvas, toolbar=None): + """Control 3D rotation/pan via the toolbar Rotate and Pan buttons. - 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. + Rotate mode (toolbar.rotate_on): + Left-drag rotates the 3D view (default Axes3D behaviour). + + Pan mode (toolbar.pan_on): + Left-drag pans the 3D view — matching the 2D toolbar behaviour. + Right-drag zooms (Axes3D built-in button=3 behaviour). + Hold 'x', 'y', or 'z' while dragging to constrain pan to that axis. + + Neither active: + All mouse-drag suppressed (no accidental rotation/pan). + + Two-layer approach for robustness across matplotlib versions: + + Layer 0 – Axes3D._button_press wrapper: + When pan mode is active and user presses left button, we override + ax.button_pressed from 1 (rotate) to 2 (pan) so that the existing + Axes3D._on_move handler produces a pan rather than a rotation. + + Layer 1 – ax.drag_pan instance-level patch: + NavigationToolbar2 calls ax.drag_pan(button, key, x, y) during + toolbar-active drag. Our wrapper allows rotation only when rotate + mode is ON, and suppresses it in all other cases. + + Layer 2 – Axes3D._on_move canvas-callback wrapper: + Wraps the motion handler so that: + • rotate mode → call original (rotation happens normally) + • pan mode → call original with axis-constraint support + • neither mode → suppress """ - def _on_3d_press(event): - if event.inaxes != ax or event.button != 1: - return - if not wx.GetKeyState(wx.WXK_CONTROL): - try: - ax.button_pressed = None - except Exception: - pass + def _rotate_active(): + return getattr(toolbar, 'rotate_on', False) - canvas.mpl_connect('button_press_event', _on_3d_press) + def _pan_active(): + return getattr(toolbar, 'pan_on', False) + + # --- Layer 0: wrap Axes3D._button_press --- + # Redirect left-click to pan (button_pressed=2) when pan mode is active. + press_cbs = getattr(canvas.callbacks, 'callbacks', {}).get('button_press_event', {}) + for cid, val in list(press_cbs.items()): + try: + func = val() + except TypeError: + func = val + if func is None: + continue + if getattr(func, '__self__', None) is ax: + canvas.mpl_disconnect(cid) + def _wrapped_press(event, _orig=func): + _orig(event) # Axes3D sets ax.button_pressed = event.button + if event.inaxes == ax and event.button == 1 and _pan_active(): + try: + ax.button_pressed = 2 # left-click → pan in Axes3D + except Exception: + pass + canvas.mpl_connect('button_press_event', _wrapped_press) + break + + # --- Layer 2: wrap Axes3D._on_move in the callback registry --- + wrapped = [False] + motion_cbs = getattr(canvas.callbacks, 'callbacks', {}).get('motion_notify_event', {}) + for cid, val in list(motion_cbs.items()): + try: + func = val() # WeakMethod / weakref.ref + except TypeError: + func = val # direct callable + if func is None: + continue + if getattr(func, '__self__', None) is ax: + canvas.mpl_disconnect(cid) + def _wrapped_move(event, _orig=func): + if event.inaxes != ax: + _orig(event) + return + if _rotate_active(): + # Rotate mode: allow normal Axes3D rotation + _orig(event) + elif _pan_active(): + # Pan mode: Axes3D pans because button_pressed was set to 2 + # in _wrapped_press. Support x/y/z key axis constraints. + key = getattr(event, 'key', None) + if key in ('x', 'y', 'z'): + try: + xlim = ax.get_xlim3d() + ylim = ax.get_ylim3d() + zlim = ax.get_zlim3d() + _orig(event) + if key == 'x': + ax.set_ylim3d(ylim); ax.set_zlim3d(zlim) + elif key == 'y': + ax.set_xlim3d(xlim); ax.set_zlim3d(zlim) + elif key == 'z': + ax.set_xlim3d(xlim); ax.set_ylim3d(ylim) + except Exception: + _orig(event) + else: + _orig(event) + # else: neither rotate nor pan — suppress all drag + canvas.mpl_connect('motion_notify_event', _wrapped_move) + wrapped[0] = True + break + + if not wrapped[0]: + # Fallback for versions where _on_move isn't in canvas callbacks. + # In pan mode redirect left-click to button=2; otherwise clear button. + def _on_press(event): + if event.inaxes != ax or event.button != 1: + return + if _pan_active(): + try: + ax.button_pressed = 2 + except Exception: + pass + elif not _rotate_active(): + for attr in ('button_pressed', '_button_pressed'): + try: + setattr(ax, attr, None) + except Exception: + pass + canvas.mpl_connect('button_press_event', _on_press) + + # --- Layer 1: patch ax.drag_pan at instance level --- + # ax.__class__.drag_pan is the unbound function (Python 3). + # Setting ax.drag_pan = our_func makes Python call our_func(button,key,x,y) + # when the toolbar does a.drag_pan(...), bypassing the class method. + try: + _orig_drag_pan = ax.__class__.drag_pan + except AttributeError: + _orig_drag_pan = None + + if _orig_drag_pan is not None: + def _patched_drag_pan(button, key, x, y, + _orig=_orig_drag_pan, _w=wrapped): + if button == 1: + # Left-click: rotate only when rotate mode ON and _on_move absent + if _rotate_active() and not _w[0]: + _orig(ax, button, key, x, y) + # Pan mode: pan driven by _on_move with button_pressed=2 + elif button == 3: + # Right-click zoom via drag_pan: only when no mode is active + # (in pan mode, zoom is driven by Axes3D._on_move with button=3) + if not _rotate_active() and not _pan_active(): + _orig(ax, button, key, x, y) + else: + _orig(ax, button, key, x, y) + ax.drag_pan = _patched_drag_pan class PDFCtrlPanel(wx.Panel): @@ -243,52 +373,131 @@ def __init__(self, parent): 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 = 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.btFree = wx.Button(self, -1, 'Free', style=wx.BU_EXACTFIT) + self.cbPlot3D = wx.ComboBox(self, choices=['Scatter', 'Surf'], style=wx.CB_READONLY, size=(75, -1)) + self.cbPlot3D.SetSelection(0) + self.cb3D.SetToolTip("Enable 3D scatter/surface plot (requires a Z variable)") + self.btXY.SetToolTip("View from above: x-y plane (z hidden)") + self.btYZ.SetToolTip("View from the side: y-z plane (x hidden)") + self.btXZ.SetToolTip("View from the front: x-z plane (y hidden)") + self.btFree.SetToolTip("Reset to free perspective 3D view") + self.cbPlot3D.SetToolTip("3D plot type: Scatter = point cloud; Surf = surface") self.btXY.Hide() self.btYZ.Hide() self.btXZ.Hide() + self.btFree.Hide() + self.cbPlot3D.Hide() dummy_sizer = wx.BoxSizer(wx.HORIZONTAL) - 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) + 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) + dummy_sizer.Add(self.btFree , 0, flag=wx.CENTER|wx.LEFT, border=4) + dummy_sizer.Add(self.cbPlot3D, 0, flag=wx.CENTER|wx.LEFT, border=8) self.SetSizer(dummy_sizer) - 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.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.Bind(wx.EVT_BUTTON, self.onViewFree, self.btFree) + self.Bind(wx.EVT_COMBOBOX, self.on3DTypeChange, self.cbPlot3D) + # Pending camera state applied after set_subplots recreates 3D axes + self._pending_elev = None + self._pending_azim = None + self._pending_hide = None # 'x', 'y', 'z', or None self.Hide() - def on3DChange(self, event=None): + def _update3DButtons(self): + """Show/hide plane buttons – does NOT redraw. cbPlot3D replaced by cbCurveType.""" is3D = self.cb3D.IsChecked() self.btXY.Show(is3D) self.btYZ.Show(is3D) self.btXZ.Show(is3D) + self.btFree.Show(is3D) + self.cbPlot3D.Hide() # cbCurveType (in ctrlPanel) now serves this role self.GetSizer().Layout() - self.parent.load_and_draw() - def _setView(self, elev, azim): + def on3DChange(self, event=None): + self.parent.set3DMode(self.cb3D.IsChecked()) + + @staticmethod + def _apply_3d_plane(ax, elev, azim, hide_axis): + """Set camera, projection type and axis visibility on one 3D axes.""" + ax.view_init(elev=elev, azim=azim) + if hide_axis: + try: + ax.set_proj_type('ortho') + except Exception: + pass + for name in ('x', 'y', 'z'): + axis_obj = getattr(ax, '{}axis'.format(name), None) + if axis_obj is None: + continue + visible = (name != hide_axis) + axis_obj.set_visible(visible) + try: + axis_obj.pane.set_visible(visible) + axis_obj.pane.fill = visible + except Exception: + pass + else: + try: + ax.set_proj_type('persp') + except Exception: + pass + for name in ('x', 'y', 'z'): + axis_obj = getattr(ax, '{}axis'.format(name), None) + if axis_obj is None: + continue + axis_obj.set_visible(True) + try: + axis_obj.pane.set_visible(True) + axis_obj.pane.fill = True + except Exception: + pass + + def _setView(self, elev, azim, hide_axis=None): + self._pending_elev = elev + self._pending_azim = azim + self._pending_hide = hide_axis for ax in self.parent.fig.axes: if hasattr(ax, 'view_init'): - ax.view_init(elev=elev, azim=azim) + self._apply_3d_plane(ax, elev, azim, hide_axis) self.parent.canvas.draw() def onViewXY(self, event=None): - self._setView(elev=90, azim=-90) + self._setView(elev=90, azim=-90, hide_axis='z') def onViewYZ(self, event=None): - self._setView(elev=0, azim=0) + self._setView(elev=0, azim=0, hide_axis='x') def onViewXZ(self, event=None): - self._setView(elev=0, azim=-90) + self._setView(elev=0, azim=-90, hide_axis='y') + + def on3DTypeChange(self, event=None): + """Redraw when 3D plot type (Scatter/Surf) changes.""" + self.parent.load_and_draw() + + def onViewFree(self, event=None): + """Reset camera angles to free perspective without rescaling axis ranges.""" + self._setView(elev=30, azim=-60, hide_axis=None) def _GUI2Data(self): + # plot3DType is now driven by the unified cbCurveType in the parent PlotPanel + try: + plot3D_type = self.parent.cbCurveType.GetValue() + if plot3D_type not in ('Scatter', 'Surf'): + plot3D_type = 'Scatter' + except Exception: + plot3D_type = self.cbPlot3D.GetValue() return { - 'colormap': self.COLORMAP, - 'colorbar': True, - 'view3D': self.cb3D.IsChecked(), + 'colormap': self.COLORMAP, + 'colorbar': True, + 'view3D': self.cb3D.IsChecked(), + 'plot3DType': plot3D_type, } @@ -516,6 +725,7 @@ def __init__(self, parent, data): except ValueError: i = 2 self.cbFont.SetSelection(i) + self.cbFont.SetToolTip("Axis tick and label font size") # Legend # NOTE: we don't offer "best" since best is slow lbLegend = wx.StaticText( self, -1, 'Legend:') @@ -526,6 +736,7 @@ def __init__(self, parent, data): except ValueError: i=1 self.cbLegend.SetSelection(i) + self.cbLegend.SetToolTip("Position of the plot legend") # Legend Font lbLgdFont = wx.StaticText( self, -1, 'Legend font:') self.cbLgdFont = wx.ComboBox(self, choices=fontChoices, style=wx.CB_READONLY) @@ -534,6 +745,7 @@ def __init__(self, parent, data): except ValueError: i = 2 self.cbLgdFont.SetSelection(i) + self.cbLgdFont.SetToolTip("Font size for legend text") # Line Width Font lbLW = wx.StaticText( self, -1, 'Line width:') LWChoices = ['0.5','1.0','1.25','1.5','1.75','2.0','2.5','3.0'] @@ -543,6 +755,7 @@ def __init__(self, parent, data): except ValueError: i = 3 self.cbLW.SetSelection(i) + self.cbLW.SetToolTip("Width of plot lines in points") # Marker Size lbMS = wx.StaticText( self, -1, 'Marker size:') MSChoices = ['0.5','1','2','3','4','5','6','7','8'] @@ -552,6 +765,7 @@ def __init__(self, parent, data): except ValueError: i = 2 self.cbMS.SetSelection(i) + self.cbMS.SetToolTip("Size of data point markers") # Layout #dummy_sizer = wx.BoxSizer(wx.HORIZONTAL) @@ -709,6 +923,25 @@ def __init__(self, parent, selPanel, pipeLike=None, infoPanel=None, data=None): self.cbSwapXY = wx.CheckBox(self.ctrlPanel, -1, 'Swap XY',(10,10)) self.cbFlipX = wx.CheckBox(self.ctrlPanel, -1, 'Flip X',(10,10)) self.cbFlipY = wx.CheckBox(self.ctrlPanel, -1, 'Flip Y',(10,10)) + # Save default combo selections for 2D/3D switching + self._2d_curve_type_sel = 1 # 'LS' + self._3d_curve_type_sel = 0 # 'Scatter' + # Tooltips + self.cbCurveType.SetToolTip("Line style: Plain = solid lines; LS = varied dashes; Markers = symbols; Mix = both") + self.cbSub.SetToolTip("Split each y-variable into its own subplot") + self.cbLogX.SetToolTip("Use logarithmic scale on x-axis") + self.cbLogY.SetToolTip("Use logarithmic scale on y-axis") + self.cbSync.SetToolTip("Synchronise x-axis limits across all subplots") + self.cbXHair.SetToolTip("Show a crosshair cursor on the plot") + self.cbPlotMatrix.SetToolTip("Show a scatter-plot matrix of all selected columns") + self.cbAutoScale.SetToolTip("Rescale axes on every redraw") + self.cbGrid.SetToolTip("Show grid lines on the plot") + self.cbStepPlot.SetToolTip("Draw data as a step/staircase plot") + self.cbMeasure.SetToolTip("Enable measurement mode: click two points to measure distance") + self.cbMarkPt.SetToolTip("Mark individual data points with markers") + self.cbSwapXY.SetToolTip("Swap the x and y axes") + self.cbFlipX.SetToolTip("Flip (reverse) the x-axis direction") + self.cbFlipY.SetToolTip("Flip (reverse) the y-axis direction") #self.cbSub.SetValue(True) # DEFAULT TO SUB? self.cbSync.SetValue(True) self.cbXHair.SetValue(self.data['CrossHair']) # Have cross hair by default @@ -748,21 +981,42 @@ def __init__(self, parent, selPanel, pipeLike=None, infoPanel=None, data=None): cb_sizer.Add(self.cbFlipX , 0, flag=wx.ALL, border=1) cb_sizer.Add(self.cbFlipY , 0, flag=wx.ALL, border=1) - self.ctrlPanel.SetSizer(cb_sizer) + # --- 3D-specific ctrl panel (shown only in 3D mode, RIGHT NEXT to the other checkboxes) + self.ctrl3DPanel = wx.Panel(self.ctrlPanel) + self.cbLogZ = wx.CheckBox(self.ctrl3DPanel, -1, 'Log-z', (10, 10)) + self.cbFlipZ = wx.CheckBox(self.ctrl3DPanel, -1, 'Flip Z', (10, 10)) + self.cbLogZ.SetToolTip("Use logarithmic scale on z-axis") + self.cbFlipZ.SetToolTip("Flip (reverse) the z-axis direction") + self.Bind(wx.EVT_CHECKBOX, self.redraw_event, self.cbLogZ) + self.Bind(wx.EVT_CHECKBOX, self.redraw_event, self.cbFlipZ) + sizer3D = wx.FlexGridSizer(rows=5, cols=1, hgap=0, vgap=0) + sizer3D.Add(self.cbLogZ, 0, flag=wx.ALL, border=1) + sizer3D.Add(self.cbFlipZ, 0, flag=wx.ALL, border=1) + self.ctrl3DPanel.SetSizer(sizer3D) + self.ctrl3DPanel.Hide() + # Wrap cb_sizer and ctrl3DPanel in a horizontal sizer inside ctrlPanel + ctrlHSizer = wx.BoxSizer(wx.HORIZONTAL) + ctrlHSizer.Add(cb_sizer, 0, flag=wx.ALL, border=0) + ctrlHSizer.Add(self.ctrl3DPanel, 0, flag=wx.LEFT|wx.EXPAND, border=4) + self.ctrlPanel.SetSizer(ctrlHSizer) # --- Crosshair Panel crossHairPanel= wx.Panel(self) self.lbCrossHairX = wx.StaticText(crossHairPanel, -1, 'x = ... ') self.lbCrossHairY = wx.StaticText(crossHairPanel, -1, 'y = ... ') + self.lbCrossHairZ = wx.StaticText(crossHairPanel, -1, 'z = ... ') self.lbDeltaX = wx.StaticText(crossHairPanel, -1, ' ') self.lbDeltaY = wx.StaticText(crossHairPanel, -1, ' ') self.lbCrossHairX.SetFont(getMonoFont(self)) self.lbCrossHairY.SetFont(getMonoFont(self)) + self.lbCrossHairZ.SetFont(getMonoFont(self)) self.lbDeltaX.SetFont(getMonoFont(self)) self.lbDeltaY.SetFont(getMonoFont(self)) - cbCH = wx.FlexGridSizer(rows=4, cols=1, hgap=0, vgap=0) + self.lbCrossHairZ.Hide() # shown only in 3D mode + cbCH = wx.FlexGridSizer(rows=5, cols=1, hgap=0, vgap=0) cbCH.Add(self.lbCrossHairX , 0, flag=wx.ALL, border=1) cbCH.Add(self.lbCrossHairY , 0, flag=wx.ALL, border=1) + cbCH.Add(self.lbCrossHairZ , 0, flag=wx.ALL, border=1) cbCH.Add(self.lbDeltaX , 0, flag=wx.ALL, border=1) cbCH.Add(self.lbDeltaY , 0, flag=wx.ALL, border=1) crossHairPanel.SetSizer(cbCH) @@ -815,6 +1069,37 @@ def addTables(self, *args, **kwargs): print('[WARN] callback to add tables to parent was not set. (call setAddTablesCallback)') + def set3DMode(self, is3D, redraw=True): + """Switch cbCurveType between 2D/3D choices, show/hide 3D-only controls.""" + # Temporarily unbind EVT_COMBOBOX to prevent spurious redraw on Windows + # (ComboBox.Set() can fire selection-change events on some platforms) + self.Unbind(wx.EVT_COMBOBOX, source=self.cbCurveType) + try: + if is3D: + self._2d_curve_type_sel = self.cbCurveType.GetSelection() + self.cbCurveType.Set(['Scatter', 'Surf']) + self.cbCurveType.SetSelection(max(0, min(self._3d_curve_type_sel, 1))) + self.cbCurveType.SetToolTip("3D plot type: Scatter = point cloud; Surf = triangulated surface") + else: + self._3d_curve_type_sel = self.cbCurveType.GetSelection() + self.cbCurveType.Set(['Plain', 'LS', 'Markers', 'Mix']) + self.cbCurveType.SetSelection(self._2d_curve_type_sel) + self.cbCurveType.SetToolTip("Line style: Plain = solid lines; LS = varied dashes; Markers = symbols; Mix = both") + finally: + self.Bind(wx.EVT_COMBOBOX, self.redraw_event, self.cbCurveType) + self.ctrl3DPanel.Show(is3D) + self.lbCrossHairZ.Show(is3D) + self.colorPanel._update3DButtons() + if hasattr(self, 'navTBTop'): + try: + self.navTBTop.set3DMode(is3D) + except Exception: + pass + self.ctrlPanel.Layout() + self.plotsizer.Layout() + if redraw: + self.load_and_draw() + # --- GUI DATA def saveData(self, data): data['Grid'] = self.cbGrid.IsChecked() @@ -834,7 +1119,9 @@ def captureViewData(self): data['sync'] = self.cbSync.IsChecked() data['autoScale'] = self.cbAutoScale.IsChecked() data['stepPlot'] = self.cbStepPlot.IsChecked() - data['curveType'] = self.cbCurveType.GetSelection() + data['curveType'] = self.cbCurveType.GetValue() # string e.g. 'LS' or 'Scatter' + data['logZ'] = self.cbLogZ.IsChecked() if hasattr(self, 'cbLogZ') else False + data['flipZ'] = self.cbFlipZ.IsChecked() if hasattr(self, 'cbFlipZ') else False data['plotStyle'] = { 'Font': self.esthPanel.cbFont.GetValue(), 'LegendFont': self.esthPanel.cbLgdFont.GetValue(), @@ -842,7 +1129,18 @@ def captureViewData(self): 'LineWidth': self.esthPanel.cbLW.GetValue(), 'MarkerSize': self.esthPanel.cbMS.GetValue(), } - data['view3D'] = self.colorPanel.cb3D.IsChecked() + data['view3D'] = self.colorPanel.cb3D.IsChecked() + data['plot3D_type'] = self.colorPanel.cbPlot3D.GetValue() + # Camera angle for 3D view + elev, azim = None, None + for ax in self.fig.axes: + if hasattr(ax, 'elev'): + elev = ax.elev + azim = ax.azim + break + data['view3D_elev'] = elev + data['view3D_azim'] = azim + data['view3D_hide'] = self.colorPanel._pending_hide # R1 – Spectral / FFT panel spc = self.spcPanel._GUI2Data() spc['xlim'] = self.spcPanel.tMaxFreq.GetValue() @@ -884,7 +1182,6 @@ def restoreViewData(self, data): self.cbSync.SetValue(data.get('sync', True)) self.cbAutoScale.SetValue(data.get('autoScale', True)) self.cbStepPlot.SetValue(data.get('stepPlot', False)) - self.cbCurveType.SetSelection(data.get('curveType', 1)) # R6-R8 – axis toggles and matrix self.cbSwapXY.SetValue(data.get('swapXY', False)) self.cbFlipX.SetValue(data.get('flipX', False)) @@ -921,7 +1218,37 @@ def restoreViewData(self, data): matplotlib_rc('font', **{'size': int(plotStyle.get('Font', '11'))}) except Exception: pass - self.colorPanel.cb3D.SetValue(data.get('view3D', False)) + # Restore 3D mode first (switches cbCurveType items, shows/hides ctrl3DPanel) + view3D = data.get('view3D', False) + self.colorPanel.cb3D.SetValue(view3D) + self.set3DMode(view3D, redraw=False) + # Restore cbCurveType value (handles both old integer index and new string format) + curveType = data.get('curveType', None) + if view3D: + _choices = ['Scatter', 'Surf'] + if isinstance(curveType, str) and curveType in _choices: + self.cbCurveType.SetSelection(_choices.index(curveType)) + elif data.get('plot3D_type') in _choices: + self.cbCurveType.SetSelection(_choices.index(data['plot3D_type'])) + else: + self.cbCurveType.SetSelection(0) + else: + _choices = ['Plain', 'LS', 'Markers', 'Mix'] + if isinstance(curveType, str) and curveType in _choices: + self.cbCurveType.SetSelection(_choices.index(curveType)) + elif isinstance(curveType, int): + self.cbCurveType.SetSelection(min(curveType, len(_choices) - 1)) + else: + self.cbCurveType.SetSelection(1) # default LS + # Restore 3D extra options + if hasattr(self, 'cbLogZ'): + self.cbLogZ.SetValue(data.get('logZ', False)) + if hasattr(self, 'cbFlipZ'): + self.cbFlipZ.SetValue(data.get('flipZ', False)) + # Store pending camera state so set_subplots applies it after redraw + self.colorPanel._pending_elev = data.get('view3D_elev') + self.colorPanel._pending_azim = data.get('view3D_azim') + self.colorPanel._pending_hide = data.get('view3D_hide') # R1 – Spectral / FFT panel spc = data.get('spectral', {}) if spc: @@ -1218,7 +1545,19 @@ 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) + _patch_3d_ctrl_rotate(ax, self.canvas, toolbar=self.navTBTop) + # Restore pending camera angle (set by plane buttons or view restore) + cp = self.colorPanel + elev = cp._pending_elev + azim = cp._pending_azim + hide = cp._pending_hide + if elev is not None or azim is not None or hide is not None: + ColorCtrlPanel._apply_3d_plane( + ax, + elev if elev is not None else ax.elev, + azim if azim is not None else ax.azim, + hide, + ) elif i==0: ax=self.fig.add_subplot(nPlots,1,i+1) # Store first axis to share with other @@ -1235,6 +1574,22 @@ def set_subplots(self,nPlots): def onMouseMove(self, event): if event.inaxes and len(self.plotData)>0: x, y = event.xdata, event.ydata + ax = event.inaxes + if hasattr(ax, 'view_init'): # 3D axes + try: + coord_str = ax.format_coord(x, y) + # format_coord returns e.g. "x=1.23, y=4.56, z=7.89" + parts = {} + for part in coord_str.replace(', ', ',').split(','): + if '=' in part: + k, v = part.split('=', 1) + parts[k.strip()] = v.strip() + self.lbCrossHairX.SetLabel('x = ' + parts.get('x', '...')) + self.lbCrossHairY.SetLabel('y = ' + parts.get('y', '...')) + self.lbCrossHairZ.SetLabel('z = ' + parts.get('z', '...')) + except Exception: + pass + return self.lbCrossHairX.SetLabel('x =' + self.formatLabelValue(x,self.plotData[0].xIsDate)) self.lbCrossHairY.SetLabel('y =' + self.formatLabelValue(y,self.plotData[0].yIsDate)) @@ -1610,6 +1965,8 @@ def getPlotOptions(self, PD=None): plot_options['flipY'] = self.cbFlipY.IsChecked() plot_options['logX'] = self.cbLogX.IsChecked() plot_options['logY'] = self.cbLogY.IsChecked() + plot_options['logZ'] = self.cbLogZ.IsChecked() if hasattr(self, 'cbLogZ') else False + plot_options['flipZ'] = self.cbFlipZ.IsChecked() if hasattr(self, 'cbFlipZ') else False if self.cbGrid.IsChecked(): plot_options['grid'] = {'visible': self.cbGrid.IsChecked(), 'linestyle':'-', 'linewidth':0.5, 'color':'#b0b0b0'} else: @@ -1632,9 +1989,9 @@ def getPlotOptions(self, PD=None): plot_options['LineStyles'] = ['-','--', '-','-','-'] plot_options['Markers'] = ['' ,'' ,'o','^','s'] else: - # Combination of linestyles markers, colors, etc. - # But at that stage, if the user really want this, then we can implement an option to set styles per plot. Not high priority. - raise Exception('Not implemented') + # 3D mode ('Scatter'/'Surf') or unknown – use plain defaults + plot_options['LineStyles'] = ['-'] + plot_options['Markers'] = [''] # --- Font options font_options = dict() @@ -1689,23 +2046,32 @@ def plot_all(self, autoscale=True): ax_right, bAllNegRight = self.plotSignals(ax_left, axis_idx, PD, pm, 2, plot_options) # Log Axes - if plot_options['logX']: + if plot_options['logX'] and not hasattr(ax_left, 'set_zlim'): try: ax_left.set_xscale("log", nonpositive='clip') # latest - except: - ax_left.set_xscale("log", nonposx='clip') # legacy + except Exception: + try: + ax_left.set_xscale("log", nonposx='clip') # legacy + except Exception: + pass - if plot_options['logY']: + if plot_options['logY'] and not hasattr(ax_left, 'set_zlim'): if bAllNegLeft is False: try: ax_left.set_yscale("log", nonpositive='clip') # latest - except: - ax_left.set_yscale("log", nonposy='clip') + except Exception: + try: + ax_left.set_yscale("log", nonposy='clip') + except Exception: + pass if bAllNegRight is False and ax_right is not None: try: ax_right.set_yscale("log", nonpositive='clip') # latest - except: - ax_left.set_yscale("log", nonposy='clip') # legacy + except Exception: + try: + ax_left.set_yscale("log", nonposy='clip') # legacy + except Exception: + pass if not autoscale: # We force the limits to be the same as before @@ -1854,11 +2220,50 @@ def plotSignals(self, ax, axis_idx, PD, pm, left_right, opts): # Gather color-panel options once colorOpts = self.colorPanel._GUI2Data() - colormap = colorOpts['colormap'] + colormap = colorOpts['colormap'] showColorBar = colorOpts['colorbar'] - use3D = colorOpts['view3D'] - - iPlot=-1 + use3D = colorOpts['view3D'] + plot3DType = colorOpts.get('plot3DType', 'Scatter') + logZ = opts.get('logZ', False) + flipZ = opts.get('flipZ', False) + + # --- Pre-compute shared Z normalisation across all Z-coloured signals on this axis + # This ensures consistent colours and a single correct colorbar for all signals. + z_norm = None + z_label_combined = '' + _z_signals_pd = [] # PlotData objects that will be plotted with Z coloring + for signal_idx in loop_range: + will_plot = ( + (left_right == 1 and (pm is None or pm[signal_idx][axis_idx] == left_right)) or + (left_right == 2 and pm is not None and pm[signal_idx][axis_idx] == left_right) + ) + if will_plot: + pd_i = PD[signal_idx] + if pd_i.z is not None and not pd_i.zIsString: + _z_signals_pd.append(pd_i) + + if _z_signals_pd and showColorBar: + import matplotlib.colors as mcolors + import matplotlib.cm as mcm + all_z_parts = [] + z_label_parts = [] + for pd_i in _z_signals_pd: + z_vals = np.asarray(pd_i.z, dtype=float) + if logZ and use3D: + with np.errstate(divide='ignore', invalid='ignore'): + z_vals = np.log10(z_vals) + all_z_parts.append(z_vals) + z_label_parts.append(pd_i.sz) + all_z = np.concatenate(all_z_parts) + finite_z = all_z[np.isfinite(all_z)] + if len(finite_z) > 0: + z_norm = mcolors.Normalize(vmin=np.min(finite_z), vmax=np.max(finite_z)) + z_label_combined = ' / '.join(unique(z_label_parts)) + if logZ and use3D: + z_label_combined = 'log\u2081\u2080(' + z_label_combined + ')' + + iPlot = -1 + _colorbar_added = False # show colorbar at most once per axis for signal_idx in loop_range: do_plot = False if left_right == 1 and (pm is None or pm[signal_idx][axis_idx] == left_right): @@ -1876,27 +2281,43 @@ def plotSignals(self, ax, axis_idx, PD, pm, left_right, opts): iPlot+=1 hasZ = pd.z is not None and not pd.zIsString if hasZ and use3D: - # 3D scatter: x, y, z as spatial axes + # 3D plot: x, y, z as spatial axes + # Apply log-z via data transformation (Axes3D does not support set_zscale) + z_plot = np.asarray(pd.z, dtype=float) + if logZ: + with np.errstate(divide='ignore', invalid='ignore'): + z_plot = np.log10(z_plot) + if flipZ: + try: + axis.invert_zaxis() + except Exception: + pass 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) + if plot3DType == 'Surf': + sc = axis.plot_trisurf(pd.x, pd.y, z_plot, cmap=colormap, alpha=0.85, + norm=z_norm) + else: + sc = axis.scatter(pd.x, pd.y, z_plot, label=pd.syl, + s=opts['ms']**2, cmap=colormap, c=z_plot, norm=z_norm) except Exception: - axis.scatter(pd.x, pd.y, pd.z, label=pd.syl, s=opts['ms']**2) + try: + axis.scatter(pd.x, pd.y, z_plot, label=pd.syl, s=opts['ms']**2) + except Exception: + pass try: bAllNeg = bAllNeg and all(pd.y<=0) except Exception: pass elif hasZ: - # 2D scatter colored by Z variable + # 2D scatter coloured by Z variable – use shared norm for consistent colours 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) + label=pd.syl, s=opts['ms']**2, norm=z_norm) except Exception: - axis.scatter(pd.x, pd.y, label=pd.syl, s=opts['ms']**2) + try: + axis.scatter(pd.x, pd.y, label=pd.syl, s=opts['ms']**2) + except Exception: + pass try: bAllNeg = bAllNeg and all(pd.y<=0) except Exception: @@ -1918,6 +2339,21 @@ def plotSignals(self, ax, axis_idx, PD, pm, left_right, opts): bAllNeg = bAllNeg and all(pd.y<=0) except: pass # Dates or strings + + # --- Single colorbar for all Z-coloured signals on this axis + if showColorBar and _z_signals_pd and not _colorbar_added: + try: + import matplotlib.cm as mcm, matplotlib.colors as mcolors + sm = mcm.ScalarMappable(norm=z_norm, cmap=colormap) + sm.set_array([]) + if use3D: + self.fig.colorbar(sm, ax=axis, label=z_label_combined, shrink=0.7, pad=0.1) + else: + self.fig.colorbar(sm, ax=axis, label=z_label_combined) + _colorbar_added = True + except Exception: + pass + return axis, bAllNeg def findPlotMode(self,PD): diff --git a/pydatview/GUISelectionPanel.py b/pydatview/GUISelectionPanel.py index ef3b0a6..8600344 100644 --- a/pydatview/GUISelectionPanel.py +++ b/pydatview/GUISelectionPanel.py @@ -1,4 +1,5 @@ import wx +import os import platform from pydatview.common import * from pydatview.GUICommon import * @@ -11,10 +12,45 @@ __all__ = ['ColumnPanel', 'TablePanel', 'SelectionPanel','SEL_MODES','SEL_MODES_ID','TablePopup','ColumnPopup'] -SEL_MODES = ['auto','Same tables' ,'Sim. tables' ,'2 tables','3 tables (exp.)' ] +SEL_MODES = ['Auto','Same columns' ,'Similar columns','2 tables','3 tables' ] SEL_MODES_ID = ['auto','sameColumnsMode','simColumnsMode','twoColumnsMode' ,'threeColumnsMode' ] MAX_X_COLUMNS=300 # Maximum number of columns used in combo box of the x-axis (for performance) + +def _tab_shortname(tab): + """Return a path-independent key for a table: basename[|sheetname]. + + This allows view files to be reused with the same data files located in a + different directory. Falls back to the full tab.name when no filename is + available (e.g. formula-derived tables). + """ + if not getattr(tab, 'filename', ''): + return tab.name + basename = os.path.splitext(os.path.basename(tab.filename))[0] + parts = tab.name.split('|') + try: + idx = parts.index(basename) + return '|'.join(parts[idx:]) + except ValueError: + return parts[-1] + + +def _find_tab_by_key(tab_list, key): + """Find a table matching *key* as shortname (new format) or full name (old). + + Tries shortname first so new-format view files work when data is in a + different directory. Falls back to an exact full-name match for backward + compatibility with view files saved before the shortname change. + """ + for t in tab_list: + if _tab_shortname(t) == key: + return t + for t in tab_list: + if t.name == key: + return t + return None + + def ireplace(text, old, new): """ Replace case insensitive """ try: @@ -721,6 +757,7 @@ def __init__(self, parent, selPanel): self.Filt2Full=None # Index of GUI columns in self.columns self.bShowID=False self.bReadOnly=False + self._filterDebounceTimer = None # --- GUI Toolbar tb = wx.ToolBar(self,wx.ID_ANY,style=wx.TB_HORIZONTAL|wx.TB_TEXT|wx.TB_HORZ_LAYOUT|wx.TB_NODIVIDER) self.bt=wx.Button(tb,wx.ID_ANY,CHAR['menu'], style=wx.BU_EXACTFIT) @@ -739,6 +776,7 @@ def __init__(self, parent, selPanel): self.Bind(wx.EVT_BUTTON , self.onFilterChange, self.btFilter) self.tFilter.Bind(wx.EVT_KEY_DOWN, self.onFilterKey , self.tFilter ) self.Bind(wx.EVT_TEXT_ENTER, self.onFilterChange, self.tFilter ) + self.tFilter.Bind(wx.EVT_TEXT, self._onFilterText, self.tFilter) # self.comboX = wx.ComboBox(self, choices=[], style=wx.CB_READONLY) self.comboX.SetFont(getMonoFont(self)) @@ -1092,6 +1130,17 @@ def onFilterChange(self, event=None): self.setGUIColumns(xSel=xSel, ySel=ySel, zSel=zSel) # Filtering done here self.triggerPlot() # Trigger a col selection event + def _onFilterText(self, event=None): + """Debounced live filter: fires ~200 ms after the user stops typing.""" + if self._filterDebounceTimer is not None: + try: + self._filterDebounceTimer.Stop() + except Exception: + pass + self._filterDebounceTimer = wx.CallLater(200, self.onFilterChange) + if event is not None: + event.Skip() + def onFilterKey(self, event=None): s=GetKeyString(event) if s=='ESCAPE' or s=='Ctrl+C': @@ -1122,6 +1171,9 @@ def __init__(self, parent, tabList, mode='auto', mainframe=None): self.currentMode = None self.nSplits = -1 self.IKeepPerTab=None + # Debounce timers (wx.CallLater instances) + self._colDebounceTimer = None + self._tabDebounceTimer = None # Useful callBacks to be set by callee self.redrawCallback = None # called when new data is selected self.colSelectionChangeCallback = None # called after a column selection has changed @@ -1183,27 +1235,39 @@ def parentUpdateLayout(self): if self.updateLayoutCallback is not None: self.updateLayoutCallback() - def onColSelectionChange(self, event=None): - # Letting selection panel handle the change + def _doColSelectionChange(self): + self._colDebounceTimer = None self.colSelectionChanged() - # We call the callback if it was set if self.colSelectionChangeCallback is not None: self.colSelectionChangeCallback() - # We redraw self.redraw() - def onTabSelectionChange(self, event=None): - ISel=self.tabPanel.lbTab.GetSelections() - if len(ISel)>0: - # Letting seletion panel handle the change + def onColSelectionChange(self, event=None): + if self._colDebounceTimer is not None: + try: + self._colDebounceTimer.Stop() + except Exception: + pass + self._colDebounceTimer = wx.CallLater(150, self._doColSelectionChange) + + def _doTabSelectionChange(self): + self._tabDebounceTimer = None + ISel = self.tabPanel.lbTab.GetSelections() + if len(ISel) > 0: self.tabSelectionChanged() - - # We call the callback if it was set if self.tabSelectionChangeCallback is not None: self.tabSelectionChangeCallback() + self._doColSelectionChange() - # We trigger a column selection change... - self.onColSelectionChange(event=None) + def onTabSelectionChange(self, event=None): + ISel = self.tabPanel.lbTab.GetSelections() + if len(ISel) > 0: + if self._tabDebounceTimer is not None: + try: + self._tabDebounceTimer.Stop() + except Exception: + pass + self._tabDebounceTimer = wx.CallLater(150, self._doTabSelectionChange) @@ -1566,9 +1630,10 @@ def captureViewState(self): self.saveSelection() # ensure internal state is up-to-date ISel = self.tabSelected state = {} - # Save which tables are selected by name (robust across reloads) - state['tabSelectedNames'] = [self.tabList[i].name for i in ISel if i < self.tabList.len()] - # Save column selections keyed by table name, storing BOTH index and name + # Save which tables are selected – store shortname (basename only) so + # the view file is portable across directories. + state['tabSelectedNames'] = [_tab_shortname(self.tabList[i]) for i in ISel if i < self.tabList.len()] + # Save column selections keyed by shortname, storing BOTH index and name tabSelectionsFull = {} for k, v in self.tabSelections.items(): tab = next((t for t in self.tabList if t.name == k), None) @@ -1585,7 +1650,8 @@ def captureViewState(self): zSel = v.get('zSel', 0) zColIdx = zSel - 1 # convert comboZ index to column index zName = cols[zColIdx] if 0 <= zColIdx < len(cols) else None - tabSelectionsFull[k] = { + short_k = _tab_shortname(tab) if tab is not None else k + tabSelectionsFull[short_k] = { 'xSel': v['xSel'], 'ySel': list(v['ySel']), 'zSel': v.get('zSel', 0), @@ -1614,7 +1680,7 @@ def captureViewState(self): formulas_state = {} for tab in self.tabList: if tab.formulas: - formulas_state[tab.name] = sorted(tab.formulas, key=lambda f: f['pos']) + formulas_state[_tab_shortname(tab)] = sorted(tab.formulas, key=lambda f: f['pos']) state['formulas'] = formulas_state return state @@ -1622,10 +1688,15 @@ def restoreViewState(self, state): """Restore selection state from a saved view (does not redraw). Returns a list of warning strings for any missing tables or columns.""" warnings = [] - # Re-apply saved formulas (added columns) before resolving column names + # Re-apply saved formulas (added columns) before resolving column names. + # Keys in saved_formulas are shortnames; translate to raw_name for applyFormulas. saved_formulas = state.get('formulas', {}) if saved_formulas: - self.tabList.applyFormulas(saved_formulas) + full_formulas = {} + for short_k, flist in saved_formulas.items(): + matched = _find_tab_by_key(self.tabList, short_k) + full_formulas[matched.raw_name if matched else short_k] = flist + self.tabList.applyFormulas(full_formulas) # Refresh column panel so new columns are visible ISel = self.tabPanel.lbTab.GetSelections() for i in ISel: @@ -1634,17 +1705,17 @@ def restoreViewState(self, state): if tab.name in self.tabSelections: ts = self.tabSelections[tab.name] self.colPanel1.setTab(tab, ts['xSel'], ts['ySel'], ts.get('zSel', 0)) - # Restore column selections by table name, matching by column name first + # Restore column selections – keys in the saved state are shortnames (or + # full names in old view files); _find_tab_by_key handles both. selectedNames = set(state.get('tabSelectedNames', [])) for k, v in state.get('tabSelections', {}).items(): - if k not in self.tabSelections: - continue - tab = next((t for t in self.tabList if t.name == k), None) + tab = _find_tab_by_key(self.tabList, k) if tab is None: - # Table not currently loaded; restore raw indices so they're - # ready if the table is loaded later, but don't overwrite with - # anything that could lose information. - self.tabSelections[k] = {'xSel': v.get('xSel', -1), 'ySel': tuple(v.get('ySel', [])), 'zSel': v.get('zSel', 0)} + # No matching table loaded; skip (can't store by shortname key). + continue + # Use the full internal key so tabSelections stays consistent. + full_k = tab.name + if full_k not in self.tabSelections: continue xSel = v.get('xSel', -1) ySel = list(v.get('ySel', [])) @@ -1682,7 +1753,7 @@ def restoreViewState(self, state): zSel = 0 elif zSel > len(cols): # comboZ length = len(cols)+1 zSel = 0 - self.tabSelections[k] = {'xSel': xSel, 'ySel': tuple(ySel), 'zSel': zSel} + self.tabSelections[full_k] = {'xSel': xSel, 'ySel': tuple(ySel), 'zSel': zSel} # Restore sim tab selection simSel = state.get('simTabSelection', {}) self.simTabSelection = dict(simSel) @@ -1690,13 +1761,14 @@ def restoreViewState(self, state): self.simTabSelection['ySel'] = tuple(self.simTabSelection['ySel']) # Restore filters self.filterSelection = list(state.get('filterSelection', ['', '', ''])) - # Restore table selection by name, track which names are missing + # Restore table selection – saved names are shortnames (or full names for + # old view files); _find_tab_by_key handles both formats. tabSelectedNames = state.get('tabSelectedNames', []) - loadedNames = {t.name for t in self.tabList} - missing_tables = [n for n in tabSelectedNames if n not in loadedNames] + missing_tables = [n for n in tabSelectedNames if _find_tab_by_key(self.tabList, n) is None] if missing_tables: warnings.append('Table(s) not found: {}'.format(', '.join('"{}"'.format(n) for n in missing_tables))) - self.tabSelected = [i for i, t in enumerate(self.tabList) if t.name in tabSelectedNames] + self.tabSelected = [i for i, t in enumerate(self.tabList) + if _tab_shortname(t) in tabSelectedNames or t.name in tabSelectedNames] # Apply selection to the list box for i in range(self.tabPanel.lbTab.GetCount()): self.tabPanel.lbTab.Deselect(i) diff --git a/pydatview/GUIToolBox.py b/pydatview/GUIToolBox.py index 1d3d812..8ca0a58 100644 --- a/pydatview/GUIToolBox.py +++ b/pydatview/GUIToolBox.py @@ -280,14 +280,14 @@ def __init__(self, canvas, keep_tools, plotPanel): self.VERSION = matplotlib.__version__ self.plotPanel = plotPanel #print('MPL VERSION:',self.VERSION) - if self.VERSION[0]=='2' or self.VERSION[0]=='1': + if self.VERSION[0]=='2' or self.VERSION[0]=='1': wx.ToolBar.__init__(self, canvas.GetParent(), -1, style=wx.TB_HORIZONTAL | wx.NO_BORDER | wx.TB_FLAT | wx.TB_NODIVIDER) NavigationToolbar2.__init__(self, canvas) self.canvas = canvas self._idle = True try: # Old matplotlib - self.statbar = None + self.statbar = None except: pass self.prevZoomRect = None @@ -296,7 +296,9 @@ def __init__(self, canvas, keep_tools, plotPanel): else: NavigationToolbar2Wx.__init__(self, canvas) - self.pan_on=False + self.pan_on = False + self.rotate_on = False + self._rotate_tool_id = None # Make sure we start in zoom mode if 'Pan' in keep_tools: @@ -308,40 +310,103 @@ def __init__(self, canvas, keep_tools, plotPanel): if t.GetLabel() not in keep_tools: self.DeleteToolByPos(i) + # Add Rotate toggle button (after filtering, so it always appears) + if 'Pan' in keep_tools: + try: + _bmp = wx.ArtProvider.GetBitmap(wx.ART_REDO, wx.ART_TOOLBAR, (16, 16)) + _rt = self.AddCheckTool(-1, label='Rotate', bitmap1=_bmp) + self._rotate_tool_id = _rt.GetId() + self.Bind(wx.EVT_TOOL, self._toggle_rotate, id=self._rotate_tool_id) + self.SetToolShortHelp(self._rotate_tool_id, + 'Toggle 3D rotate mode (left-drag rotates)\n' + 'When off: left-drag pans, right-drag zooms') + self.Realize() + # Disabled until 3D mode is activated + self.EnableTool(self._rotate_tool_id, False) + except Exception: + pass + # Update Pan button tooltip to document 3D z-axis constraint + try: + for i in range(self.GetToolsCount()): + t = self.GetToolByPos(i) + if t.GetLabel() == 'Pan': + self.SetToolShortHelp(t.GetId(), + 'Left button pans, Right button zooms\n' + 'x/y/z fixes axis, CTRL fixes aspect') + break + except Exception: + pass + + def _toggle_rotate(self, event=None): + """Toggle rotate mode on/off. When on: drag rotates 3D axes; zoom/pan disabled.""" + self.rotate_on = not self.rotate_on + if self.rotate_on: + # Deactivate pan if active + if self.pan_on: + self.pan_on = False + NavigationToolbar2.pan(self) + # Deactivate zoom if active + try: + from matplotlib.backend_bases import _Mode + if self.mode == _Mode.ZOOM: + NavigationToolbar2.zoom(self) + except Exception: + try: + if getattr(self, '_active', None) == 'ZOOM': + NavigationToolbar2.zoom(self) + except Exception: + pass + else: + # Restore zoom mode + NavigationToolbar2.zoom(self) + if self._rotate_tool_id is not None: + self.ToggleTool(self._rotate_tool_id, self.rotate_on) + def zoom(self, *args): # NEW - MPL>=3.0.0 - if self.pan_on: + if self.pan_on or self.rotate_on: pass else: - NavigationToolbar2.zoom(self,*args) # We skip wx and use the parent - # BEFORE - #NavigationToolbar2Wx.zoom(self,*args) + NavigationToolbar2.zoom(self, *args) # We skip wx and use the parent def pan(self, *args): - self.pan_on=not self.pan_on + # Deactivate rotate if active + if self.rotate_on: + self.rotate_on = False + if self._rotate_tool_id is not None: + self.ToggleTool(self._rotate_tool_id, False) + self.pan_on = not self.pan_on # NEW - MPL >= 3.0.0 NavigationToolbar2.pan(self, *args) # We skip wx and use to parent if not self.pan_on: self.zoom() - # BEFORE - #try: - # isPan = self._active=='PAN' - #except: - # try: - # from matplotlib.backend_bases import _Mode - # isPan = self.mode == _Mode.PAN - # except: - # raise Exception('Pan not found, report a pyDatView bug, with matplotlib version.') - #NavigationToolbar2Wx.pan(self,*args) - #if isPan: - # self.zoom() + + def set3DMode(self, is3D): + """Enable/disable the rotate button based on whether 3D mode is active.""" + if self._rotate_tool_id is not None: + try: + self.EnableTool(self._rotate_tool_id, is3D) + if not is3D and self.rotate_on: + # Turn off rotate mode when leaving 3D + self.rotate_on = False + self.ToggleTool(self._rotate_tool_id, False) + NavigationToolbar2.zoom(self) + except Exception: + pass def home(self, *args): - """Restore the original view.""" - # Feature: if user click on home, we trigger a tight layout - self.plotPanel.setSubplotTight(draw=False) - # Feature: We force autoscale - self.canvas.GetParent().redraw_same_data(force_autoscale=True) + """Restore the original view. In 3D mode, resets camera AND axis ranges.""" + cp = getattr(self.plotPanel, 'colorPanel', None) + if cp is not None and cp.cb3D.IsChecked(): + cp._pending_elev = 30 + cp._pending_azim = -60 + cp._pending_hide = None + self.plotPanel.redraw_same_data(force_autoscale=True) + else: + # Feature: if user click on home, we trigger a tight layout + self.plotPanel.setSubplotTight(draw=False) + # Feature: We force autoscale + self.canvas.GetParent().redraw_same_data(force_autoscale=True) def set_message(self, s): pass diff --git a/pydatview/appdata.py b/pydatview/appdata.py index c343fbb..03fba6b 100644 --- a/pydatview/appdata.py +++ b/pydatview/appdata.py @@ -134,6 +134,8 @@ def defaultAppData(mainframe): data['infoPanel']=InfoPanel.defaultData() # Saved views data['views'] = [] + # Recent files (up to 10 paths) + data['recentFiles'] = [] return data diff --git a/pydatview/main.py b/pydatview/main.py index 2656449..8186d0f 100644 --- a/pydatview/main.py +++ b/pydatview/main.py @@ -31,6 +31,7 @@ from pydatview.GUISelectionPanel import SEL_MODES,SEL_MODES_ID from pydatview.GUISelectionPanel import ColumnPopup,TablePopup +from pydatview.GUISelectionPanel import _tab_shortname from pydatview.GUIPipelinePanel import PipelinePanel from pydatview.GUIToolBox import GetKeyString, TBAddTool from pydatview.Tables import TableList, Table @@ -145,17 +146,24 @@ def __init__(self, data=None): menuBar = wx.MenuBar() fileMenu = wx.Menu() - loadMenuItem = fileMenu.Append(wx.ID_NEW,"Open file" ,"Open file" ) + loadMenuItem = fileMenu.Append(wx.ID_NEW,"&Open file\tCtrl+O" ,"Open file" ) + reloadMenuItem= fileMenu.Append(wx.ID_ANY,"&Reload\tCtrl+R" ,"Reload current files" ) + addMenuItem = fileMenu.Append(wx.ID_ANY,"&Add file\tCtrl+A" ,"Add file to current data" ) + self.recentFilesMenu = wx.Menu() + fileMenu.AppendSubMenu(self.recentFilesMenu, 'Recent Files') + fileMenu.AppendSeparator() scrpMenuItem = fileMenu.Append(-1 ,"Export script" ,"Export script" ) exptMenuItem = fileMenu.Append(-1 ,"Export table" ,"Export table" ) saveMenuItem = fileMenu.Append(wx.ID_SAVE,"Save figure" ,"Save figure" ) exitMenuItem = fileMenu.Append(wx.ID_EXIT, 'Quit', 'Quit application') menuBar.Append(fileMenu, "&File") - self.Bind(wx.EVT_MENU,self.onExit ,exitMenuItem) - self.Bind(wx.EVT_MENU,self.onLoad ,loadMenuItem) - self.Bind(wx.EVT_MENU,self.onScript,scrpMenuItem) - self.Bind(wx.EVT_MENU,self.onExport,exptMenuItem) - self.Bind(wx.EVT_MENU,self.onSave ,saveMenuItem) + self.Bind(wx.EVT_MENU,self.onExit ,exitMenuItem) + self.Bind(wx.EVT_MENU,self.onLoad ,loadMenuItem) + self.Bind(wx.EVT_MENU,self.onReload ,reloadMenuItem) + self.Bind(wx.EVT_MENU,self.onAdd ,addMenuItem) + self.Bind(wx.EVT_MENU,self.onScript ,scrpMenuItem) + self.Bind(wx.EVT_MENU,self.onExport ,exptMenuItem) + self.Bind(wx.EVT_MENU,self.onSave ,saveMenuItem) # --- Data Plugins # NOTE: very important, need "s_loc" otherwise the lambda function take the last toolName @@ -175,7 +183,7 @@ def __init__(self, data=None): # --- Views Menu self.viewsMenu = wx.Menu() - saveViewMenuItem = self.viewsMenu.Append(wx.ID_ANY, 'Save current view...', 'Save the current selection and plot settings as a named view') + saveViewMenuItem = self.viewsMenu.Append(wx.ID_ANY, '&Save current view...\tCtrl+S', 'Save the current selection and plot settings as a named view') exportViewMenuItem = self.viewsMenu.Append(wx.ID_ANY, 'Export view to file...', 'Export the current view to a .pdvview file (includes file list and settings)') importViewMenuItem = self.viewsMenu.Append(wx.ID_ANY, 'Import view from file...', 'Load a .pdvview file, open its data files, and restore the view') self.viewsMenu.AppendSeparator() @@ -219,8 +227,9 @@ def __init__(self, data=None): # --- ToolBar tb = self.CreateToolBar(wx.TB_HORIZONTAL|wx.TB_TEXT|wx.TB_HORZ_LAYOUT) tb.AddSeparator() - self.comboMode = wx.ComboBox(tb, choices = SEL_MODES, style=wx.CB_READONLY) + self.comboMode = wx.ComboBox(tb, choices = SEL_MODES, style=wx.CB_READONLY) self.comboMode.SetSelection(0) + self.comboMode.SetToolTip("How to handle column matching when multiple tables are selected") #tb.AddStretchableSpace() tb.AddControl( wx.StaticText(tb, -1, 'Mode: ' ) ) tb.AddControl( self.comboMode ) @@ -240,16 +249,23 @@ def __init__(self, data=None): TBAddTool(tb, "Open" , 'ART_FILE_OPEN', self.onLoad) TBAddTool(tb, "Reload", 'ART_REDO' , self.onReload) TBAddTool(tb, "Add" , 'ART_PLUS' , self.onAdd) - # Views combobox - tb.AddSeparator() - tb.AddControl( wx.StaticText(tb, -1, 'View: ' ) ) - self.comboViews = wx.ComboBox(tb, choices=[], style=wx.CB_READONLY, size=(120,-1)) - self.comboViews.SetToolTip('Select a saved view to restore it') - tb.AddControl(self.comboViews) - self.Bind(wx.EVT_COMBOBOX, self.onRestoreViewFromCombo, self.comboViews) tb.AddStretchableSpace() tb.Realize() self.toolBar = tb + # Set short-help tooltips on named toolbar tools + try: + _tb_tooltips = { + 'Open': 'Open file (Ctrl+O)', + 'Reload': 'Reload current files (Ctrl+R)', + 'Add': 'Add file to current data set (Ctrl+A)', + } + for i in range(tb.GetToolsCount()): + t = tb.GetToolByPos(i) + lbl = t.GetLabel() + if lbl in _tb_tooltips: + tb.SetToolShortHelp(t.GetId(), _tb_tooltips[lbl]) + except Exception: + pass # Bind Toolbox Events self.Bind(wx.EVT_COMBOBOX, self.onModeChange, self.comboMode ) self.Bind(wx.EVT_COMBOBOX, self.onFormatChange, self.comboFormats ) @@ -257,6 +273,7 @@ def __init__(self, data=None): tb.Bind(wx.EVT_CHECKBOX, self.onLivePlotChange, self.cbLivePlot) # Populate the views combobox and menu from saved data self._populateViewsUI() + self._populateRecentFilesMenu() # --- Status bar self.statusbar=self.CreateStatusBar(3, style=0) @@ -289,12 +306,11 @@ def __init__(self, data=None): self.Bind(wx.EVT_CLOSE, self.onClose) # Shortcuts - idFilter=wx.NewId() + idFilter = wx.NewId() self.Bind(wx.EVT_MENU, self.onFilter, id=idFilter) - - accel_tbl = wx.AcceleratorTable( - [(wx.ACCEL_CTRL, ord('F'), idFilter )] - ) + accel_tbl = wx.AcceleratorTable([ + (wx.ACCEL_CTRL, ord('F'), idFilter), + ]) self.SetAcceleratorTable(accel_tbl) def onFilter(self,event): @@ -361,6 +377,16 @@ def load_files(self, filenames=[], fileformats=None, bReload=False, bAdd=False, # Display warnings for warn in warnList: Warn(self,warn) + # Track recent files (only for fresh loads, not reloads) + if not bReload and filenames: + recent = self.data.get('recentFiles', []) + for p in reversed(filenames): + p = os.path.abspath(p) + if p in recent: + recent.remove(p) + recent.insert(0, p) + self.data['recentFiles'] = recent[:10] + self._populateRecentFilesMenu() # Load tables into the GUI if self.tabList.len()>0: self.load_tabs_into_GUI(bReload=bReload, bAdd=bAdd, bPlot=bPlot) @@ -460,9 +486,23 @@ def setStatusBar(self, ISel=None): self.statusbar.SetStatusText(self.tabList[ISel[0]].filename , ISTAT+1) self.statusbar.SetStatusText(self.tabList[ISel[0]].shapestring , ISTAT+2) else: - self.statusbar.SetStatusText('{} tables loaded'.format(nTabs) ,ISTAT+0) + self.statusbar.SetStatusText('{} tables loaded'.format(nTabs) ,ISTAT+0) self.statusbar.SetStatusText(", ".join(list(set([self.tabList.filenames[i] for i in ISel]))),ISTAT+1) self.statusbar.SetStatusText('' ,ISTAT+2) + # Update window title to show loaded file names + base = PROG_NAME + ' ' + PROG_VERSION + try: + unique_files = list(dict.fromkeys( + os.path.basename(self.tabList.filenames[i]) for i in range(nTabs) + if self.tabList.filenames[i])) + if len(unique_files) == 0: + self.SetTitle(base) + elif len(unique_files) <= 3: + self.SetTitle('{} \u2014 {}'.format(base, ', '.join(unique_files))) + else: + self.SetTitle('{} \u2014 {} \u2026 ({} files)'.format(base, unique_files[0], len(unique_files))) + except Exception: + self.SetTitle(base) # --- Table Actions - TODO consider a table handler, or doing only the triggers def onTabListChangeLowLevel(self): @@ -639,18 +679,14 @@ def onColSelectionChangeTrigger(self, event=None): def onLivePlotChange(self, event=None): if self.cbLivePlot.IsChecked(): if hasattr(self,'plotPanel'): - #print('[INFO] Reenabling live plot') - #self.plotPanel.Enable(True) - #self.infoPanel.Enable(True) + self.statusbar.SetStatusText('', ISTAT) self.redrawCallback() else: + self.statusbar.SetStatusText('Live plot OFF \u2014 press Ctrl+R to update', ISTAT) if hasattr(self,'plotPanel'): - #print('[INFO] Disabling live plot') for ax in self.plotPanel.fig.axes: ax.annotate('Live Plot Disabled', xy=(0.5, 0.5), size=20, xycoords='axes fraction', ha='center', va='center',) self.plotPanel.canvas.draw() - #self.plotPanel.Enable(False) - #self.infoPanel.Enable(False) def redrawCallback(self): @@ -833,19 +869,35 @@ def onShowLoaderMenu(self, event=None): # --- Views: save / restore def _populateViewsUI(self): - """Rebuild the Views menu items and toolbar combobox from saved views list""" + """Rebuild the Views menu items from saved views list""" views = self.data.get('views', []) - # Update combobox - names = [v['name'] for v in views] - self.comboViews.Set(names) - # Rebuild dynamic menu items (keep 'Save...' and separator at top) - # Remove all items after the separator (index 2+) - while self.viewsMenu.GetMenuItemCount() > 2: - item = self.viewsMenu.FindItemByPosition(2) + # Rebuild dynamic menu items (keep Save/Export/Import + separator at top, positions 0-3) + while self.viewsMenu.GetMenuItemCount() > 4: + item = self.viewsMenu.FindItemByPosition(4) self.viewsMenu.Delete(item) for v in views: - item = self.viewsMenu.Append(wx.ID_ANY, v['name'], 'Restore view: ' + v['name']) - self.Bind(wx.EVT_MENU, lambda e, name=v['name']: self.onRestoreView(name), item) + subMenu = wx.Menu() + applyItem = subMenu.Append(wx.ID_ANY, 'Apply view') + applyTabItem = subMenu.Append(wx.ID_ANY, 'Apply view to current table') + deleteItem = subMenu.Append(wx.ID_ANY, 'Delete view') + self.viewsMenu.AppendSubMenu(subMenu, v['name']) + self.Bind(wx.EVT_MENU, lambda e, n=v['name']: self.onRestoreView(n), applyItem) + self.Bind(wx.EVT_MENU, lambda e, n=v['name']: self.onRestoreViewCurrentTable(n), applyTabItem) + self.Bind(wx.EVT_MENU, lambda e, n=v['name']: self.onDeleteView(n), deleteItem) + + def _populateRecentFilesMenu(self): + """Rebuild the Recent Files submenu from saved recentFiles list.""" + while self.recentFilesMenu.GetMenuItemCount() > 0: + item = self.recentFilesMenu.FindItemByPosition(0) + self.recentFilesMenu.Delete(item) + recent = self.data.get('recentFiles', []) + if not recent: + emptyItem = self.recentFilesMenu.Append(wx.ID_ANY, '(empty)') + emptyItem.Enable(False) + else: + for path in recent: + item = self.recentFilesMenu.Append(wx.ID_ANY, path) + self.Bind(wx.EVT_MENU, lambda e, p=path: self.load_files([p]), item) def _capturePipelineState(self): """Return {action_name: data_dict} for every action currently in the pipeline.""" @@ -971,6 +1023,111 @@ def onRestoreView(self, name): else: self.statusbar.SetStatusText('View "{}" restored.'.format(name), ISTAT) + def onRestoreViewCurrentTable(self, name): + """Restore view's column selections + plot settings on the currently selected table(s). + + Keeps the current table selection but resolves the view's saved column + names (x, y, z) against each currently selected table. If a table's + shortname matches a saved entry that is used directly; otherwise the + first saved selection is tried. + """ + if not hasattr(self, 'selPanel') or not hasattr(self, 'plotPanel'): + return + views = self.data.get('views', []) + view = next((v for v in views if v['name'] == name), None) + if view is None: + return + + selection = view.get('selection', {}) + saved_tabs = selection.get('tabSelections', {}) + + # Apply formulas from the view so added columns exist before name lookup + saved_formulas = selection.get('formulas', {}) + if saved_formulas: + from pydatview.GUISelectionPanel import _find_tab_by_key + full_formulas = {} + for short_k, flist in saved_formulas.items(): + matched = _find_tab_by_key(self.tabList, short_k) + full_formulas[matched.raw_name if matched else short_k] = flist + self.tabList.applyFormulas(full_formulas) + + # Restore plot settings (3D mode, style, etc.) + self.plotPanel.restoreViewData(view.get('plotPanel', {})) + + warnings = [] + ISel = self.selPanel.tabPanel.lbTab.GetSelections() + if len(ISel) == 0 or not saved_tabs: + self.plotPanel.load_and_draw() + self.statusbar.SetStatusText('View "{}" settings applied.'.format(name), ISTAT) + return + + # Collect a fallback selection (first saved entry) + fallback_sel = next(iter(saved_tabs.values())) + + for iTab in ISel: + if iTab >= self.tabList.len(): + continue + tab = self.tabList[iTab] + cols = list(tab.columns) + full_k = tab.name + short = _tab_shortname(tab) + + # Match by shortname first, then fall back to first saved entry + matched_sel = saved_tabs.get(short, fallback_sel) + + # Resolve X column by name + xName = matched_sel.get('xName') + xSel = matched_sel.get('xSel', -1) + if xName is not None: + xSel = cols.index(xName) if xName in cols else -1 + if xName not in cols: + warnings.append('Table "{}": x-column "{}" not found'.format(short, xName)) + elif xSel >= len(cols): + xSel = -1 + + # Resolve Y columns by name + yNames = matched_sel.get('yNames', []) + if yNames: + ySel = [cols.index(yn) for yn in yNames if yn in cols] + missing = [yn for yn in yNames if yn not in cols] + if missing: + warnings.append('Table "{}": column(s) not found: {}'.format( + short, ', '.join('"{}"'.format(n) for n in missing))) + else: + ySel_raw = matched_sel.get('ySel', []) + ySel = [iy for iy in ySel_raw if 0 <= iy < len(cols)] + + # Resolve Z column by name (comboZ: 0=None, 1+=col) + zName = matched_sel.get('zName') + zSel = matched_sel.get('zSel', 0) + if zName is not None: + zSel = (cols.index(zName) + 1) if zName in cols else 0 + if zName not in cols: + warnings.append('Table "{}": z-column "{}" not found'.format(short, zName)) + elif zSel > len(cols): + zSel = 0 + + if full_k in self.selPanel.tabSelections: + self.selPanel.tabSelections[full_k] = { + 'xSel': xSel, 'ySel': tuple(ySel), 'zSel': zSel, + } + + # Refresh column panels from the updated selections (without overwriting) + self.selPanel.tabSelectionChanged(save=False) + self.plotPanel.load_and_draw() + if warnings: + Warn(self, 'View "{}" partially applied:\n\n{}'.format(name, '\n'.join(warnings))) + self.statusbar.SetStatusText('View "{}" partially applied.'.format(name), ISTAT) + else: + self.statusbar.SetStatusText('View "{}" applied to current table.'.format(name), ISTAT) + + def onDeleteView(self, name): + """Delete the named view from the saved views list""" + views = self.data.get('views', []) + self.data['views'] = [v for v in views if v['name'] != name] + self._populateViewsUI() + self.statusbar.SetStatusText('View "{}" deleted.'.format(name), ISTAT) + def onExportView(self, event=None): """Export the current view (files + selection + plot settings) to a .pdvview file""" if not hasattr(self, 'selPanel') or not hasattr(self, 'plotPanel'): From 692e905e71daff74e05cfdedf6f0c79110c6ec5e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Mar 2026 11:31:33 +0000 Subject: [PATCH 12/41] Fix TypeError in column filter; add 2D+Z scatter mode to curve-type combo Cancels debounce timer in empty(), guards against np.array column indices (TypeError fix). Adds Scatter/Scatter+Line choices to the curve-type combo when a Z column is selected but 3-D view is off. --- pydatview/GUIPlotPanel.py | 79 +++++++++++++++++++++++++++++----- pydatview/GUISelectionPanel.py | 16 +++++-- 2 files changed, 81 insertions(+), 14 deletions(-) diff --git a/pydatview/GUIPlotPanel.py b/pydatview/GUIPlotPanel.py index 0f15985..8980a4b 100644 --- a/pydatview/GUIPlotPanel.py +++ b/pydatview/GUIPlotPanel.py @@ -923,9 +923,11 @@ def __init__(self, parent, selPanel, pipeLike=None, infoPanel=None, data=None): self.cbSwapXY = wx.CheckBox(self.ctrlPanel, -1, 'Swap XY',(10,10)) self.cbFlipX = wx.CheckBox(self.ctrlPanel, -1, 'Flip X',(10,10)) self.cbFlipY = wx.CheckBox(self.ctrlPanel, -1, 'Flip Y',(10,10)) - # Save default combo selections for 2D/3D switching - self._2d_curve_type_sel = 1 # 'LS' - self._3d_curve_type_sel = 0 # 'Scatter' + # Save default combo selections for 2D/3D/2DZ switching + self._2d_curve_type_sel = 1 # 'LS' + self._2dZ_curve_type_sel = 0 # 'Scatter' + self._3d_curve_type_sel = 0 # 'Scatter' + self._combo_mode = '2D' # '2D' | '2DZ' | '3D' # Tooltips self.cbCurveType.SetToolTip("Line style: Plain = solid lines; LS = varied dashes; Markers = symbols; Mix = both") self.cbSub.SetToolTip("Split each y-variable into its own subplot") @@ -1076,15 +1078,22 @@ def set3DMode(self, is3D, redraw=True): self.Unbind(wx.EVT_COMBOBOX, source=self.cbCurveType) try: if is3D: - self._2d_curve_type_sel = self.cbCurveType.GetSelection() + # Save current selection before switching to 3D + if self._combo_mode == '2D': + self._2d_curve_type_sel = self.cbCurveType.GetSelection() + elif self._combo_mode == '2DZ': + self._2dZ_curve_type_sel = self.cbCurveType.GetSelection() self.cbCurveType.Set(['Scatter', 'Surf']) self.cbCurveType.SetSelection(max(0, min(self._3d_curve_type_sel, 1))) self.cbCurveType.SetToolTip("3D plot type: Scatter = point cloud; Surf = triangulated surface") + self._combo_mode = '3D' else: self._3d_curve_type_sel = self.cbCurveType.GetSelection() + # Return to plain 2D – setZMode will upgrade to '2DZ' if Z is present self.cbCurveType.Set(['Plain', 'LS', 'Markers', 'Mix']) self.cbCurveType.SetSelection(self._2d_curve_type_sel) self.cbCurveType.SetToolTip("Line style: Plain = solid lines; LS = varied dashes; Markers = symbols; Mix = both") + self._combo_mode = '2D' finally: self.Bind(wx.EVT_COMBOBOX, self.redraw_event, self.cbCurveType) self.ctrl3DPanel.Show(is3D) @@ -1100,6 +1109,37 @@ def set3DMode(self, is3D, redraw=True): if redraw: self.load_and_draw() + def setZMode(self, hasZ): + """Switch cbCurveType between plain-2D and 2D+Z choices. + + Called after getPlotData determines whether any series has a Z variable + and 3D mode is NOT active. Does nothing if already in the correct mode. + """ + if self.colorPanel.cb3D.IsChecked(): + return # 3D is handled exclusively by set3DMode + target = '2DZ' if hasZ else '2D' + if target == self._combo_mode: + return + self.Unbind(wx.EVT_COMBOBOX, source=self.cbCurveType) + try: + if hasZ: + # Switching from plain 2D → 2DZ + self._2d_curve_type_sel = self.cbCurveType.GetSelection() + self.cbCurveType.Set(['Scatter', 'Scatter+Line']) + self.cbCurveType.SetSelection(max(0, min(self._2dZ_curve_type_sel, 1))) + self.cbCurveType.SetToolTip( + "2D scatter coloured by Z: Scatter = dots only; Scatter+Line = dots with connecting lines") + else: + # Switching from 2DZ → plain 2D + self._2dZ_curve_type_sel = self.cbCurveType.GetSelection() + self.cbCurveType.Set(['Plain', 'LS', 'Markers', 'Mix']) + self.cbCurveType.SetSelection(self._2d_curve_type_sel) + self.cbCurveType.SetToolTip( + "Line style: Plain = solid lines; LS = varied dashes; Markers = symbols; Mix = both") + finally: + self.Bind(wx.EVT_COMBOBOX, self.redraw_event, self.cbCurveType) + self._combo_mode = target + # --- GUI DATA def saveData(self, data): data['Grid'] = self.cbGrid.IsChecked() @@ -1233,11 +1273,17 @@ def restoreViewData(self, data): else: self.cbCurveType.SetSelection(0) else: - _choices = ['Plain', 'LS', 'Markers', 'Mix'] - if isinstance(curveType, str) and curveType in _choices: - self.cbCurveType.SetSelection(_choices.index(curveType)) + _2d_choices = ['Plain', 'LS', 'Markers', 'Mix'] + _2dZ_choices = ['Scatter', 'Scatter+Line'] + if isinstance(curveType, str) and curveType in _2dZ_choices: + # Saved while in 2DZ mode – store for when setZMode activates it + self._2dZ_curve_type_sel = _2dZ_choices.index(curveType) + # Leave combo in plain-2D for now; setZMode will switch if Z data present + self.cbCurveType.SetSelection(self._2d_curve_type_sel) + elif isinstance(curveType, str) and curveType in _2d_choices: + self.cbCurveType.SetSelection(_2d_choices.index(curveType)) elif isinstance(curveType, int): - self.cbCurveType.SetSelection(min(curveType, len(_choices) - 1)) + self.cbCurveType.SetSelection(min(curveType, len(_2d_choices) - 1)) else: self.cbCurveType.SetSelection(1) # default LS # Restore 3D extra options @@ -1802,6 +1848,8 @@ def getPlotData(self, plotType=None): self.colorPanel.Show() else: self.colorPanel.Hide() + # Adapt the curve-type combo for 2D+Z (only when not already in 3D mode) + self.setZMode(hasZ and not self.colorPanel.cb3D.IsChecked()) self.plotsizer.Layout() def PD_Compare(self,mode): @@ -1988,10 +2036,16 @@ def getPlotOptions(self, PD=None): elif self.cbCurveType.Value=='Mix': # NOTE, can be improved plot_options['LineStyles'] = ['-','--', '-','-','-'] plot_options['Markers'] = ['' ,'' ,'o','^','s'] + elif self.cbCurveType.Value in ('Scatter', 'Scatter+Line'): + # 2DZ mode – line style choices are not used for scatter; keep defaults + plot_options['LineStyles'] = ['-'] + plot_options['Markers'] = [''] else: # 3D mode ('Scatter'/'Surf') or unknown – use plain defaults plot_options['LineStyles'] = ['-'] plot_options['Markers'] = [''] + # 2D+Z scatter style (only meaningful when Z present and not 3D) + plot_options['plot2DZType'] = self.cbCurveType.Value if self.cbCurveType.Value in ('Scatter', 'Scatter+Line') else 'Scatter' # --- Font options font_options = dict() @@ -2311,8 +2365,11 @@ def plotSignals(self, ax, axis_idx, PD, pm, left_right, opts): elif hasZ: # 2D scatter coloured by Z variable – use shared norm for consistent colours try: + if opts.get('plot2DZType') == 'Scatter+Line': + axis.plot(pd.x, pd.y, color='#808080', alpha=0.4, + lw=opts['lw'], zorder=1) sc = axis.scatter(pd.x, pd.y, c=pd.z, cmap=colormap, - label=pd.syl, s=opts['ms']**2, norm=z_norm) + label=pd.syl, s=opts['ms']**2, norm=z_norm, zorder=2) except Exception: try: axis.scatter(pd.x, pd.y, label=pd.syl, s=opts['ms']**2) @@ -2542,11 +2599,11 @@ def cleanPlot(self): gc.collect() def load_and_draw(self): - """ Full draw event: + """ Full draw event: - Get plot data based on selection - Plot them - Trigger changes to infoPanel - + """ if self.plotDone: self.subplotsPar = self.getSubplotSpacing() diff --git a/pydatview/GUISelectionPanel.py b/pydatview/GUISelectionPanel.py index 8600344..76c5fd4 100644 --- a/pydatview/GUISelectionPanel.py +++ b/pydatview/GUISelectionPanel.py @@ -753,7 +753,7 @@ def __init__(self, parent, selPanel): self.selPanel = selPanel; # Data self.tab=None - self.columns=[] # All the columns available (may be different from the displayed ones) + self.columns=np.array([]) # All the columns available (may be different from the displayed ones) self.Filt2Full=None # Index of GUI columns in self.columns self.bShowID=False self.bReadOnly=False @@ -990,7 +990,8 @@ def setGUIColumns(self, xSel=-1, ySel=[], zSel=0, selInFull=True): else: self.Filt2Full = list(np.arange(len(self.columns))) - columns=self.columns[self.Filt2Full] + # Guard: ensure columns is always a numpy array so fancy list indexing works + columns=np.array(self.columns)[self.Filt2Full] # GUI update self.Freeze() @@ -1061,6 +1062,15 @@ def forceZeroSelection(self): self.lbColumns.SetSelection(-1) def empty(self): + # Cancel any pending filter debounce timer to prevent it from firing + # after columns have been cleared (which would cause a TypeError when + # setGUIColumns tries self.columns[self.Filt2Full] on a plain Python list). + if self._filterDebounceTimer is not None: + try: + self._filterDebounceTimer.Stop() + except Exception: + pass + self._filterDebounceTimer = None self.lbColumns.Clear() self.comboX.Clear() self.comboZ.Clear() @@ -1073,7 +1083,7 @@ def empty(self): self.comboZ.Enable(False) self.bt.Enable(False) self.tab=None - self.columns=[] + self.columns=np.array([]) self.Filt2Full=None self.btClear.Enable(False) self.btFilter.Enable(False) From 7b84697a788c689548d0041422bb1c53da382f81 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Mar 2026 17:53:08 +0000 Subject: [PATCH 13/41] Recent Files: submenu including view files and exported files (30 entries) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Recent Files submenu now tracks: opened data files, imported .pdvview files, exported view files, and exported tables — all in a single list, newest first, capped at 30. Clicking a .pdvview entry calls load_view_file(); all others call load_files(). https://claude.ai/code/session_01RK7XoUAyBGq81RqNdX7htm --- pydatview/appdata.py | 2 +- pydatview/main.py | 26 ++++++++++++++++++-------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/pydatview/appdata.py b/pydatview/appdata.py index 03fba6b..822195b 100644 --- a/pydatview/appdata.py +++ b/pydatview/appdata.py @@ -134,7 +134,7 @@ def defaultAppData(mainframe): data['infoPanel']=InfoPanel.defaultData() # Saved views data['views'] = [] - # Recent files (up to 10 paths) + # Recent files/views (up to 30 paths) data['recentFiles'] = [] return data diff --git a/pydatview/main.py b/pydatview/main.py index 8186d0f..5776027 100644 --- a/pydatview/main.py +++ b/pydatview/main.py @@ -379,14 +379,8 @@ def load_files(self, filenames=[], fileformats=None, bReload=False, bAdd=False, Warn(self,warn) # Track recent files (only for fresh loads, not reloads) if not bReload and filenames: - recent = self.data.get('recentFiles', []) for p in reversed(filenames): - p = os.path.abspath(p) - if p in recent: - recent.remove(p) - recent.insert(0, p) - self.data['recentFiles'] = recent[:10] - self._populateRecentFilesMenu() + self._track_recent(p) # Load tables into the GUI if self.tabList.len()>0: self.load_tabs_into_GUI(bReload=bReload, bAdd=bAdd, bPlot=bPlot) @@ -559,6 +553,7 @@ def exportTab(self, iTab): path = dlg.GetPath() fformat = fformat[dlg.GetFilterIndex()] tab.export(path=path, fformat=fformat) + self._track_recent(path) def onShowTool(self, event=None, toolName=''): """ @@ -885,6 +880,16 @@ def _populateViewsUI(self): self.Bind(wx.EVT_MENU, lambda e, n=v['name']: self.onRestoreViewCurrentTable(n), applyTabItem) self.Bind(wx.EVT_MENU, lambda e, n=v['name']: self.onDeleteView(n), deleteItem) + def _track_recent(self, path): + """Insert *path* at the top of recentFiles (capped at 30) and refresh the menu.""" + recent = self.data.get('recentFiles', []) + abs_path = os.path.abspath(path) + if abs_path in recent: + recent.remove(abs_path) + recent.insert(0, abs_path) + self.data['recentFiles'] = recent[:30] + self._populateRecentFilesMenu() + def _populateRecentFilesMenu(self): """Rebuild the Recent Files submenu from saved recentFiles list.""" while self.recentFilesMenu.GetMenuItemCount() > 0: @@ -897,7 +902,10 @@ def _populateRecentFilesMenu(self): else: for path in recent: item = self.recentFilesMenu.Append(wx.ID_ANY, path) - self.Bind(wx.EVT_MENU, lambda e, p=path: self.load_files([p]), item) + if path.endswith(VIEW_FILE_EXT): + self.Bind(wx.EVT_MENU, lambda e, p=path: self.load_view_file(p), item) + else: + self.Bind(wx.EVT_MENU, lambda e, p=path: self.load_files([p]), item) def _capturePipelineState(self): """Return {action_name: data_dict} for every action currently in the pipeline.""" @@ -1168,6 +1176,7 @@ def onExportView(self, event=None): with open(path, 'w') as f: json.dump(view_data, f, indent=2) self.statusbar.SetStatusText('View exported to: {}'.format(path), ISTAT) + self._track_recent(path) except Exception as e: Error(self, 'Failed to export view:\n{}'.format(str(e))) @@ -1233,6 +1242,7 @@ def load_view_file(self, path): self.statusbar.SetStatusText('View "{}" partially restored.'.format(view_name), ISTAT) else: self.statusbar.SetStatusText('View "{}" loaded from file.'.format(view_name), ISTAT) + self._track_recent(path) def mainFrameUpdateLayout(self, event=None): if hasattr(self.nb,'fields_1d_tab'): From 2341491ffbd56974c77832e291502046e40bc02b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 26 Mar 2026 23:08:44 +0000 Subject: [PATCH 14/41] Persistent absolute panel widths for selection splitter Each panel now stores its own absolute pixel width. Window resize only affects the last panel (absorbs slack); other panels keep their width. Dragging a sash updates only the panel to its left via GetSashIdx(). Mode switches (1/2/3 columns) restore saved widths instead of resetting to equal. Widths are persisted in view save/restore via stable panel names. https://claude.ai/code/session_01RK7XoUAyBGq81RqNdX7htm --- pydatview/GUIMultiSplit.py | 104 ++++++++++++++++++++++++--------- pydatview/GUISelectionPanel.py | 49 ++++++++++++---- 2 files changed, 112 insertions(+), 41 deletions(-) diff --git a/pydatview/GUIMultiSplit.py b/pydatview/GUIMultiSplit.py index fdfadf6..4764fc0 100644 --- a/pydatview/GUIMultiSplit.py +++ b/pydatview/GUIMultiSplit.py @@ -3,58 +3,104 @@ class MultiSplit(MultiSplitterWindow): - def __init__(self,parent,*args,**kwargs): - super(MultiSplit,self).__init__(parent,*args,**kwargs) + def __init__(self, parent, *args, **kwargs): + super(MultiSplit, self).__init__(parent, *args, **kwargs) self.Bind(wx.EVT_SPLITTER_SASH_POS_CHANGED, self.onSashChange) self.Bind(wx.EVT_SIZE, self.onParentChangeSize, self) -# self.nWindow=0 + self._panelWidths = {} # {id(window): pixel_width} — absolute pixel width per panel - def SetMinimumPaneSize(self,size): - super(MultiSplit,self).SetMinimumPaneSize(size) - self.MinSashSize=size + def SetMinimumPaneSize(self, size): + super(MultiSplit, self).SetMinimumPaneSize(size) + self.MinSashSize = size def AppendWindow(self, window, **kwargs): - super(MultiSplit,self).AppendWindow(window,**kwargs) + super(MultiSplit, self).AppendWindow(window, **kwargs) window.Show() def DetachWindow(self, window): - super(MultiSplit,self).DetachWindow(window) + super(MultiSplit, self).DetachWindow(window) window.Hide() def InsertWindow(self, idx, window, *args, **kwargs): - super(MultiSplit,self).InsertWindow(idx, window, *args, **kwargs) + super(MultiSplit, self).InsertWindow(idx, window, *args, **kwargs) window.Show() @property def nWindows(self): return len(self._windows) + def _savePanelWidths(self): + """Record each non-last pane's current pixel width.""" + for i in range(self.nWindows - 1): + win = self._windows[i] + self._panelWidths[id(win)] = self.GetSashPosition(i) + + def _restorePanelWidths(self): + """Apply stored absolute pixel widths to sash positions. + + Panels with no stored width receive an equal share of unclaimed space. + Falls back to setEquiSash() when no history exists at all. + The last panel always absorbs whatever space remains. + """ + if self.nWindows <= 1: + return + total = self.GetClientSize()[0] + if total <= 0: + return + has_history = any(id(w) in self._panelWidths for w in self._windows[:-1]) + if not has_history: + self.setEquiSash() + return + known_sum = sum( + self._panelWidths[id(w)] + for w in self._windows[:-1] + if id(w) in self._panelWidths + ) + n_unknown = sum(1 for w in self._windows[:-1] if id(w) not in self._panelWidths) + unk_w = ( + max(self.MinSashSize, (total - known_sum) // n_unknown) + if n_unknown else self.MinSashSize + ) + for i, win in enumerate(self._windows[:-1]): + w = self._panelWidths.get(id(win), unk_w) + self.SetSashPosition(i, max(self.MinSashSize, w)) + def removeAll(self): - for i in reversed(range(self.nWindows)): + self._savePanelWidths() # remember widths before detaching + for i in reversed(range(self.nWindows)): w = self.GetWindow(i) self.DetachWindow(w) w.Hide() - def onParentChangeSize(self,Event=None): - #print('here',self.GetClientSize()) - self.setEquiSash() + def onParentChangeSize(self, Event=None): + # Re-apply stored absolute widths; the last panel absorbs any slack. + self._restorePanelWidths() - def setEquiSash(self,event=None): - if self.nWindows>0: - if self.nWindows==1: + def setEquiSash(self, event=None): + if self.nWindows > 0: + if self.nWindows == 1: self.SetSashPosition(0, 0) else: - S=self.GetClientSize() - borders=5*self.nWindows-1 - equi=int((S[0]-borders)/self.nWindows) - for i in range(self.nWindows-1): + S = self.GetClientSize() + borders = 5 * self.nWindows - 1 + equi = int((S[0] - borders) / self.nWindows) + for i in range(self.nWindows - 1): self.SetSashPosition(i, equi) - def onSashChange(self,event=None): - # Not really pretty but will ensure the size don't go out of screen - pos=[self.GetSashPosition(i) for i in range(self.nWindows)] - if any([p Date: Fri, 27 Mar 2026 14:46:09 +0000 Subject: [PATCH 15/41] =?UTF-8?q?docs:=20add=20AGENT.md=20=E2=80=94=20comp?= =?UTF-8?q?rehensive=20guide=20for=20AI=20agents=20using=20pyDatView?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers when to use the tool, installation, all supported file formats, GUI layout, selection/plot/pipeline/view features, export options, keyboard shortcuts, and common-task quick reference. https://claude.ai/code/session_01RK7XoUAyBGq81RqNdX7htm --- AGENT.md | 346 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 346 insertions(+) create mode 100644 AGENT.md diff --git a/AGENT.md b/AGENT.md new file mode 100644 index 0000000..fbd01e2 --- /dev/null +++ b/AGENT.md @@ -0,0 +1,346 @@ +# pyDatView — Agent Guide + +## What is pyDatView and When to Use It + +pyDatView is a cross-platform GUI tool for loading, visualising, and analysing +**tabulated time-series and general tabular data**. Use it when: + +- A user wants to **inspect or plot data files** quickly without writing code + (CSV, Excel, OpenFAST output, NetCDF, mat, HDF5, Parquet, …) +- A user asks to **compare signals** across multiple files or tables +- A user needs **spectral analysis** (FFT / PSD) or **probability density** + plots without bespoke code +- A user wants to **apply pipeline operations** (filter, resample, bin, + remove outliers) and immediately see the result +- A user asks to create a **reusable view** that can be shared with colleagues + or re-opened later on different data +- A user needs to **export a clean Python script** that reproduces a plot for + a report or publication +- A user is doing **wind-energy / OpenFAST post-processing** (specialised + channel renaming, nodal averaging, etc.) + +Do **not** use pyDatView as a replacement for general-purpose data processing +scripts, database queries, or machine-learning pipelines. + +--- + +## Installation + +```bash +# Clone and install dependencies +git clone https://github.com/ebranlard/pyDatView +cd pyDatView +pip install -r requirements.txt + +# Launch +python pyDatView.py # empty window +python pyDatView.py file.csv # open one or more files directly +``` + +**macOS:** replace `python` with `pythonw`. +**Windows:** a self-contained installer (`pyDatView*.exe`) is available from +the GitHub releases page. + +--- + +## Launching from Python + +```python +import pydatview + +# Open by filename +pydatview.show('results.csv') +pydatview.show(['run1.out', 'run2.out']) + +# Pass DataFrames directly +pydatview.show(df) +pydatview.show([df1, df2], names=['Baseline', 'Modified']) + +# Mix DataFrames and files +pydatview.show(dataframes=[df], filenames=['ref.csv']) +``` + +`show()` blocks until the window is closed and returns nothing. + +--- + +## Supported File Formats (40+) + +| Category | Extensions / Formats | +|---|---| +| Generic tabular | `.csv`, `.txt`, `.xls`, `.xlsx`, `.parquet`, `.pkl` | +| Scientific | `.nc` (NetCDF), `.mat` (MATLAB), `.tdms`, `.vtu` / `.pvtu` (VTK) | +| OpenFAST / FAST | `.out`, `.outb`, `.fst`, `.dat`, linearisation files, summary files | +| Wind files | `.bts` (TurbSim), Bladed, HAWCStab2, HAWC2 | +| Other | Tecplot `.dat`, GNUPlot, Plot3D, Cactus, RAAW MAT, FLEX, BModes | +| Config | `.yaml` / `.yml` | + +Format is detected automatically from the file extension. Use the **Format** +dropdown in the toolbar to force a specific reader. + +--- + +## GUI Layout + +``` +┌─ Menu Bar ─────────────────────────────────────────────────────┐ +├─ Toolbar [Mode] [Format] [Live Plot ☑] [Views dropdown]───────┤ +├─ Selection Panel (left) ─────────────────┬─ Plot Canvas (right)┤ +│ ┌─ Table list ──────────────────────┐ │ (matplotlib) │ +│ ├─ Column Panel 1 ─────────────────┤ │ │ +│ │ X-axis [dropdown] │ ├─ Plot controls ─────┤ +│ │ Z/Color [dropdown] │ │ type · PDF · FFT │ +│ │ Filter [regex] │ │ MinMax · Polar │ +│ │ Column list (multi-select) │ │ Colormap · Aesthet.│ +│ ├─ Column Panel 2 (compare mode) ──┤ ├─ Info / Stats ──────┤ +│ └─ Column Panel 3 (3-table mode) ──┘ │ mean·std·min·max │ +├─ Pipeline bar (bottom) ─────────────────┴─────────────────────┤ +└────────────────────────────────────────────────────────────────┘ +``` + +Panels can be resized by dragging the sashes. Each panel remembers its last +width independently. + +--- + +## File and Table Operations + +### Opening files +- **File → Open** (Ctrl+O): replace current data with new file(s) +- **File → Add file** (Ctrl+A): add file(s) to current session +- **Drag-and-drop** onto the window; hold Ctrl while dropping to *add* rather + than *replace* +- **File → Recent Files** submenu: last 30 opened/exported paths, newest first; + `.pdvview` view files open directly as views + +### Table context menu (right-click on table list) +- Rename, Delete, Reload from disk, Merge tables +- Export to CSV / Parquet / `.outb` + +### Multi-table selection +Select multiple tables with Ctrl+click or Shift+click. Use the **Mode** +dropdown to control column matching: + +| Mode | Behaviour | +|---|---| +| Auto | pyDatView picks the best strategy | +| Same columns | Only columns present in all tables | +| Similar columns | Fuzzy name matching | +| 2 tables | Fixed two-table comparison layout | +| 3 tables | Fixed three-table layout | + +--- + +## Column / Signal Selection + +- **X-axis dropdown**: choose the independent variable (time, index, etc.) +- **Y-axis list**: multi-select with Click / Ctrl+Click / Shift+Click +- **Z/Color dropdown**: optional third variable — activates coloured scatter + or 3-D view (see below) +- **Filter box**: live regex filter on column names; if exactly one match + remains it is auto-selected + +### Formula columns (right-click column list → "Add column from formula") +``` +{Speed} * 0.5 * {Density} * {Area} # arithmetic +np.sqrt({Fx}**2 + {Fy}**2) # numpy functions +``` +Predefined shortcuts: unit conversions, derivatives, correlations, +normalisations. + +--- + +## Plot Types + +Select via the radio buttons in the Plot Type panel on the right: + +| Type | Description | +|---|---| +| **Regular** | Line or scatter plot (default) | +| **FFT** | Power spectral density (Welch or raw), frequency × PSD, amplitude | +| **PDF** | Histogram / probability density function | +| **MinMax** | Data normalised to [0, 1]; useful for shape comparison | +| **Compare** | Subplots side-by-side | +| **Polar** | Polar coordinates (beta) | + +### Curve style (curve-type combo box) +- **2-D (no Z)**: Plain line, Least-Squares fit, Markers only, Mix +- **2-D + Z colour**: Scatter, Scatter+Line (thin grey connecting line with + coloured scatter on top) +- **3-D view**: Scatter, Surface + +### Subplot layout +- **Over plot**: all Y signals on one axes +- **Subplot**: one axes per signal, stacked vertically with shared X zoom +- **Comparison**: side-by-side subplots across tables + +--- + +## Z / Color Variable and 3-D View + +1. Select a column in the **Z/Color** dropdown (Column Panel 1, below X-axis) +2. The plot automatically switches to coloured scatter +3. A **colormap** selector and **colorbar** toggle appear below the canvas +4. Check **3D view** in the Color panel to render a true 3-D scatter or surface +5. 3-D interaction: + - View preset buttons: XY plane / YZ plane / XZ plane / free perspective + - Rotate: left-drag; Pan: Ctrl+left-drag; Zoom: scroll wheel + - **Rotate button** in toolbar: toggle rotation mode + +--- + +## Pipeline Actions + +The pipeline bar at the bottom chains data-transformation steps that +**reapply automatically on every plot redraw** (including after file reload). + +Available actions (via the "+" button or Plugins menu): + +| Action | Effect | +|---|---| +| Mask | Zero-out or remove selected time ranges | +| Remove Outliers | Statistical outlier rejection | +| Filter | Low-pass, moving average, or custom FIR/IIR | +| Resample | Change sampling rate | +| Bin data | Aggregate into bins (useful for scatter data) | +| Standardise units (SI / WE) | Automatic unit conversion | +| OpenFAST: Nodal Average | Average radial / nodal data | +| OpenFAST: Rename channels | v2.3 / v3.4 channel-name migration | + +--- + +## Measurements and Statistics + +- **Left/Right cursors**: click on the canvas to place measurement markers; + the info panel shows Δx and Δy +- **Info panel** (bottom right): mean, std, min, max, count for each selected + signal; updated live +- Copy statistics to clipboard via right-click in the info panel + +--- + +## Views — Save and Restore Complete Sessions + +A **view** captures: selected tables, X/Y/Z columns, applied formulas, plot +type and settings, pipeline state, and panel sash widths. + +### Named views (in-session) +- **Views → Save current view…** (Ctrl+S): type a name, saved in memory +- **Views selector** (toolbar dropdown): instantly restore any saved view +- Saved views survive file reloads within the same session + +### Portable view files (`.pdvview`) +- **Views → Export view to file…**: saves a JSON file containing all settings + *and relative paths to the source data files* +- **Views → Import view from file…**: restores the full session +- Share a `.pdvview` file with colleagues; as long as data files are at the + same relative location the view reopens identically +- Opening a `.pdvview` via File → Open or Recent Files also restores it +- `.pdvview` files are also tracked in the Recent Files list + +--- + +## Export + +| Export target | How | +|---|---| +| Figure (PNG, PDF, SVG, EPS) | Save button in plot toolbar | +| Table data (CSV, Parquet, `.outb`) | Right-click table → Export, or File → Export table | +| Python script | File → Export script | +| View file (`.pdvview`) | Views → Export view to file | + +### Python script export +Generates a standalone script that reproduces the current plot. Options: +- Library flavour: `pydatview`, `welib`, `openfast_toolbox` +- DataFrames as dict, list, or enumeration +- Verbosity level for comments + +The generated script is a starting point; a header note flags what may need +manual adjustment. + +--- + +## Keyboard Shortcuts + +| Shortcut | Action | +|---|---| +| Ctrl+O | Open file(s) | +| Ctrl+A | Add file(s) | +| Ctrl+R | Reload current files | +| Ctrl+S | Save current view | +| Ctrl+F | Focus column filter | + +--- + +## Plot Aesthetics + +Accessible via the **Aesthetics** panel (right side): +- Font size (6–18 pt) +- Line width (0.5–3.0) +- Marker size (0.5–8) +- Legend position (11 options including "None") +- Legend font size + +Changes apply immediately to the canvas. + +--- + +## Configuration and Persistence + +Settings are stored in a JSON file in the platform's app-data directory +(e.g. `~/.pydatview_rc` on Linux). Persisted state includes: + +- Window size +- Font sizes +- Recent files list (30 entries) +- Named views +- Pipeline state +- Plot panel settings +- Loader options (e.g. date format `dayfirst`) + +**Reset to defaults**: Help → Reset options (deletes the config file and +closes the application; reopen to start fresh). + +--- + +## Programmatic / Scripting Notes + +When helping a user write code that uses pyDatView: + +```python +# Minimal example +import pandas as pd +import pydatview + +df = pd.read_csv('data.csv') +pydatview.show(df, names=['My Data']) + +# Multiple runs comparison +import glob +files = sorted(glob.glob('results_*.out')) +pydatview.show(filenames=files) +``` + +The `show()` call launches a blocking `wx.App` main loop. It cannot be used +inside another `wx.App` or inside a Jupyter cell without special handling +(use `pydatview.showApp()` in a subprocess, or call from a standalone script). + +Pipeline actions and view states can be scripted via internal APIs +(`MainFrame.addAction`, `captureViewState`, `restoreViewState`) but these are +internal and subject to change; prefer the GUI for interactive use. + +--- + +## Quick Reference — Common Tasks + +| Task | Steps in GUI | +|---|---| +| Open and plot a CSV | Ctrl+O → select file → pick X/Y columns | +| Compare two runs | Ctrl+O → select both files → Ctrl-click both in table list | +| FFT of a signal | Select signal → click **FFT** radio button | +| Add a derived channel | Right-click column list → Add formula | +| Filter noisy signal | Pipeline "+" → Filter → set cutoff | +| Save reusable session | Ctrl+S → type name; or Views → Export view to file | +| Export plot for report | Save button (floppy icon) in plot toolbar → PNG/PDF | +| Generate Python script | File → Export script | +| 3-D scatter | Select Z/Color column → check "3D view" | From f29c77908889a611e45e5be6b6314a8999b99fa9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 08:45:40 +0000 Subject: [PATCH 16/41] fix: make last panel resizable via proportional scaling on window resize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the last panel was never stored in _panelWidths so dragging the sash to its left had no persistent effect — the panel snapped back to "remainder" on the next resize. Changes: - _savePanelWidths now records ALL panels including the last - _restorePanelWidths scales all panels proportionally when the window is resized (instead of letting only the last panel absorb slack) - onSashChange also saves the last panel's width after every drag Result: every panel has a stored width; resizing the window maintains proportions; no panel is treated as a passive remainder. https://claude.ai/code/session_01RK7XoUAyBGq81RqNdX7htm --- pydatview/GUIMultiSplit.py | 46 +++++++++++++++++++++++--------------- 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/pydatview/GUIMultiSplit.py b/pydatview/GUIMultiSplit.py index 4764fc0..5153794 100644 --- a/pydatview/GUIMultiSplit.py +++ b/pydatview/GUIMultiSplit.py @@ -30,40 +30,46 @@ def nWindows(self): return len(self._windows) def _savePanelWidths(self): - """Record each non-last pane's current pixel width.""" + """Record ALL panels' current pixel widths (including the last).""" + total = self.GetClientSize()[0] for i in range(self.nWindows - 1): - win = self._windows[i] - self._panelWidths[id(win)] = self.GetSashPosition(i) + self._panelWidths[id(self._windows[i])] = self.GetSashPosition(i) + if self.nWindows >= 1: + sash_sum = sum(self.GetSashPosition(i) for i in range(self.nWindows - 1)) + self._panelWidths[id(self._windows[-1])] = max(self.MinSashSize, total - sash_sum) def _restorePanelWidths(self): - """Apply stored absolute pixel widths to sash positions. + """Restore panel widths, scaling all proportionally to the current total. - Panels with no stored width receive an equal share of unclaimed space. + Panels with no stored width share any unclaimed space equally. Falls back to setEquiSash() when no history exists at all. - The last panel always absorbs whatever space remains. """ if self.nWindows <= 1: return total = self.GetClientSize()[0] if total <= 0: return - has_history = any(id(w) in self._panelWidths for w in self._windows[:-1]) + has_history = any(id(w) in self._panelWidths for w in self._windows) if not has_history: self.setEquiSash() return known_sum = sum( self._panelWidths[id(w)] - for w in self._windows[:-1] + for w in self._windows if id(w) in self._panelWidths ) - n_unknown = sum(1 for w in self._windows[:-1] if id(w) not in self._panelWidths) + n_unknown = sum(1 for w in self._windows if id(w) not in self._panelWidths) unk_w = ( max(self.MinSashSize, (total - known_sum) // n_unknown) if n_unknown else self.MinSashSize ) - for i, win in enumerate(self._windows[:-1]): - w = self._panelWidths.get(id(win), unk_w) - self.SetSashPosition(i, max(self.MinSashSize, w)) + widths = [self._panelWidths.get(id(w), unk_w) for w in self._windows] + # Scale proportionally so panels fill the current total width + w_sum = sum(widths) + if w_sum > 0 and abs(w_sum - total) > 1: + widths = [max(self.MinSashSize, int(w * total / w_sum)) for w in widths] + for i, w in enumerate(widths[:-1]): + self.SetSashPosition(i, w) def removeAll(self): self._savePanelWidths() # remember widths before detaching @@ -73,7 +79,7 @@ def removeAll(self): w.Hide() def onParentChangeSize(self, Event=None): - # Re-apply stored absolute widths; the last panel absorbs any slack. + # Scale all panels proportionally to the new total width. self._restorePanelWidths() def setEquiSash(self, event=None): @@ -88,18 +94,22 @@ def setEquiSash(self, event=None): self.SetSashPosition(i, equi) def onSashChange(self, event=None): - """Persist only the pane to the LEFT of the dragged sash. + """Persist the pane to the LEFT of the dragged sash and the last pane. - All other stored widths are untouched so only the last pane absorbs the - change in total width. GetSashIdx() is provided by MultiSplitterEvent - and returns the 0-based index of the moved sash. + Only those two stored widths change per drag; all others are untouched. + GetSashIdx() is provided by MultiSplitterEvent and returns the 0-based + index of the moved sash. """ if event is not None and hasattr(event, 'GetSashIdx'): idx = event.GetSashIdx() if 0 <= idx < self.nWindows - 1: self._panelWidths[id(self._windows[idx])] = self.GetSashPosition(idx) + # Also record the last panel (it absorbs the drag visually) + total = self.GetClientSize()[0] + sash_sum = sum(self.GetSashPosition(j) for j in range(self.nWindows - 1)) + self._panelWidths[id(self._windows[-1])] = max(self.MinSashSize, total - sash_sum) else: - # Fallback (called manually without an event): save all non-last panes + # Fallback (called manually without an event): save all panes self._savePanelWidths() From b7dc1d7ef5f45b4575ce021227c236c254ac9ae3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 15:19:28 +0000 Subject: [PATCH 17/41] Keep panels fixed size on window resize, only plot area changes - Set vSplitter sash gravity to 0 so the left selection panel keeps its width when the window is resized - Stop MultiSplit from scaling sub-panels proportionally on EVT_SIZE - Remove automatic sash repositioning from the resize/idle handler; layout updates still happen when switching column modes https://claude.ai/code/session_01TEDdcedUgmSE1yVF33MFYE --- pydatview/GUIFields1D.py | 1 + pydatview/GUIMultiSplit.py | 5 +++-- pydatview/main.py | 1 - 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/pydatview/GUIFields1D.py b/pydatview/GUIFields1D.py index 3c191b8..8161ca5 100644 --- a/pydatview/GUIFields1D.py +++ b/pydatview/GUIFields1D.py @@ -42,6 +42,7 @@ def __init__(self, parent, mainframe): self.vSplitter.SplitVertically(self.selPanel, self.tSplitter) self.vSplitter.SetMinimumPaneSize(SIDE_COL[0]) + self.vSplitter.SetSashGravity(0) # Left panel stays fixed on resize; only plot area changes self.tSplitter.SetSashPosition(SIDE_COL[0]) diff --git a/pydatview/GUIMultiSplit.py b/pydatview/GUIMultiSplit.py index 5153794..154f614 100644 --- a/pydatview/GUIMultiSplit.py +++ b/pydatview/GUIMultiSplit.py @@ -79,8 +79,9 @@ def removeAll(self): w.Hide() def onParentChangeSize(self, Event=None): - # Scale all panels proportionally to the new total width. - self._restorePanelWidths() + # Keep panels at their current pixel widths (don't scale). + # The last panel absorbs any extra space automatically. + pass def setEquiSash(self, event=None): if self.nWindows > 0: diff --git a/pydatview/main.py b/pydatview/main.py index 5776027..2969de7 100644 --- a/pydatview/main.py +++ b/pydatview/main.py @@ -1254,7 +1254,6 @@ def mainFrameUpdateLayout(self, event=None): def OnIdle(self, event): if self.resized: self.resized = False - self.mainFrameUpdateLayout() if hasattr(self,'plotPanel'): self.plotPanel.setSubplotTight() #self.Thaw() # Commented see #166 From 5297fad24496c778c2d9b273756acb2f481d6131 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 4 May 2026 12:50:08 +0000 Subject: [PATCH 18/41] Fix miscellaneous bugs: pipeline typos, mask IndexError, loader options - Fix pipeline save/delete and table repr typos (GUIPipelinePanel, pipeline, Tables) - Fix saveOptions typo'd parameter name (Tables) - Persist loader options (dayfirst, naming) on shutdown (appdata) - Drop DC bin when converting FFT to Period (plotdata) - Fix mask plugin IndexError on empty dataframes by using dtype-based timestamp detection instead of df.iloc[0,i]; surface the underlying exception in applyMaskString for better debugging (data_mask, Tables) https://claude.ai/code/session_01RusnXtXqo9DDz4dX2rUhXB --- pydatview/GUIPipelinePanel.py | 9 ++++++--- pydatview/Tables.py | 15 ++++++++++----- pydatview/appdata.py | 4 ++-- pydatview/pipeline.py | 2 +- pydatview/plotdata.py | 6 +++++- pydatview/plugins/data_mask.py | 11 +++++++++-- 6 files changed, 33 insertions(+), 14 deletions(-) diff --git a/pydatview/GUIPipelinePanel.py b/pydatview/GUIPipelinePanel.py index a081376..396a725 100644 --- a/pydatview/GUIPipelinePanel.py +++ b/pydatview/GUIPipelinePanel.py @@ -152,15 +152,18 @@ def _addPanel(self, action): self.wrapSizer.Add(ap, 0, wx.ALIGN_LEFT|wx.ALIGN_CENTER_VERTICAL, 0) def _deletePanel(self, action): + actionPanel = None for child in self.wrapSizer.Children: win = child.GetWindow() if win is not None: if hasattr(win,'action'): if win.action==action: actionPanel=win - self.wrapSizer.Hide(actionPanel) #actionPanel.Destroy() - self.wrapSizer.Layout() - self.Sizer.Layout() + break + if actionPanel is not None: + self.wrapSizer.Hide(actionPanel) #actionPanel.Destroy() + self.wrapSizer.Layout() + self.Sizer.Layout() def onCloseAction(self, event, action=None): self.remove(action, tabList=self.tabList) # TODO diff --git a/pydatview/Tables.py b/pydatview/Tables.py index 060ebb3..a2bff7c 100644 --- a/pydatview/Tables.py +++ b/pydatview/Tables.py @@ -21,7 +21,7 @@ def __init__(self, tabs=None, options=None): self.options = self.defaultOptions() if options is None else options # --- Options - def saveOptions(self, optionts): + def saveOptions(self, options): options['naming'] = self.options['naming'] options['dayfirst'] = self.options['dayfirst'] @@ -636,7 +636,7 @@ def __repr__(self): s='Table object:\n' s+=' - name: {}\n'.format(self.name) s+=' - raw_name : {}\n'.format(self.raw_name) - s+=' - active_name: {}\n'.format(self.raw_name) + s+=' - active_name: {}\n'.format(self.active_name) s+=' - filename : {}\n'.format(self.filename) s+=' - fileformat : {}\n'.format(self.fileformat) s+=' - fileformat_name : {}\n'.format(self.fileformat_name) @@ -667,9 +667,14 @@ def applyMaskString(self, sMask, bAdd=True): else: self.mask=mask self.maskString=sMask - except: - # TODO come up with better error messages - raise Exception('Error: The mask failed to evaluate for table: '+self.nickname) + except Exception as e: + # Preserve the underlying error so the user can debug a + # bad mask string instead of seeing a generic message. + raise Exception( + 'Error: The mask failed to evaluate for table: {}\n' + ' mask string: {}\n' + ' reason: {}: {}'.format( + self.nickname, sMask, type(e).__name__, e)) if sum(mask)==0: self.clearMask() raise PyDatViewException('Error: The mask returned no value for table: '+self.nickname) diff --git a/pydatview/appdata.py b/pydatview/appdata.py index 822195b..34b9a54 100644 --- a/pydatview/appdata.py +++ b/pydatview/appdata.py @@ -82,8 +82,8 @@ def saveAppData(mainFrame, data): mainFrame.plotPanel.saveData(data['plotPanel']) if hasattr(mainFrame, 'infoPanel'): mainFrame.infoPanel.saveData(data['infoPanel']) - if hasattr(mainFrame, 'tablist'): - mainFrame.tablist.saveOptions(data['loaderOptions']) + if hasattr(mainFrame, 'tabList'): + mainFrame.tabList.saveOptions(data['loaderOptions']) if hasattr(mainFrame, 'pipePanel'): mainFrame.pipePanel.saveData(data['pipeline']) diff --git a/pydatview/pipeline.py b/pydatview/pipeline.py index f9f1373..e8cb35a 100644 --- a/pydatview/pipeline.py +++ b/pydatview/pipeline.py @@ -480,7 +480,7 @@ def saveData(self, data): data['actionsPlotFilters'] = {} for ac in self.actionsData: data['actionsData'][ac.name] = ac.data - for ac in self.actions: + for ac in self.actionsPlotFilters: data['actionsPlotFilters'][ac.name] = ac.data #data[] = self.Naming diff --git a/pydatview/plotdata.py b/pydatview/plotdata.py index 1b835fd..f9c7701 100644 --- a/pydatview/plotdata.py +++ b/pydatview/plotdata.py @@ -321,7 +321,11 @@ def toFFT(PD, yType='Amplitude', xType='1/x', avgMethod='Welch', avgWindow='Hamm else: PD.sx= '' elif xType=='x': - PD.x=1/PD.x + # Drop DC (f=0) to avoid division by zero when computing Period + nz = PD.x != 0 + PD.x = PD.x[nz] + PD.y = PD.y[nz] + PD.x = 1/PD.x if unit(PD.sx)=='s': PD.sx= 'Period [s]' else: diff --git a/pydatview/plugins/data_mask.py b/pydatview/plugins/data_mask.py index 9bd4a00..6043691 100644 --- a/pydatview/plugins/data_mask.py +++ b/pydatview/plugins/data_mask.py @@ -105,13 +105,20 @@ def addTabMask(tab, opts): def formatMaskString(df, sMask): """ """ from pydatview.common import no_unit + # Detect timestamp columns via dtype rather than df.iloc[0, i]: + # iloc[0, i] raises IndexError on empty frames, and is fragile when + # the column index isn't a plain RangeIndex. + dtypes = df.dtypes # TODO Loop on {VAR} instead.. for i, c_in_df in enumerate(df.columns): c_no_unit = no_unit(c_in_df).strip() # TODO sort out the mess with asarray (introduced to have and/or # as array won't work with date comparison - # NOTE: using iloc to avoid duplicates column issue - if isinstance(df.iloc[0,i], pd._libs.tslibs.timestamps.Timestamp): + try: + is_timestamp = pd.api.types.is_datetime64_any_dtype(dtypes.iloc[i]) + except Exception: + is_timestamp = False + if is_timestamp: sMask=sMask.replace('{'+c_no_unit+'}','df[\''+c_in_df+'\']') else: sMask=sMask.replace('{'+c_no_unit+'}','np.asarray(df[\''+c_in_df+'\'])') From b2d50943fa259c7d636443a6d116336d0c17001f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 4 May 2026 12:50:19 +0000 Subject: [PATCH 19/41] Fix GUI layout: panel sizing, splitter behavior, live-update sashes - Fix selection-panel width blowing out with long column names by subclassing ListBox/ComboBox to clamp DoGetBestSize (GUISelectionPanel) - Restore MultiSplit onParentChangeSize to call _restorePanelWidths and Skip the event so base class handles resize (GUIMultiSplit) - Set MinimumPaneSize and SashGravity before SplitVertically so the initial sash position is not silently overridden (GUIFields1D) - Enable SP_LIVE_UPDATE on both outer vSplitter and tSplitter to eliminate the XOR tracker line on click-and-hold (GUIFields1D) - Don't clear the filter search box text on Ctrl+C (GUISelectionPanel) https://claude.ai/code/session_01RusnXtXqo9DDz4dX2rUhXB --- pydatview/GUIFields1D.py | 15 ++++++++++----- pydatview/GUIMultiSplit.py | 13 ++++++++++--- pydatview/GUISelectionPanel.py | 35 +++++++++++++++++++++++++++++----- 3 files changed, 50 insertions(+), 13 deletions(-) diff --git a/pydatview/GUIFields1D.py b/pydatview/GUIFields1D.py index 8161ca5..2ae4cf1 100644 --- a/pydatview/GUIFields1D.py +++ b/pydatview/GUIFields1D.py @@ -19,7 +19,7 @@ class Fields1DPanel(wx.SplitterWindow): # TODO Panel def __init__(self, parent, mainframe): # Superclass constructor - super(Fields1DPanel, self).__init__(parent) + super(Fields1DPanel, self).__init__(parent, style=wx.SP_LIVE_UPDATE) # Data self.parent = parent self.mainframe = mainframe @@ -29,7 +29,7 @@ def __init__(self, parent, mainframe): # --- Create a selPanel, plotPanel and infoPanel mode = SEL_MODES_ID[mainframe.comboMode.GetSelection()] self.selPanel = SelectionPanel(self.vSplitter, mainframe.tabList, mode=mode, mainframe=mainframe) - self.tSplitter = wx.SplitterWindow(self.vSplitter) + self.tSplitter = wx.SplitterWindow(self.vSplitter, style=wx.SP_LIVE_UPDATE) #self.tSplitter.SetMinimumPaneSize(20) self.infoPanel = InfoPanel(self.tSplitter, data=mainframe.data['infoPanel']) self.plotPanel = PlotPanel(self.tSplitter, self.selPanel, infoPanel=self.infoPanel, pipeLike=mainframe.pipePanel, data=mainframe.data['plotPanel']) @@ -40,10 +40,15 @@ def __init__(self, parent, mainframe): self.tSplitter.SetSashGravity(1) self.tSplitter.SetSashPosition(400) - self.vSplitter.SplitVertically(self.selPanel, self.tSplitter) + # Set min pane size and gravity *before* splitting so the sash + # position passed to SplitVertically isn't silently overridden by + # a later SetMinimumPaneSize or SetSashGravity call (wx sometimes + # re-normalizes the sash on these calls when the splitter already + # has a size). self.vSplitter.SetMinimumPaneSize(SIDE_COL[0]) - self.vSplitter.SetSashGravity(0) # Left panel stays fixed on resize; only plot area changes - self.tSplitter.SetSashPosition(SIDE_COL[0]) + self.vSplitter.SetSashGravity(0) # Left pane stays fixed on resize; only plot area changes + self.vSplitter.SplitVertically(self.selPanel, self.tSplitter, SIDE_COL[0]) + self.vSplitter.SetSashPosition(SIDE_COL[0]) # --- Bind diff --git a/pydatview/GUIMultiSplit.py b/pydatview/GUIMultiSplit.py index 154f614..50e010a 100644 --- a/pydatview/GUIMultiSplit.py +++ b/pydatview/GUIMultiSplit.py @@ -79,9 +79,16 @@ def removeAll(self): w.Hide() def onParentChangeSize(self, Event=None): - # Keep panels at their current pixel widths (don't scale). - # The last panel absorbs any extra space automatically. - pass + # Re-apply stored widths (or fall back to equal split) when the + # MultiSplit is resized. Needed both for the initial layout (when + # the MultiSplit first gets its real size from its parent) and + # for mode switches that append/detach panels while the splitter + # is visible. The outer vSplitter's gravity=0 already keeps the + # SelectionPanel width fixed on window resize, so this handler + # mostly just fires during startup. + self._restorePanelWidths() + if Event is not None: + Event.Skip() def setEquiSash(self, event=None): if self.nWindows > 0: diff --git a/pydatview/GUISelectionPanel.py b/pydatview/GUISelectionPanel.py index dee36a2..6ccf36c 100644 --- a/pydatview/GUISelectionPanel.py +++ b/pydatview/GUISelectionPanel.py @@ -17,6 +17,28 @@ MAX_X_COLUMNS=300 # Maximum number of columns used in combo box of the x-axis (for performance) +class _ClampedListBox(wx.ListBox): + """wx.ListBox whose best size doesn't balloon with long item text. + + Default wx.ListBox.DoGetBestSize() returns a size based on the widest + item's text and the item count. Via GetEffectiveMinSize this propagates + up to the parent panel, past any SetMinSize cap, and can push the + selection panel wider than the splitter's configured sash position. + Clamp the best size here to a small fixed value so the container's + layout is driven by the splitter, not by the list contents. + """ + def DoGetBestSize(self): + return wx.Size(50, 80) + + +class _ClampedComboBox(wx.ComboBox): + """wx.ComboBox whose best width doesn't balloon with long item text.""" + def DoGetBestSize(self): + sz = wx.ComboBox.DoGetBestSize(self) + # Clamp width; keep the natural height so the dropdown still renders. + return wx.Size(50, sz.height) + + def _tab_shortname(tab): """Return a path-independent key for a table: basename[|sheetname]. @@ -702,7 +724,7 @@ def __init__(self, parent, selPanel, mainframe): tb.Bind(wx.EVT_BUTTON, self.showTableMenu, self.bt) tb.Realize() #label = wx.StaticText( self, -1, 'Tables: ') - self.lbTab=wx.ListBox(self, -1, choices=[], style=wx.LB_EXTENDED) + self.lbTab=_ClampedListBox(self, -1, choices=[], style=wx.LB_EXTENDED) self.lbTab.SetFont(getMonoFont(self)) sizer = wx.BoxSizer(wx.VERTICAL) sizer.Add(tb, 0, flag=wx.EXPAND,border=5) @@ -778,13 +800,16 @@ def __init__(self, parent, selPanel): self.Bind(wx.EVT_TEXT_ENTER, self.onFilterChange, self.tFilter ) self.tFilter.Bind(wx.EVT_TEXT, self._onFilterText, self.tFilter) # - self.comboX = wx.ComboBox(self, choices=[], style=wx.CB_READONLY) + # Use clamped subclasses so long column names don't inflate the + # widgets' best size and push the selection panel past the + # splitter's sash position. + self.comboX = _ClampedComboBox(self, choices=[], style=wx.CB_READONLY) self.comboX.SetFont(getMonoFont(self)) - self.lbColumns=wx.ListBox(self, -1, choices=[], style=wx.LB_EXTENDED ) + self.lbColumns = _ClampedListBox(self, -1, choices=[], style=wx.LB_EXTENDED) self.lbColumns.SetFont(getMonoFont(self)) # Z/Color variable selector self.lbZ = wx.StaticText(self, -1, 'z-axis:') - self.comboZ = wx.ComboBox(self, choices=['None'], style=wx.CB_READONLY) + self.comboZ = _ClampedComboBox(self, choices=['None'], style=wx.CB_READONLY) self.comboZ.SetFont(getMonoFont(self)) self.comboZ.SetSelection(0) # Events @@ -1153,7 +1178,7 @@ def _onFilterText(self, event=None): def onFilterKey(self, event=None): s=GetKeyString(event) - if s=='ESCAPE' or s=='Ctrl+C': + if s=='ESCAPE': self.onClearFilter() event.Skip() From a73efc639c679aade01b8a1c3cb1d6190ea3cdcc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 4 May 2026 12:50:30 +0000 Subject: [PATCH 20/41] Add axis limits, z-limits, background image, and color-scale support Axis limits: - Add xmin/xmax/ymin/ymax text fields to EstheticsPanel - Add zmin/zmax fields for 2D+Z and 3D modes - Flatten EstheticsPanel to a single WrapSizer; hide optional panels reliably on first open via plotsizer.Hide() Background image: - Add BG toolbar button with Load/Paste/Clear menu - Two modes: 'Fixed' (default, fills plot area via imshow + xlim/ylim-changed callbacks) and 'Moving with axes' (glued to data coords captured on switch) - IMAGE_EXTS constant shared with main.py for recent-files routing Z-axis / color-scale: - Use z-axis limit fields for color scale in 2D+Z scatter mode - Fix color-scale / z-limit sync when AutoScale toggles - Exclude datetime Z columns from color-scale path - Preserve 3D camera state across redraws; enable logZ; dedupe colorbars https://claude.ai/code/session_01RusnXtXqo9DDz4dX2rUhXB --- pydatview/GUIPlotPanel.py | 542 ++++++++++++++++++++++++++++++++++---- 1 file changed, 496 insertions(+), 46 deletions(-) diff --git a/pydatview/GUIPlotPanel.py b/pydatview/GUIPlotPanel.py index 8980a4b..7040d57 100644 --- a/pydatview/GUIPlotPanel.py +++ b/pydatview/GUIPlotPanel.py @@ -6,6 +6,7 @@ #from matplotlib import pyplot as plt import matplotlib import matplotlib.dates as mdates +import matplotlib.image as mpimg # Backends: # ['GTK3Agg', 'GTK3Cairo', 'GTK4Agg', 'GTK4Cairo', 'MacOSX', 'nbAgg', 'QtAgg', 'QtCairo', 'Qt5Agg', 'Qt5Cairo', 'TkAgg', 'TkCairo', 'WebAgg', 'WX', 'WXAgg', 'WXCairo', 'agg', 'cairo', 'pdf', 'pgf', 'ps', 'svg', 'template'] matplotlib.use('WX') # Important for Windows version of installer. NOTE: changed from Agg to wxAgg, then to WX @@ -44,6 +45,9 @@ import gc +# Supported image file extensions for background images +IMAGE_EXTS = ('.png', '.jpg', '.jpeg', '.bmp', '.tif', '.tiff') + from pydatview.common import * # unique, CHAR, pretty_date from pydatview.plotdata import PlotData, compareMultiplePD from pydatview.plotdata import PDL_xlabel @@ -766,25 +770,74 @@ def __init__(self, parent, data): i = 2 self.cbMS.SetSelection(i) self.cbMS.SetToolTip("Size of data point markers") - - # Layout - #dummy_sizer = wx.BoxSizer(wx.HORIZONTAL) - dummy_sizer = wx.WrapSizer(orient=wx.HORIZONTAL) - dummy_sizer.Add(lbFont ,0, flag = wx.CENTER|wx.LEFT,border = 1) - dummy_sizer.Add(self.cbFont ,0, flag = wx.CENTER|wx.LEFT,border = 1) - dummy_sizer.Add(lbLW ,0, flag = wx.CENTER|wx.LEFT,border = 5) - dummy_sizer.Add(self.cbLW ,0, flag = wx.CENTER|wx.LEFT,border = 1) - dummy_sizer.Add(lbMS ,0, flag = wx.CENTER|wx.LEFT,border = 5) - dummy_sizer.Add(self.cbMS ,0, flag = wx.CENTER|wx.LEFT,border = 1) - dummy_sizer.Add(lbLegend ,0, flag = wx.CENTER|wx.LEFT,border = 5) - dummy_sizer.Add(self.cbLegend ,0, flag = wx.CENTER|wx.LEFT,border = 1) - dummy_sizer.Add(lbLgdFont ,0, flag = wx.CENTER|wx.LEFT,border = 5) - dummy_sizer.Add(self.cbLgdFont ,0, flag = wx.CENTER|wx.LEFT,border = 1) - self.SetSizer(dummy_sizer) + # Axis Limits + lbXMin = wx.StaticText(self, -1, 'xmin:') + self.tXMin = wx.TextCtrl(self, size=(60, -1), style=wx.TE_PROCESS_ENTER) + self.tXMin.SetValue(str(data.get('xmin', ''))) + self.tXMin.SetToolTip("Minimum x-axis limit (empty = auto)") + lbXMax = wx.StaticText(self, -1, 'xmax:') + self.tXMax = wx.TextCtrl(self, size=(60, -1), style=wx.TE_PROCESS_ENTER) + self.tXMax.SetValue(str(data.get('xmax', ''))) + self.tXMax.SetToolTip("Maximum x-axis limit (empty = auto)") + lbYMin = wx.StaticText(self, -1, 'ymin:') + self.tYMin = wx.TextCtrl(self, size=(60, -1), style=wx.TE_PROCESS_ENTER) + self.tYMin.SetValue(str(data.get('ymin', ''))) + self.tYMin.SetToolTip("Minimum y-axis limit (empty = auto)") + lbYMax = wx.StaticText(self, -1, 'ymax:') + self.tYMax = wx.TextCtrl(self, size=(60, -1), style=wx.TE_PROCESS_ENTER) + self.tYMax.SetValue(str(data.get('ymax', ''))) + self.tYMax.SetToolTip("Maximum y-axis limit (empty = auto)") + lbZMin = wx.StaticText(self, -1, 'zmin:') + self.tZMin = wx.TextCtrl(self, size=(60, -1), style=wx.TE_PROCESS_ENTER) + self.tZMin.SetValue(str(data.get('zmin', ''))) + self.tZMin.SetToolTip("Minimum z value (empty = auto). 3D: z axis. 2D+Z: color scale lower bound.") + lbZMax = wx.StaticText(self, -1, 'zmax:') + self.tZMax = wx.TextCtrl(self, size=(60, -1), style=wx.TE_PROCESS_ENTER) + self.tZMax.SetValue(str(data.get('zmax', ''))) + self.tZMax.SetToolTip("Maximum z value (empty = auto). 3D: z axis. 2D+Z: color scale upper bound.") + + # Layout — single WrapSizer holding both the esthetic controls and + # the axis-limit controls. Previously this was a BoxSizer(VERTICAL) + # containing two nested WrapSizers, but nested WrapSizers inside a + # BoxSizer break the panel's Hide() propagation on first show — the + # esthetics panel would leak through as visible on startup — so we + # keep a single WrapSizer and let it wrap naturally. + self.lbZMin = lbZMin + self.lbZMax = lbZMax + main_sizer = wx.WrapSizer(orient=wx.HORIZONTAL) + main_sizer.Add(lbFont ,0, flag = wx.CENTER|wx.LEFT,border = 1) + main_sizer.Add(self.cbFont ,0, flag = wx.CENTER|wx.LEFT,border = 1) + main_sizer.Add(lbLW ,0, flag = wx.CENTER|wx.LEFT,border = 5) + main_sizer.Add(self.cbLW ,0, flag = wx.CENTER|wx.LEFT,border = 1) + main_sizer.Add(lbMS ,0, flag = wx.CENTER|wx.LEFT,border = 5) + main_sizer.Add(self.cbMS ,0, flag = wx.CENTER|wx.LEFT,border = 1) + main_sizer.Add(lbLegend ,0, flag = wx.CENTER|wx.LEFT,border = 5) + main_sizer.Add(self.cbLegend ,0, flag = wx.CENTER|wx.LEFT,border = 1) + main_sizer.Add(lbLgdFont ,0, flag = wx.CENTER|wx.LEFT,border = 5) + main_sizer.Add(self.cbLgdFont ,0, flag = wx.CENTER|wx.LEFT,border = 1) + main_sizer.Add(lbXMin ,0, flag = wx.CENTER|wx.LEFT,border = 5) + main_sizer.Add(self.tXMin ,0, flag = wx.CENTER|wx.LEFT,border = 1) + main_sizer.Add(lbXMax ,0, flag = wx.CENTER|wx.LEFT,border = 5) + main_sizer.Add(self.tXMax ,0, flag = wx.CENTER|wx.LEFT,border = 1) + main_sizer.Add(lbYMin ,0, flag = wx.CENTER|wx.LEFT,border = 5) + main_sizer.Add(self.tYMin ,0, flag = wx.CENTER|wx.LEFT,border = 1) + main_sizer.Add(lbYMax ,0, flag = wx.CENTER|wx.LEFT,border = 5) + main_sizer.Add(self.tYMax ,0, flag = wx.CENTER|wx.LEFT,border = 1) + main_sizer.Add(lbZMin ,0, flag = wx.CENTER|wx.LEFT,border = 5) + main_sizer.Add(self.tZMin ,0, flag = wx.CENTER|wx.LEFT,border = 1) + main_sizer.Add(lbZMax ,0, flag = wx.CENTER|wx.LEFT,border = 5) + main_sizer.Add(self.tZMax ,0, flag = wx.CENTER|wx.LEFT,border = 1) + self.SetSizer(main_sizer) self.Hide() # Callbacks self.Bind(wx.EVT_COMBOBOX ,self.onAnyEsthOptionChange) self.cbFont.Bind(wx.EVT_COMBOBOX ,self.onFontOptionChange) + self.tXMin.Bind(wx.EVT_TEXT_ENTER, self.onAxisLimitChange) + self.tXMax.Bind(wx.EVT_TEXT_ENTER, self.onAxisLimitChange) + self.tYMin.Bind(wx.EVT_TEXT_ENTER, self.onAxisLimitChange) + self.tYMax.Bind(wx.EVT_TEXT_ENTER, self.onAxisLimitChange) + self.tZMin.Bind(wx.EVT_TEXT_ENTER, self.onAxisLimitChange) + self.tZMax.Bind(wx.EVT_TEXT_ENTER, self.onAxisLimitChange) # Store data self.data={} @@ -797,6 +850,38 @@ def onFontOptionChange(self,event=None): matplotlib_rc('font', **{'size':int(self.cbFont.Value) }) # affect all (including ticks) self.onAnyEsthOptionChange() + def onAxisLimitChange(self, event=None): + if self.parent.cbAutoScale.IsChecked(): + self.parent.cbAutoScale.SetValue(False) + self.parent.redraw_same_data() + + def getAxisLimits(self): + """Return axis limit values. Empty/invalid fields become None.""" + def _parse(tc): + v = tc.GetValue().strip() + if v == '': + return None + try: + return float(v) + except ValueError: + return None + return { + 'xmin': _parse(self.tXMin), + 'xmax': _parse(self.tXMax), + 'ymin': _parse(self.tYMin), + 'ymax': _parse(self.tYMax), + 'zmin': _parse(self.tZMin), + 'zmax': _parse(self.tZMax), + } + + def showZLimits(self, show): + """Show or hide the z-axis limit fields.""" + self.lbZMin.Show(show) + self.tZMin.Show(show) + self.lbZMax.Show(show) + self.tZMax.Show(show) + self.Layout() + def _GUI2Data(self): """ data['plotStyle'] """ self.data['Font'] = int(self.cbFont.GetValue()) @@ -804,6 +889,12 @@ def _GUI2Data(self): self.data['LegendPosition'] = self.cbLegend.GetValue() self.data['LineWidth'] = float(self.cbLW.GetValue()) self.data['MarkerSize'] = float(self.cbMS.GetValue()) + self.data['xmin'] = self.tXMin.GetValue() + self.data['xmax'] = self.tXMax.GetValue() + self.data['ymin'] = self.tYMin.GetValue() + self.data['ymax'] = self.tYMax.GetValue() + self.data['zmin'] = self.tZMin.GetValue() + self.data['zmax'] = self.tZMax.GetValue() return self.data @@ -857,7 +948,14 @@ def __init__(self, parent, selPanel, pipeLike=None, infoPanel=None, data=None): self.markers = [] # List of GUIMeasures self.xlim_prev = [[0, 1]] self.ylim_prev = [[0, 1]] + self.zlim_prev = [] # per-axis z-limits for 3D axes; None otherwise + self.view3d_prev = [] # per-axis (elev, azim) for 3D axes; None otherwise self.addTablesCallback = None + self._bg_image = None # numpy array for background image + self._bg_glued = False # True = 'Moving with axes' (glued to data coords); + # False = 'Fixed' (fills current plot view, default) + self._bg_extent = None # [xmin, xmax, ymin, ymax] in data coords, captured + # when entering 'Moving with axes' mode # --- GUI self.fig = Figure(facecolor="white", figsize=(1, 1)) @@ -873,6 +971,7 @@ def __init__(self, parent, selPanel, pipeLike=None, infoPanel=None, data=None): self.navTBBottom = MyNavigationToolbar2Wx(self.canvas, ['Subplots', 'Save'], plotPanel=self) TBAddCheckTool(self.navTBBottom,'', icons.chart.GetBitmap(), self.onEsthToggle) self.esthToggle=False + TBAddTool(self.navTBBottom, 'BG', callback=self.onBgMenu) self.navTBBottom.Realize() @@ -901,6 +1000,7 @@ def __init__(self, parent, selPanel, pipeLike=None, infoPanel=None, data=None): # --- Esthetics panel self.esthPanel = EstheticsPanel(self, data=self.data['plotStyle']) + self.esthPanel.showZLimits(False) # hidden until a Z variable is selected # --- Ctrl Panel @@ -1042,22 +1142,32 @@ 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.toolSizer ,0,flag = wx.EXPAND|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.spcPanel ,0,flag = wx.EXPAND|wx.TOP|wx.BOTTOM,border = 10) + plotsizer.Add(self.pdfPanel ,0,flag = wx.EXPAND|wx.TOP|wx.BOTTOM,border = 10) + plotsizer.Add(self.cmpPanel ,0,flag = wx.EXPAND|wx.TOP|wx.BOTTOM,border = 10) + plotsizer.Add(self.mmxPanel ,0,flag = wx.EXPAND|wx.TOP|wx.BOTTOM,border = 10) + plotsizer.Add(self.polPanel ,0,flag = wx.EXPAND|wx.TOP|wx.BOTTOM,border = 10) + plotsizer.Add(self.colorPanel ,0,flag = wx.EXPAND|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.esthPanel ,0,flag = wx.EXPAND|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; + # Explicitly hide optional panels via the sizer. Each panel calls + # self.Hide() at the end of its own __init__, but on some platforms + # that pre-Add state is not reliably honored by the sizer once the + # parent first lays itself out, so the panels would briefly appear + # on first open. Hiding via the sizer after SetSizer makes it stick. + for _p in (self.spcPanel, self.pdfPanel, self.cmpPanel, self.mmxPanel, + self.polPanel, self.colorPanel, self.esthPanel, + self.slEsth, self.slCtrl): + plotsizer.Hide(_p) + plotsizer.Layout() # self.setSubplotSpacing(init=True) # --- Bindings/callback @@ -1200,6 +1310,15 @@ def captureViewData(self): data['flipY'] = self.cbFlipY.IsChecked() # R8 – Plot matrix data['plotMatrix'] = self.cbPlotMatrix.IsChecked() + # R9 – Axis limits + data['axisLimits'] = { + 'xmin': self.esthPanel.tXMin.GetValue(), + 'xmax': self.esthPanel.tXMax.GetValue(), + 'ymin': self.esthPanel.tYMin.GetValue(), + 'ymax': self.esthPanel.tYMax.GetValue(), + 'zmin': self.esthPanel.tZMin.GetValue(), + 'zmax': self.esthPanel.tZMax.GetValue(), + } return data def restoreViewData(self, data): @@ -1359,6 +1478,14 @@ def restoreViewData(self, data): self.polPanel.cbPolarAbout.SetSelection(_pol_about.index(pol.get('About', _pol_about[0]))) except ValueError: pass + # R9 – Axis limits + axisLimits = data.get('axisLimits', {}) + self.esthPanel.tXMin.SetValue(str(axisLimits.get('xmin', ''))) + self.esthPanel.tXMax.SetValue(str(axisLimits.get('xmax', ''))) + self.esthPanel.tYMin.SetValue(str(axisLimits.get('ymin', ''))) + self.esthPanel.tYMax.SetValue(str(axisLimits.get('ymax', ''))) + self.esthPanel.tZMin.SetValue(str(axisLimits.get('zmin', ''))) + self.esthPanel.tZMax.SetValue(str(axisLimits.get('zmax', ''))) @staticmethod def defaultData(): @@ -1371,6 +1498,12 @@ def defaultData(): plotStyle['LegendPosition'] = 'Upper right' plotStyle['LineWidth'] = '1.5' plotStyle['MarkerSize'] = '2' + plotStyle['xmin'] = '' + plotStyle['xmax'] = '' + plotStyle['ymin'] = '' + plotStyle['ymax'] = '' + plotStyle['zmin'] = '' + plotStyle['zmax'] = '' data['plotStyle']= plotStyle return data @@ -1387,6 +1520,117 @@ def onEsthToggle(self,event): self.Thaw() event.Skip() + # --- Background image + def onBgMenu(self, event): + """Show popup menu with background image options.""" + menu = wx.Menu() + m1 = menu.Append(wx.ID_ANY, 'Load from file...') + m2 = menu.Append(wx.ID_ANY, 'Paste from clipboard') + menu.AppendSeparator() + m3 = menu.Append(wx.ID_ANY, 'Clear background') + m3.Enable(self._bg_image is not None) + menu.AppendSeparator() + # Mode toggle: radio items — only one can be active at a time. + mFixed = menu.AppendRadioItem(wx.ID_ANY, 'Fixed (default)') + mMoving = menu.AppendRadioItem(wx.ID_ANY, 'Moving with axes') + mFixed.Enable(self._bg_image is not None) + mMoving.Enable(self._bg_image is not None) + if self._bg_image is not None: + if self._bg_glued: + mMoving.Check(True) + else: + mFixed.Check(True) + self.Bind(wx.EVT_MENU, self.onLoadBgImage, m1) + self.Bind(wx.EVT_MENU, self.onPasteBgImage, m2) + self.Bind(wx.EVT_MENU, self.onClearBgImage, m3) + self.Bind(wx.EVT_MENU, self.onBgModeFixed, mFixed) + self.Bind(wx.EVT_MENU, self.onBgModeMoving, mMoving) + self.PopupMenu(menu) + menu.Destroy() + + def _setBgImage(self, img_array): + """Install a new background image without touching any GUI setting. + + The image is shown filling the current plot area ('Fixed' mode), so + AutoScale, axis limits and other plot options are left exactly as the + user had them. + """ + self._bg_image = img_array + self._bg_glued = False + self._bg_extent = None + self.redraw_same_data() + + def onLoadBgImage(self, event): + """Load a background image from file.""" + exts_pattern = ';'.join('*' + e for e in IMAGE_EXTS) + wildcard = 'Image files ({0})|{0}|All files (*.*)|*.*'.format(exts_pattern) + with wx.FileDialog(self, 'Load background image', wildcard=wildcard, + style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST) as dlg: + if dlg.ShowModal() == wx.ID_CANCEL: + return + path = dlg.GetPath() + try: + img = mpimg.imread(path) + self._setBgImage(img) + # Track in the Recent Files menu + top = self.GetTopLevelParent() + if hasattr(top, '_track_recent'): + top._track_recent(path) + except Exception as e: + Error(self, 'Failed to load image:\n{}'.format(str(e))) + + def onPasteBgImage(self, event): + """Paste a background image from the clipboard.""" + bmp_data = wx.BitmapDataObject() + success = False + if wx.TheClipboard.Open(): + success = wx.TheClipboard.GetData(bmp_data) + wx.TheClipboard.Close() + if not success: + Warn(self, 'No image found in clipboard.') + return + bmp = bmp_data.GetBitmap() + img = bmp.ConvertToImage() + width, height = img.GetWidth(), img.GetHeight() + buf = bytes(img.GetDataBuffer()) + arr = np.frombuffer(buf, dtype=np.uint8).reshape(height, width, 3).copy() + # Normalize to 0-1 float for matplotlib + self._setBgImage(arr.astype(np.float64) / 255.0) + + def onClearBgImage(self, event): + """Remove the background image.""" + self._bg_image = None + self.redraw_same_data() + + def onBgModeFixed(self, event): + """'Fixed' mode: the image always fills the current plot view. + + The axes pan/zoom/rescale freely; the image extent is recomputed on + every redraw so it keeps covering the visible area. No GUI settings + are changed when entering this mode. + """ + self._bg_glued = False + self._bg_extent = None + self.redraw_same_data() + + def onBgModeMoving(self, event): + """'Moving with axes' mode: the image is glued to data coordinates. + + We capture the current xlim/ylim as the image's data-coord extent; + from now on the image is rendered at those data points, so pan/zoom + moves the image along with the data. We deliberately do NOT toggle + AutoScale or any other GUI setting — the image-to-data relation is + preserved regardless. + """ + if len(self.fig.axes) == 0: + return + ax = self.fig.axes[0] + xlim = ax.get_xlim_() + ylim = ax.get_ylim_() + self._bg_extent = [min(xlim), max(xlim), min(ylim), max(ylim)] + self._bg_glued = True + self.redraw_same_data() + def setSubplotSpacing(self, init=False, tight=False): """ Handle default subplot spacing @@ -1848,6 +2092,8 @@ def getPlotData(self, plotType=None): self.colorPanel.Show() else: self.colorPanel.Hide() + # Show z-limit fields when a Z variable is present (3D or color-mapped 2D) + self.esthPanel.showZLimits(hasZ) # Adapt the curve-type combo for 2D+Z (only when not already in 3D mode) self.setZMode(hasZ and not self.colorPanel.cb3D.IsChecked()) self.plotsizer.Layout() @@ -2091,13 +2337,45 @@ def plot_all(self, autoscale=True): # Set limit before plot when possible, for optimization self.set_axes_lim(PD, ax_left, plotType) + # Draw background image if present (not supported on 3D axes) + if self._bg_image is not None: + if not hasattr(ax_left, 'set_zlim'): + if self._bg_glued and self._bg_extent is not None: + # Moving-with-axes: image is glued to data coords + ax_left.imshow(self._bg_image, extent=self._bg_extent, + aspect='auto', zorder=0, interpolation='bilinear', + origin='upper') + else: + # Fixed (default): image fills the current view. + # Use standard get_xlim/get_ylim (not swap-aware) because + # imshow() is not overridden by SwappyAxes and uses + # standard data-space coordinates. + xlim = ax_left.get_xlim() + ylim = ax_left.get_ylim() + bg_ext = [xlim[0], xlim[1], ylim[0], ylim[1]] + bg_artist = ax_left.imshow( + self._bg_image, extent=bg_ext, + aspect='auto', zorder=0, interpolation='bilinear', + origin='upper') + # Keep the image filling the visible area during + # interactive pan / zoom by updating its extent + # whenever the axis limits change. + def _on_lim_changed(ax, _a=bg_artist, _s=self): + if _s._bg_glued or _a.axes is None: + return + xl = ax.get_xlim() + yl = ax.get_ylim() + _a.set_extent([xl[0], xl[1], yl[0], yl[1]]) + ax_left.callbacks.connect('xlim_changed', _on_lim_changed) + ax_left.callbacks.connect('ylim_changed', _on_lim_changed) + # Actually plot if self.infoPanel is not None: pm = self.infoPanel.getPlotMatrix(PD, self.cbSub.IsChecked()) else: pm = None - __, bAllNegLeft = self.plotSignals(ax_left, axis_idx, PD, pm, 1, plot_options) - ax_right, bAllNegRight = self.plotSignals(ax_left, axis_idx, PD, pm, 2, plot_options) + __, bAllNegLeft = self.plotSignals(ax_left, axis_idx, PD, pm, 1, plot_options, autoscale=autoscale) + ax_right, bAllNegRight = self.plotSignals(ax_left, axis_idx, PD, pm, 2, plot_options, autoscale=autoscale) # Log Axes if plot_options['logX'] and not hasattr(ax_left, 'set_zlim'): @@ -2128,8 +2406,51 @@ def plot_all(self, autoscale=True): pass if not autoscale: - # We force the limits to be the same as before - self._restore_limits() + user_lim = self.esthPanel.getAxisLimits() + has_user_lim = any(v is not None for v in user_lim.values()) + if has_user_lim: + # User specified at least one limit: start from data-computed + # limits (already set by set_axes_lim), override with user values + for ax_i in axes: + cur_xlim = list(ax_i.get_xlim_()) + cur_ylim = list(ax_i.get_ylim_()) + if user_lim['xmin'] is not None: + cur_xlim[0] = user_lim['xmin'] + if user_lim['xmax'] is not None: + cur_xlim[1] = user_lim['xmax'] + if user_lim['ymin'] is not None: + cur_ylim[0] = user_lim['ymin'] + if user_lim['ymax'] is not None: + cur_ylim[1] = user_lim['ymax'] + ax_i.set_xlim_(cur_xlim) + ax_i.set_ylim_(cur_ylim) + # Z-axis limits (3D only). In log-z 3D mode the z + # values are plotted in log10 space, so the user's + # limits (entered in data space) must also be + # log-transformed to match. + if hasattr(ax_i, 'set_zlim'): + if user_lim['zmin'] is not None or user_lim['zmax'] is not None: + cur_zlim = list(ax_i.get_zlim()) + u_zmin = user_lim['zmin'] + u_zmax = user_lim['zmax'] + if plot_options.get('logZ', False): + with np.errstate(divide='ignore', invalid='ignore'): + if u_zmin is not None and u_zmin > 0: + u_zmin = float(np.log10(u_zmin)) + elif u_zmin is not None: + u_zmin = None + if u_zmax is not None and u_zmax > 0: + u_zmax = float(np.log10(u_zmax)) + elif u_zmax is not None: + u_zmax = None + if u_zmin is not None: + cur_zlim[0] = u_zmin + if u_zmax is not None: + cur_zlim[1] = u_zmax + ax_i.set_zlim(cur_zlim) + else: + # No user limits: restore previous zoom/pan state + self._restore_limits() elif self.pltTypePanel.cbFFT.GetValue(): # XLIM - TODO FFT ONLY NASTY try: @@ -2174,6 +2495,11 @@ def plot_all(self, autoscale=True): ax_left.invert_xaxis() if plot_options['flipY']: ax_left.invert_yaxis() + if plot_options.get('flipZ', False) and hasattr(ax_left, 'invert_zaxis'): + try: + ax_left.invert_zaxis() + except Exception: + pass # TODO put this in set_axes_lim if plotType=='Compare': @@ -2264,7 +2590,7 @@ def plot_all(self, autoscale=True): except Exception: pass - def plotSignals(self, ax, axis_idx, PD, pm, left_right, opts): + def plotSignals(self, ax, axis_idx, PD, pm, left_right, opts, autoscale=True): axis = None bAllNeg = True if pm is None: @@ -2283,6 +2609,10 @@ def plotSignals(self, ax, axis_idx, PD, pm, left_right, opts): # --- Pre-compute shared Z normalisation across all Z-coloured signals on this axis # This ensures consistent colours and a single correct colorbar for all signals. + # NOTE: z_norm must be built regardless of `showColorBar`, because it + # also controls the color mapping of the scatter/surf calls. If we + # only built it when the colorbar is on, disabling the colorbar would + # silently drop the user's zmin/zmax limits. z_norm = None z_label_combined = '' _z_signals_pd = [] # PlotData objects that will be plotted with Z coloring @@ -2293,27 +2623,78 @@ def plotSignals(self, ax, axis_idx, PD, pm, left_right, opts): ) if will_plot: pd_i = PD[signal_idx] - if pd_i.z is not None and not pd_i.zIsString: + # Only include Z-coloured signals where the Z array still + # aligns with the plotted X/Y. After FFT/PDF/MinMax/Polar + # transforms, pd.x and pd.y are reshaped (often shorter) but + # pd.z is left at its original length. Plotting colour in + # that case would error silently and leave a stray colorbar. + if (pd_i.z is not None + and not pd_i.zIsString + and not getattr(pd_i, 'zIsDate', False) + and hasattr(pd_i, 'x') and pd_i.x is not None + and len(pd_i.z) == len(pd_i.x)): _z_signals_pd.append(pd_i) - if _z_signals_pd and showColorBar: + if _z_signals_pd: import matplotlib.colors as mcolors - import matplotlib.cm as mcm all_z_parts = [] z_label_parts = [] for pd_i in _z_signals_pd: z_vals = np.asarray(pd_i.z, dtype=float) - if logZ and use3D: + if logZ: with np.errstate(divide='ignore', invalid='ignore'): z_vals = np.log10(z_vals) all_z_parts.append(z_vals) z_label_parts.append(pd_i.sz) all_z = np.concatenate(all_z_parts) finite_z = all_z[np.isfinite(all_z)] + # Start from data-driven vmin/vmax (if any finite data) + z_vmin = None + z_vmax = None if len(finite_z) > 0: - z_norm = mcolors.Normalize(vmin=np.min(finite_z), vmax=np.max(finite_z)) + z_vmin = float(np.min(finite_z)) + z_vmax = float(np.max(finite_z)) + # Override with user-specified z-limits if provided. + # Only honored when AutoScale is off, matching the x/y/z handling + # in plot_all(); otherwise stale values in the zmin/zmax fields + # would desynchronise the colorbar from the data-driven axes. + # In log-z mode the data was transformed with log10 above, so + # the user's limits (entered in data space) must also be log10'd. + if not autoscale: + user_lim = self.esthPanel.getAxisLimits() + u_zmin = user_lim['zmin'] + u_zmax = user_lim['zmax'] + if logZ: + with np.errstate(divide='ignore', invalid='ignore'): + if u_zmin is not None and u_zmin > 0: + u_zmin = float(np.log10(u_zmin)) + elif u_zmin is not None: + u_zmin = None # invalid: drop + if u_zmax is not None and u_zmax > 0: + u_zmax = float(np.log10(u_zmax)) + elif u_zmax is not None: + u_zmax = None # invalid: drop + if u_zmin is not None: + z_vmin = u_zmin + if u_zmax is not None: + z_vmax = u_zmax + # Ensure a valid pair. Handle edge cases: + # - inverted user limits (zmin > zmax): swap silently + # - constant Z data (zmin == zmax): pad by a small epsilon so + # Normalize doesn't collapse to a single colour + if z_vmin is not None and z_vmax is not None: + if z_vmin > z_vmax: + z_vmin, z_vmax = z_vmax, z_vmin + if z_vmin == z_vmax: + eps = max(abs(z_vmin), 1.0) * 1e-6 + z_vmin -= eps + z_vmax += eps + z_norm = mcolors.Normalize(vmin=z_vmin, vmax=z_vmax) + elif z_vmin is not None or z_vmax is not None: + # Partial limits — build norm with what we have + z_norm = mcolors.Normalize(vmin=z_vmin, vmax=z_vmax) z_label_combined = ' / '.join(unique(z_label_parts)) - if logZ and use3D: + if logZ: z_label_combined = 'log\u2081\u2080(' + z_label_combined + ')' iPlot = -1 @@ -2333,7 +2714,13 @@ def plotSignals(self, ax, axis_idx, PD, pm, left_right, opts): pd=PD[signal_idx] if do_plot: iPlot+=1 - hasZ = pd.z is not None and not pd.zIsString + # Only treat as Z-coloured if z is present AND aligned with x + # (transforms like FFT/PDF reshape x but not z). + hasZ = (pd.z is not None + and not pd.zIsString + and not getattr(pd, 'zIsDate', False) + and pd.x is not None + and len(pd.z) == len(pd.x)) if hasZ and use3D: # 3D plot: x, y, z as spatial axes # Apply log-z via data transformation (Axes3D does not support set_zscale) @@ -2341,11 +2728,10 @@ def plotSignals(self, ax, axis_idx, PD, pm, left_right, opts): if logZ: with np.errstate(divide='ignore', invalid='ignore'): z_plot = np.log10(z_plot) - if flipZ: - try: - axis.invert_zaxis() - except Exception: - pass + # NOTE: flipZ is applied once per axis after the signal + # loop, NOT here. invert_zaxis() toggles state, so calling + # it per-signal would cancel out for an even number of + # Z-coloured signals. try: if plot3DType == 'Surf': sc = axis.plot_trisurf(pd.x, pd.y, z_plot, cmap=colormap, alpha=0.85, @@ -2364,11 +2750,15 @@ def plotSignals(self, ax, axis_idx, PD, pm, left_right, opts): pass elif hasZ: # 2D scatter coloured by Z variable – use shared norm for consistent colours + z_color = np.asarray(pd.z, dtype=float) + if logZ: + with np.errstate(divide='ignore', invalid='ignore'): + z_color = np.log10(z_color) try: if opts.get('plot2DZType') == 'Scatter+Line': axis.plot(pd.x, pd.y, color='#808080', alpha=0.4, lw=opts['lw'], zorder=1) - sc = axis.scatter(pd.x, pd.y, c=pd.z, cmap=colormap, + sc = axis.scatter(pd.x, pd.y, c=z_color, cmap=colormap, label=pd.syl, s=opts['ms']**2, norm=z_norm, zorder=2) except Exception: try: @@ -2397,8 +2787,16 @@ def plotSignals(self, ax, axis_idx, PD, pm, left_right, opts): except: pass # Dates or strings - # --- Single colorbar for all Z-coloured signals on this axis - if showColorBar and _z_signals_pd and not _colorbar_added: + # --- Single colorbar for all Z-coloured signals on this axis. + # Skip when z_norm is None (no finite z data and no user limits), + # otherwise ScalarMappable would build a misleading default 0..1 bar. + # Also skip when plotSignals already added one on this physical + # subplot (e.g. twinx right axis coming in after the left axis). + # Both bars would crowd the same subplot with effectively the same + # information for the user. + already = getattr(ax, '_colorbar_added', False) + if (showColorBar and _z_signals_pd and not _colorbar_added + and z_norm is not None and axis is not None and not already): try: import matplotlib.cm as mcm, matplotlib.colors as mcolors sm = mcm.ScalarMappable(norm=z_norm, cmap=colormap) @@ -2408,6 +2806,10 @@ def plotSignals(self, ax, axis_idx, PD, pm, left_right, opts): else: self.fig.colorbar(sm, ax=axis, label=z_label_combined) _colorbar_added = True + try: + ax._colorbar_added = True + except Exception: + pass except Exception: pass @@ -2669,6 +3071,8 @@ def redraw_same_data(self, force_autoscale=False): def _store_limits(self): self.xlim_prev = [] self.ylim_prev = [] + self.zlim_prev = [] + self.view3d_prev = [] for ax in self.fig.axes: try: self.xlim_prev.append(ax.get_xlim_()) @@ -2676,14 +3080,60 @@ def _store_limits(self): except AttributeError: self.xlim_prev.append((0, 1)) self.ylim_prev.append((0, 1)) + # Capture 3D-only state so redraws don't silently discard it. + if hasattr(ax, 'set_zlim'): + try: + self.zlim_prev.append(tuple(ax.get_zlim())) + except Exception: + self.zlim_prev.append(None) + else: + self.zlim_prev.append(None) + if hasattr(ax, 'view_init'): + try: + self.view3d_prev.append((float(ax.elev), float(ax.azim))) + except Exception: + self.view3d_prev.append(None) + else: + self.view3d_prev.append(None) + # Mirror the latest interactive rotation back into the pending slots + # so that the next set_subplots (which rebuilds the 3D axes from + # scratch) keeps the user's view angle instead of snapping back to + # matplotlib's default. Only copy the first 3D axis — that's what the + # existing view-save path does. + for vs in self.view3d_prev: + if vs is not None: + try: + self.colorPanel._pending_elev = vs[0] + self.colorPanel._pending_azim = vs[1] + except Exception: + pass + break def _restore_limits(self): - for ax, xlim, ylim in zip(self.fig.axes, self.xlim_prev, self.ylim_prev): + axes = list(self.fig.axes) + for i, ax in enumerate(axes): + if i >= len(self.xlim_prev): + break try: - ax.set_xlim_(xlim) - ax.set_ylim_(ylim) + ax.set_xlim_(self.xlim_prev[i]) + ax.set_ylim_(self.ylim_prev[i]) except AttributeError: pass + if (hasattr(ax, 'set_zlim') + and i < len(self.zlim_prev) + and self.zlim_prev[i] is not None): + try: + ax.set_zlim(self.zlim_prev[i]) + except Exception: + pass + if (hasattr(ax, 'view_init') + and i < len(self.view3d_prev) + and self.view3d_prev[i] is not None): + try: + elev, azim = self.view3d_prev[i] + ax.view_init(elev=elev, azim=azim) + except Exception: + pass if __name__ == '__main__': import pandas as pd; From fbc42ec786541291970083746e67aaf24cc5ee34 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 4 May 2026 12:50:44 +0000 Subject: [PATCH 21/41] Add Ctrl+V paste and context-aware Ctrl+C copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ctrl+V (paste): accepts file paths (data, .pdvview, images) or raw bitmaps from the clipboard. File paths are routed through a shared _routeFilenames helper (also used by drag-and-drop). Shift+Ctrl+V adds to existing tables instead of replacing. All pasted files appear in the Recent Files menu. Ctrl+C (copy): dispatches based on last-focused pane: - Columns list → TSV of selected X/Y/Z columns across tables - Tables list → TSV of all columns for selected tables - Plot canvas → PNG bitmap of the current figure - Stats panel → reuses existing CopyToClipBoard method Implementation uses EVT_CHAR_HOOK (not AcceleratorTable) so native Ctrl+C/V in TextCtrl/SearchCtrl/ComboBox is preserved. Visible File menu entries added for discoverability. Status bar feedback on every successful paste/copy. Removes the auto-copy-on-select binding in GUIInfoPanel that silently overwrote the clipboard on every stats row click. https://claude.ai/code/session_01RusnXtXqo9DDz4dX2rUhXB --- pydatview/GUIInfoPanel.py | 4 +- pydatview/main.py | 354 +++++++++++++++++++++++++++++++++++--- 2 files changed, 337 insertions(+), 21 deletions(-) diff --git a/pydatview/GUIInfoPanel.py b/pydatview/GUIInfoPanel.py index 3a8fc2f..0e44527 100644 --- a/pydatview/GUIInfoPanel.py +++ b/pydatview/GUIInfoPanel.py @@ -244,7 +244,9 @@ def __init__(self, parent, data=None): self.tbStats.SetFont(getMonoFont(self)) # For sorting see wx/lib/mixins/listctrl.py listmix.ColumnSorterMixin #self.tbStats.Bind(wx.EVT_LIST_COL_CLICK, self.CopyToClipBoard) - self.tbStats.Bind(wx.EVT_LIST_ITEM_SELECTED, self.CopyToClipBoard) + # NOTE: auto-copy on row select was removed — it silently overwrote + # the system clipboard. Stats are now copied explicitly via Ctrl+C + # (the frame-level handler calls CopyToClipBoard). # self.tbStats.Bind(wx.EVT_RIGHT_UP, self.ShowPopup) diff --git a/pydatview/main.py b/pydatview/main.py index 2969de7..9a93b1a 100644 --- a/pydatview/main.py +++ b/pydatview/main.py @@ -34,6 +34,7 @@ from pydatview.GUISelectionPanel import _tab_shortname from pydatview.GUIPipelinePanel import PipelinePanel from pydatview.GUIToolBox import GetKeyString, TBAddTool +from pydatview.GUIPlotPanel import IMAGE_EXTS from pydatview.Tables import TableList, Table # Helper from pydatview.common import exception2string, PyDatViewException @@ -66,8 +67,20 @@ +def _toCell(v): + """Format a single value for TSV clipboard output.""" + if v is None: + return '' + try: + if isinstance(v, float): + return repr(v) + return str(v) + except Exception: + return '' + + # --------------------------------------------------------------------------------} -# --- Drag and drop +# --- Drag and drop # --------------------------------------------------------------------------------{ # Implement File Drop Target class class FileDropTarget(wx.FileDropTarget): @@ -80,16 +93,8 @@ def OnDropFiles(self, x, y, filenames): filenames.sort() if len(filenames) == 0: return True - # View files are handled separately - view_files = [f for f in filenames if f.lower().endswith(VIEW_FILE_EXT)] - data_files = [f for f in filenames if not f.lower().endswith(VIEW_FILE_EXT)] - if view_files: - self.parent.load_view_file(view_files[0]) - elif data_files: - bAdd = wx.GetKeyState(wx.WXK_CONTROL) - iFormat = self.parent.comboFormats.GetSelection() - Format = None if iFormat == 0 else self.parent.FILE_FORMATS[iFormat-1] - self.parent.load_files(data_files, fileformats=[Format]*len(data_files), bAdd=bAdd, bPlot=True) + bAdd = wx.GetKeyState(wx.WXK_CONTROL) + self.parent._routeFilenames(filenames, bAdd=bAdd) return True @@ -149,6 +154,12 @@ def __init__(self, data=None): loadMenuItem = fileMenu.Append(wx.ID_NEW,"&Open file\tCtrl+O" ,"Open file" ) reloadMenuItem= fileMenu.Append(wx.ID_ANY,"&Reload\tCtrl+R" ,"Reload current files" ) addMenuItem = fileMenu.Append(wx.ID_ANY,"&Add file\tCtrl+A" ,"Add file to current data" ) + # Menu label has no accelerator — Ctrl+V/Ctrl+C are routed via + # EVT_CHAR_HOOK so TextCtrls keep native clipboard behavior. Adding + # the shortcut here as a menu accelerator would steal paste from the + # filter box and axis-limit text fields. + pasteMenuItem = fileMenu.Append(wx.ID_ANY,"&Paste (Ctrl+V)" ,"Paste files, view, or image from clipboard (Shift+Ctrl+V to add)") + copyMenuItem = fileMenu.Append(wx.ID_ANY,"&Copy (Ctrl+C)" ,"Copy current selection or plot to clipboard") self.recentFilesMenu = wx.Menu() fileMenu.AppendSubMenu(self.recentFilesMenu, 'Recent Files') fileMenu.AppendSeparator() @@ -157,13 +168,15 @@ def __init__(self, data=None): saveMenuItem = fileMenu.Append(wx.ID_SAVE,"Save figure" ,"Save figure" ) exitMenuItem = fileMenu.Append(wx.ID_EXIT, 'Quit', 'Quit application') menuBar.Append(fileMenu, "&File") - self.Bind(wx.EVT_MENU,self.onExit ,exitMenuItem) - self.Bind(wx.EVT_MENU,self.onLoad ,loadMenuItem) - self.Bind(wx.EVT_MENU,self.onReload ,reloadMenuItem) - self.Bind(wx.EVT_MENU,self.onAdd ,addMenuItem) - self.Bind(wx.EVT_MENU,self.onScript ,scrpMenuItem) - self.Bind(wx.EVT_MENU,self.onExport ,exptMenuItem) - self.Bind(wx.EVT_MENU,self.onSave ,saveMenuItem) + self.Bind(wx.EVT_MENU,self.onExit ,exitMenuItem) + self.Bind(wx.EVT_MENU,self.onLoad ,loadMenuItem) + self.Bind(wx.EVT_MENU,self.onReload ,reloadMenuItem) + self.Bind(wx.EVT_MENU,self.onAdd ,addMenuItem) + self.Bind(wx.EVT_MENU,self.onPasteGlobal,pasteMenuItem) + self.Bind(wx.EVT_MENU,self.onCopyGlobal ,copyMenuItem) + self.Bind(wx.EVT_MENU,self.onScript ,scrpMenuItem) + self.Bind(wx.EVT_MENU,self.onExport ,exptMenuItem) + self.Bind(wx.EVT_MENU,self.onSave ,saveMenuItem) # --- Data Plugins # NOTE: very important, need "s_loc" otherwise the lambda function take the last toolName @@ -312,12 +325,302 @@ def __init__(self, data=None): (wx.ACCEL_CTRL, ord('F'), idFilter), ]) self.SetAcceleratorTable(accel_tbl) + # Ctrl+V / Ctrl+C via EVT_CHAR_HOOK so TextCtrls keep native behavior + self._lastActive = None + self._focusTrackingInstalled = False + self.Bind(wx.EVT_CHAR_HOOK, self.onCharHook) def onFilter(self,event): if hasattr(self,'selPanel'): self.selPanel.colPanel1.tFilter.SetFocus() event.Skip() + # -------------------------------------------------------------------------------- + # --- Keyboard shortcuts: Ctrl+V paste, Ctrl+C context-aware copy + # -------------------------------------------------------------------------------- + def onCharHook(self, event): + """Frame-level key dispatcher. Defers to TextCtrl/ComboBox natively.""" + key = event.GetKeyCode() + ctrl = event.ControlDown() or event.CmdDown() + if not ctrl or key not in (ord('C'), ord('V')): + event.Skip() + return + # Don't steal Ctrl+C/V from native text widgets + focus = wx.Window.FindFocus() + if isinstance(focus, (wx.TextCtrl, wx.SearchCtrl, wx.ComboBox)): + event.Skip() + return + if key == ord('V'): + self.onPasteGlobal(event) + else: + self.onCopyGlobal(event) + + def _routeFilenames(self, filenames, bAdd=False): + """Split filenames into view/image/data files and dispatch each. + + Used by both drag-and-drop and Ctrl+V paste so both paths behave the + same way. All files loaded are tracked in the Recent Files menu via + the loaders they hit. + """ + filenames = [f for f in filenames if not os.path.isdir(f)] + if not filenames: + return 0, 0, 0 + view_files = [f for f in filenames if f.lower().endswith(VIEW_FILE_EXT)] + image_files = [f for f in filenames if f.lower().endswith(IMAGE_EXTS)] + data_files = [f for f in filenames + if not f.lower().endswith(VIEW_FILE_EXT) + and not f.lower().endswith(IMAGE_EXTS)] + # View file: only the first is meaningful + if view_files: + self.load_view_file(view_files[0]) + # Image files: each becomes a background + for p in image_files: + self._loadBgImageFromPath(p) + # Data files: one batched call + if data_files: + iFormat = self.comboFormats.GetSelection() + Format = None if iFormat == 0 else self.FILE_FORMATS[iFormat-1] + self.load_files(data_files, fileformats=[Format]*len(data_files), + bAdd=bAdd, bPlot=True) + return len(view_files), len(image_files), len(data_files) + + def _loadBgImageFromPath(self, path): + """Load an image file as plot background and track in recent files.""" + if not hasattr(self, 'plotPanel'): + Warn(self, 'Plot panel not ready yet — load a data file first.') + return False + try: + import matplotlib.image as mpimg + img = mpimg.imread(path) + except Exception as e: + Error(self, 'Failed to load image:\n{}'.format(str(e))) + return False + self.plotPanel._setBgImage(img) + self._track_recent(path) + return True + + def onPasteGlobal(self, event): + """Ctrl+V: paste files, view file, or image from the clipboard.""" + bAdd = wx.GetKeyState(wx.WXK_SHIFT) + if not wx.TheClipboard.Open(): + Warn(self, 'Could not open clipboard.') + return + try: + # 1) File paths + file_data = wx.FileDataObject() + if wx.TheClipboard.GetData(file_data): + filenames = list(file_data.GetFilenames()) + if filenames: + nv, ni, nd = self._routeFilenames(filenames, bAdd=bAdd) + parts = [] + if nv: parts.append('{} view file'.format(nv)) + if ni: parts.append('{} image{}'.format(ni, '' if ni==1 else 's')) + if nd: parts.append('{} data file{}'.format(nd, '' if nd==1 else 's')) + if parts: + self.statusbar.SetStatusText('Pasted ' + ', '.join(parts), ISTAT) + return + # 2) Bitmap from clipboard (e.g. a screenshot) + bmp_data = wx.BitmapDataObject() + if wx.TheClipboard.GetData(bmp_data): + if hasattr(self, 'plotPanel'): + # Close here so onPasteBgImage can re-open the clipboard + wx.TheClipboard.Close() + self.plotPanel.onPasteBgImage(None) + self.statusbar.SetStatusText( + 'Pasted background image from clipboard', ISTAT) + return + else: + Warn(self, 'Plot panel not ready yet — load a data file first.') + return + # 3) Text — maybe a file path + text_data = wx.TextDataObject() + if wx.TheClipboard.GetData(text_data): + text = text_data.GetText().strip().strip('"').strip("'") + if text and os.path.isfile(text): + nv, ni, nd = self._routeFilenames([text], bAdd=bAdd) + if (nv + ni + nd) > 0: + self.statusbar.SetStatusText( + 'Pasted "{}"'.format(os.path.basename(text)), ISTAT) + return + Warn(self, 'Clipboard has no supported content (files, image, or path).') + finally: + try: + wx.TheClipboard.Close() + except Exception: + pass + + def _ensureFocusTracking(self): + """Install EVT_SET_FOCUS handlers on the widgets that Ctrl+C dispatches on.""" + if self._focusTrackingInstalled: + return + if not (hasattr(self, 'selPanel') and hasattr(self, 'plotPanel') + and hasattr(self, 'infoPanel')): + return + def _setActive(key): + def handler(event): + self._lastActive = key + event.Skip() + return handler + try: + for cp in (getattr(self.selPanel, 'colPanel1', None), + getattr(self.selPanel, 'colPanel2', None), + getattr(self.selPanel, 'colPanel3', None)): + if cp is not None and hasattr(cp, 'lbColumns'): + cp.lbColumns.Bind(wx.EVT_SET_FOCUS, _setActive('columns')) + tp = getattr(self.selPanel, 'tabPanel', None) + if tp is not None and hasattr(tp, 'lbTab'): + tp.lbTab.Bind(wx.EVT_SET_FOCUS, _setActive('tables')) + if hasattr(self.infoPanel, 'tbStats'): + self.infoPanel.tbStats.Bind(wx.EVT_SET_FOCUS, _setActive('stats')) + canvas = getattr(self.plotPanel, 'canvas', None) + if canvas is not None: + canvas.Bind(wx.EVT_SET_FOCUS, _setActive('plot')) + canvas.Bind(wx.EVT_LEFT_DOWN, lambda e: ( + setattr(self, '_lastActive', 'plot'), e.Skip())) + self._focusTrackingInstalled = True + except Exception as e: + print('[WARN] Failed to install focus tracking: {}'.format(e)) + + def _inferLastActive(self): + """Fallback — walk the focused widget's parents to infer the pane.""" + focus = wx.Window.FindFocus() + w = focus + while w is not None: + if hasattr(self, 'infoPanel') and w is self.infoPanel: + return 'stats' + if hasattr(self, 'plotPanel') and w is self.plotPanel: + return 'plot' + if hasattr(self, 'selPanel') and w is self.selPanel: + tp = getattr(self.selPanel, 'tabPanel', None) + # If focus is inside tabPanel, treat as tables; else columns + w2 = focus + while w2 is not None: + if w2 is tp: + return 'tables' + w2 = w2.GetParent() + return 'columns' + w = w.GetParent() + return None + + def onCopyGlobal(self, event): + """Ctrl+C: copy based on the last focused/clicked pane.""" + if not hasattr(self, 'plotPanel'): + return + active = self._lastActive or self._inferLastActive() + if active == 'stats' and hasattr(self, 'infoPanel'): + self.infoPanel.CopyToClipBoard(event) + self.statusbar.SetStatusText('Copied stats to clipboard', ISTAT) + return + if active == 'columns': + self._copyColumnsSelection() + return + if active == 'tables': + self._copyTablesSelection() + return + if active == 'plot': + self._copyPlotBitmap() + return + Warn(self, 'Click a pane (columns, tables, plot, or stats) first, then Ctrl+C.') + + def _copyColumnsSelection(self): + """Copy X, selected Y (and Z if set) columns for each selected table.""" + if not hasattr(self, 'selPanel'): + return + ITab, _ = self.selPanel.getSelectedTables() + if not ITab: + Warn(self, 'No tables selected.') + return + cp = self.selPanel.colPanel1 + iX, IY, sX, SY = cp.getColumnSelection() + try: + iZ, sZ = cp.getZColumnSelection() + except Exception: + iZ, sZ = -1, '' + col_indices = [iX] + list(IY) + if iZ >= 0 and iZ not in col_indices: + col_indices.append(iZ) + if not col_indices: + Warn(self, 'No columns selected.') + return + self._copyTablesColumnsToClipboard(ITab, col_indices, + context='columns') + + def _copyTablesSelection(self): + """Copy all columns for each selected table.""" + if not hasattr(self, 'selPanel'): + return + ITab, _ = self.selPanel.getSelectedTables() + if not ITab: + Warn(self, 'No tables selected.') + return + self._copyTablesColumnsToClipboard(ITab, None, context='tables') + + def _copyTablesColumnsToClipboard(self, ITab, col_indices, context='columns'): + """Build a tab-separated grid of selected columns and copy it.""" + try: + headers = [] + columns = [] # list of string-lists + for iTab in ITab: + try: + tab = self.tabList[iTab] + except Exception: + continue + tab_name = getattr(tab, 'active_name', None) or getattr(tab, 'raw_name', '') or 'table' + if col_indices is None: + ncols = len(tab.data.columns) + idxs = list(range(ncols)) + else: + idxs = [i for i in col_indices if 0 <= i < len(tab.data.columns)] + for i in idxs: + try: + x, _isStr, _isDate, c = tab.getColumn(i) + except Exception: + continue + col_name = str(tab.data.columns[i]) + headers.append('{}:{}'.format(tab_name, col_name)) + columns.append([_toCell(v) for v in x]) + if not columns: + Warn(self, 'Nothing to copy.') + return + nrows = max(len(c) for c in columns) + lines = ['\t'.join(headers)] + for r in range(nrows): + row = [(columns[c][r] if r < len(columns[c]) else '') + for c in range(len(columns))] + lines.append('\t'.join(row)) + text = '\n'.join(lines) + if wx.TheClipboard.Open(): + try: + wx.TheClipboard.SetData(wx.TextDataObject(text)) + finally: + wx.TheClipboard.Close() + self.statusbar.SetStatusText( + 'Copied {} tables x {} columns'.format(len(ITab), len(columns)), + ISTAT) + except Exception as e: + Error(self, 'Failed to copy to clipboard:\n{}'.format(str(e))) + + def _copyPlotBitmap(self): + """Copy the current plot figure to the clipboard as a bitmap.""" + if not hasattr(self, 'plotPanel'): + return + try: + import io as _io + buf = _io.BytesIO() + self.plotPanel.fig.savefig(buf, format='png', + dpi=self.plotPanel.fig.dpi) + buf.seek(0) + img = wx.Image(buf, wx.BITMAP_TYPE_PNG) + bmp = img.ConvertToBitmap() + if wx.TheClipboard.Open(): + try: + wx.TheClipboard.SetData(wx.BitmapDataObject(bmp)) + finally: + wx.TheClipboard.Close() + self.statusbar.SetStatusText('Copied figure to clipboard', ISTAT) + except Exception as e: + Error(self, 'Failed to copy figure to clipboard:\n{}'.format(str(e))) + def clean_memory(self,bReload=False): #print('Clean memory') # force Memory cleanup @@ -384,6 +687,7 @@ def load_files(self, filenames=[], fileformats=None, bReload=False, bAdd=False, # Load tables into the GUI if self.tabList.len()>0: self.load_tabs_into_GUI(bReload=bReload, bAdd=bAdd, bPlot=bPlot) + self._ensureFocusTracking() def load_dfs(self, dfs, names=None, bAdd=False, bPlot=True): """ Load one or multiple dataframes intoGUI """ @@ -398,6 +702,7 @@ def load_dfs(self, dfs, names=None, bAdd=False, bPlot=True): self.load_tabs_into_GUI(bAdd=bAdd, bPlot=bPlot) if hasattr(self,'selPanel'): self.selPanel.updateLayout(SEL_MODES_ID[self.comboMode.GetSelection()]) + self._ensureFocusTracking() def load_tabs_into_GUI(self, bReload=False, bAdd=False, bPlot=True): if self.nb.GetPageCount()==0: @@ -901,9 +1206,18 @@ def _populateRecentFilesMenu(self): emptyItem.Enable(False) else: for path in recent: - item = self.recentFilesMenu.Append(wx.ID_ANY, path) - if path.endswith(VIEW_FILE_EXT): + low = path.lower() + if low.endswith(VIEW_FILE_EXT): + label = '[view] {}'.format(path) + elif low.endswith(IMAGE_EXTS): + label = '[bg] {}'.format(path) + else: + label = path + item = self.recentFilesMenu.Append(wx.ID_ANY, label) + if low.endswith(VIEW_FILE_EXT): self.Bind(wx.EVT_MENU, lambda e, p=path: self.load_view_file(p), item) + elif low.endswith(IMAGE_EXTS): + self.Bind(wx.EVT_MENU, lambda e, p=path: self._loadBgImageFromPath(p), item) else: self.Bind(wx.EVT_MENU, lambda e, p=path: self.load_files([p]), item) From 60b0c7cc9e3c3d11545d96d36d7751a55be176a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 4 May 2026 21:03:06 +0000 Subject: [PATCH 22/41] docs: document background image, axis limits, paste/copy, recent files, 3D controls - Append 3D navigation note (Rotate, plane views, Home) to Z/color bullet - Add 5 new Workflow bullets: BG image, manual axis limits, Ctrl+V paste, context-aware Ctrl+C copy, Recent Files - Add 4 bullets to Features list covering same new capabilities - Remove duplicate Z/color and 3D view entries from Plot options section https://claude.ai/code/session_01TRfZkFW9YczDyobkh3cndo --- README.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 12c7b39..0616039 100644 --- a/README.md +++ b/README.md @@ -111,10 +111,15 @@ 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. + - **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. In 3D mode, use the *Rotate* toggle button to switch between rotate and pan; the *x-y*, *y-z*, *x-z* buttons snap to orthographic plane views; *Home* resets the camera. - 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. - **Views** allow you to save and restore a complete selection state (tables, channels, plot type, and plot settings). Use the **Views** menu to save the current view under a name; previously saved views appear in the toolbar drop-down and can be restored with one click. Views are stored in the session data and survive a reload. If a table or channel referenced by a saved view is not available at restore time (e.g. file not yet loaded, or a channel was renamed), the view is restored as fully as possible and a warning lists what could not be matched. - **Portable views (`.pdvview` files)**: use **Views > Export view to file** to write the current view — including the list of source files and all settings — into a single `.pdvview` JSON file. File paths are stored relative to the view file, so the whole folder can be shared or moved. To reload: drag-and-drop the `.pdvview` file onto pyDatView, or use **Views > Import view from file**. Missing source files are reported; available ones are loaded and the view is restored. + - **Background image** (BG toolbar button): load an image from file or paste from clipboard. *Fixed* mode anchors the image to the plot area regardless of zoom/pan; *Moving* mode ties it to data coordinates. Use the Clear option to remove it. + - **Manual axis limits**: open the Esthetics panel (button next to the Save icon) to set `xmin`, `xmax`, `ymin`, `ymax` — and `zmin`, `zmax` when a Z variable is active. Leave a field blank for automatic scaling. + - **Paste** (`Ctrl+V`): accepts file paths (data files, `.pdvview`, images) or bitmap images from the clipboard. `Shift+Ctrl+V` appends to the current file list instead of replacing it. Pasted files appear in Recent Files. + - **Copy** (`Ctrl+C`): context-aware — column panel → selected columns as TSV; tables panel → all columns for selected tables as TSV; plot canvas → PNG bitmap of the figure; stats panel → statistics table. + - **Recent Files** (File menu): tracks the last 30 opened data files, imported `.pdvview` files, and exported files for quick re-opening. @@ -129,6 +134,10 @@ Main features: - Export data as csv, or other file formats - Save and restore named views (table selection, channels, plot type, and aesthetics) - Export/import portable view files (`.pdvview`) that bundle file references and settings; loadable by drag-and-drop +- Background image overlay behind the plot (Fixed or Moving mode, load from file or paste from clipboard) +- Manual axis and color-scale limits (`xmin`/`xmax`/`ymin`/`ymax`/`zmin`/`zmax`) in the Esthetics panel +- Context-aware `Ctrl+C` copy (columns → TSV, plot → PNG, stats → clipboard) and `Ctrl+V` paste (file paths, images) +- Recent Files submenu (last 30 data files, view files, and exported files) Different kind of plots: - Scatter plots or line plots @@ -144,8 +153,7 @@ 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 +- Z/color variable and 3D scatter: see "Different kind of plots" above Data manipulation options: - Remove columns in a table, add columns using a given formula, and export the table to csv From cd14d68fee5c04f3318096e50a33acc843473303 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 5 May 2026 12:38:04 +0000 Subject: [PATCH 23/41] fix: pre-merge review fixes and close test gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit W2 — GUIPlotPanel.py: add missing '1.75' to LWChoices in restoreViewData; restoring a view saved with LineWidth=1.75 previously raised ValueError. W3 — GUIPlotPanel.py: captureViewData now reads plot3D_type from the live cbCurveType widget (not the always-hidden cbPlot3D), so 3D type is correctly captured when saving a view in 3D mode. W1 — main.py: remove dead onRestoreViewFromCombo method; it referenced a comboViews widget that was never created (views are restored via menu lambdas calling onRestoreView directly). M1 — GUISelectionPanel.py: fix off-by-one iFilt<=len → iFilt=0 and iFilt<=len(columnsY): + if iFilt>=0 and iFilt Date: Tue, 5 May 2026 21:19:01 +0000 Subject: [PATCH 24/41] =?UTF-8?q?fix:=203D=20panning=20=E2=80=94=20correct?= =?UTF-8?q?=20behaviour=20and=20tooltip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs fixed: 1. _toggle_rotate (GUIToolBox.py): when the Rotate toggle was turned OFF it activated zoom mode, so left-drag box-zoomed rather than panned. Now it activates pan mode instead, making the tooltip text ("When off: left-drag pans, right-drag zooms") accurate. 2. _patch_3d_ctrl_rotate (GUIPlotPanel.py): the pan implementation set ax.button_pressed=2 hoping Axes3D would pan, but button 2 triggers zoom in modern matplotlib. Replaced with a proper manual pan: on left-press in pan mode the start position and axis limits are stored; on each mouse-move the screen delta is projected onto the camera's screen-right / screen-up vectors (derived from ax.azim / ax.elev) and applied as a shift to xlim3d / ylim3d / zlim3d. x/y/z key constraints are preserved. A button-release handler clears the pan state. A fallback motion handler is added for old matplotlib builds where _on_move is not found in canvas callbacks. https://claude.ai/code/session_01GNbfFRd1uPxiEHSHYZkE6t --- pydatview/GUIPlotPanel.py | 141 +++++++++++++++++++++++++------------- pydatview/GUIToolBox.py | 8 ++- 2 files changed, 97 insertions(+), 52 deletions(-) diff --git a/pydatview/GUIPlotPanel.py b/pydatview/GUIPlotPanel.py index 731ca51..8729a3f 100644 --- a/pydatview/GUIPlotPanel.py +++ b/pydatview/GUIPlotPanel.py @@ -68,39 +68,72 @@ def _patch_3d_ctrl_rotate(ax, canvas, toolbar=None): Left-drag rotates the 3D view (default Axes3D behaviour). Pan mode (toolbar.pan_on): - Left-drag pans the 3D view — matching the 2D toolbar behaviour. - Right-drag zooms (Axes3D built-in button=3 behaviour). + Left-drag pans by shifting axis limits using the current view direction. + Right-drag uses Axes3D native right-button behaviour. Hold 'x', 'y', or 'z' while dragging to constrain pan to that axis. Neither active: All mouse-drag suppressed (no accidental rotation/pan). - - Two-layer approach for robustness across matplotlib versions: - - Layer 0 – Axes3D._button_press wrapper: - When pan mode is active and user presses left button, we override - ax.button_pressed from 1 (rotate) to 2 (pan) so that the existing - Axes3D._on_move handler produces a pan rather than a rotation. - - Layer 1 – ax.drag_pan instance-level patch: - NavigationToolbar2 calls ax.drag_pan(button, key, x, y) during - toolbar-active drag. Our wrapper allows rotation only when rotate - mode is ON, and suppresses it in all other cases. - - Layer 2 – Axes3D._on_move canvas-callback wrapper: - Wraps the motion handler so that: - • rotate mode → call original (rotation happens normally) - • pan mode → call original with axis-constraint support - • neither mode → suppress """ + import math + def _rotate_active(): return getattr(toolbar, 'rotate_on', False) def _pan_active(): return getattr(toolbar, 'pan_on', False) + # Tracks state for the manual 3D pan gesture (left-drag in pan mode). + _pan_state = {'active': False, 'x0': 0.0, 'y0': 0.0, + 'xlim': None, 'ylim': None, 'zlim': None} + + def _do_pan_move(event, _ps=_pan_state): + """Shift 3D axis limits so the scene follows the mouse drag.""" + if not _ps['active']: + return + try: + dx = event.x - _ps['x0'] + dy = event.y - _ps['y0'] + W, H = canvas.get_width_height() + if W == 0 or H == 0: + return + xlim = _ps['xlim'] + ylim = _ps['ylim'] + zlim = _ps['zlim'] + xr = xlim[1] - xlim[0] + yr = ylim[1] - ylim[0] + zr = zlim[1] - zlim[0] + # Normalised screen delta (matplotlib y increases upward). + nx = dx / W + ny = dy / H + az = math.radians(ax.azim) + el = math.radians(ax.elev) + # Screen-right unit vector in data space (horizontal, no z component). + rrx = -math.sin(az) + rry = math.cos(az) + # Screen-up unit vector in data space (camera up direction). + upx = -math.cos(az) * math.sin(el) + upy = -math.sin(az) * math.sin(el) + upz = math.cos(el) + # Shift limits so data follows the mouse (negative sign). + dpx = -(nx * rrx + ny * upx) * xr + dpy = -(nx * rry + ny * upy) * yr + dpz = -(ny * upz) * zr + key = getattr(event, 'key', None) + if key == 'x': + dpy = 0.0; dpz = 0.0 + elif key == 'y': + dpx = 0.0; dpz = 0.0 + elif key == 'z': + dpx = 0.0; dpy = 0.0 + ax.set_xlim3d(xlim[0] + dpx, xlim[1] + dpx) + ax.set_ylim3d(ylim[0] + dpy, ylim[1] + dpy) + ax.set_zlim3d(zlim[0] + dpz, zlim[1] + dpz) + canvas.draw_idle() + except Exception: + pass + # --- Layer 0: wrap Axes3D._button_press --- - # Redirect left-click to pan (button_pressed=2) when pan mode is active. press_cbs = getattr(canvas.callbacks, 'callbacks', {}).get('button_press_event', {}) for cid, val in list(press_cbs.items()): try: @@ -111,16 +144,29 @@ def _pan_active(): continue if getattr(func, '__self__', None) is ax: canvas.mpl_disconnect(cid) - def _wrapped_press(event, _orig=func): + def _wrapped_press(event, _orig=func, _ps=_pan_state): _orig(event) # Axes3D sets ax.button_pressed = event.button if event.inaxes == ax and event.button == 1 and _pan_active(): try: - ax.button_pressed = 2 # left-click → pan in Axes3D + # Suppress Axes3D rotation; pan is handled in _wrapped_move. + ax.button_pressed = None + _ps['active'] = True + _ps['x0'] = event.x + _ps['y0'] = event.y + _ps['xlim'] = list(ax.get_xlim3d()) + _ps['ylim'] = list(ax.get_ylim3d()) + _ps['zlim'] = list(ax.get_zlim3d()) except Exception: pass canvas.mpl_connect('button_press_event', _wrapped_press) break + # Clear pan state when the left button is released. + def _on_release(event, _ps=_pan_state): + if event.button == 1: + _ps['active'] = False + canvas.mpl_connect('button_release_event', _on_release) + # --- Layer 2: wrap Axes3D._on_move in the callback registry --- wrapped = [False] motion_cbs = getattr(canvas.callbacks, 'callbacks', {}).get('motion_notify_event', {}) @@ -138,27 +184,13 @@ def _wrapped_move(event, _orig=func): _orig(event) return if _rotate_active(): - # Rotate mode: allow normal Axes3D rotation _orig(event) elif _pan_active(): - # Pan mode: Axes3D pans because button_pressed was set to 2 - # in _wrapped_press. Support x/y/z key axis constraints. - key = getattr(event, 'key', None) - if key in ('x', 'y', 'z'): - try: - xlim = ax.get_xlim3d() - ylim = ax.get_ylim3d() - zlim = ax.get_zlim3d() - _orig(event) - if key == 'x': - ax.set_ylim3d(ylim); ax.set_zlim3d(zlim) - elif key == 'y': - ax.set_xlim3d(xlim); ax.set_zlim3d(zlim) - elif key == 'z': - ax.set_xlim3d(xlim); ax.set_ylim3d(ylim) - except Exception: - _orig(event) + if _pan_state['active']: + # Manual 3D pan via axis-limit shift. + _do_pan_move(event) else: + # Right-drag: native Axes3D behaviour (zoom / pan). _orig(event) # else: neither rotate nor pan — suppress all drag canvas.mpl_connect('motion_notify_event', _wrapped_move) @@ -167,13 +199,18 @@ def _wrapped_move(event, _orig=func): if not wrapped[0]: # Fallback for versions where _on_move isn't in canvas callbacks. - # In pan mode redirect left-click to button=2; otherwise clear button. - def _on_press(event): + def _on_press_fb(event, _ps=_pan_state): if event.inaxes != ax or event.button != 1: return if _pan_active(): try: - ax.button_pressed = 2 + ax.button_pressed = None + _ps['active'] = True + _ps['x0'] = event.x + _ps['y0'] = event.y + _ps['xlim'] = list(ax.get_xlim3d()) + _ps['ylim'] = list(ax.get_ylim3d()) + _ps['zlim'] = list(ax.get_zlim3d()) except Exception: pass elif not _rotate_active(): @@ -182,7 +219,14 @@ def _on_press(event): setattr(ax, attr, None) except Exception: pass - canvas.mpl_connect('button_press_event', _on_press) + canvas.mpl_connect('button_press_event', _on_press_fb) + + def _on_move_fb(event): + if event.inaxes != ax: + return + if _pan_active() and _pan_state['active']: + _do_pan_move(event) + canvas.mpl_connect('motion_notify_event', _on_move_fb) # --- Layer 1: patch ax.drag_pan at instance level --- # ax.__class__.drag_pan is the unbound function (Python 3). @@ -197,13 +241,12 @@ def _on_press(event): def _patched_drag_pan(button, key, x, y, _orig=_orig_drag_pan, _w=wrapped): if button == 1: - # Left-click: rotate only when rotate mode ON and _on_move absent + # Left-click: rotate only when rotate mode ON and _on_move absent. if _rotate_active() and not _w[0]: _orig(ax, button, key, x, y) - # Pan mode: pan driven by _on_move with button_pressed=2 + # Pan mode: handled in _wrapped_move / _on_move_fb. elif button == 3: - # Right-click zoom via drag_pan: only when no mode is active - # (in pan mode, zoom is driven by Axes3D._on_move with button=3) + # Right-click: native behaviour only when no mode is active. if not _rotate_active() and not _pan_active(): _orig(ax, button, key, x, y) else: diff --git a/pydatview/GUIToolBox.py b/pydatview/GUIToolBox.py index c46406a..85fedea 100644 --- a/pydatview/GUIToolBox.py +++ b/pydatview/GUIToolBox.py @@ -336,7 +336,7 @@ def __init__(self, canvas, keep_tools, plotPanel): pass def _toggle_rotate(self, event=None): - """Toggle rotate mode on/off. When on: drag rotates 3D axes; zoom/pan disabled.""" + """Toggle rotate mode on/off. When on: drag rotates 3D axes; when off: left-drag pans.""" self.rotate_on = not self.rotate_on if self.rotate_on: # Deactivate pan if active @@ -355,8 +355,10 @@ def _toggle_rotate(self, event=None): except Exception: pass else: - # Restore zoom mode - NavigationToolbar2.zoom(self) + # Activate pan mode when leaving rotate so left-drag pans (matches tooltip) + if not self.pan_on: + self.pan_on = True + NavigationToolbar2.pan(self) if self._rotate_tool_id is not None: self.ToggleTool(self._rotate_tool_id, self.rotate_on) From e5f27d494a1ab896f996bc4c6bd6650ba95608a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 5 May 2026 21:20:53 +0000 Subject: [PATCH 25/41] fix: use unique keys for same-basename tables in view save/restore When two files with the same basename were loaded from different directories (e.g. /dir1/data.csv and /dir2/data.csv), _tab_shortname returned 'data' for both. captureViewState keyed tabSelectionsFull by shortname so the second table's entry silently overwrote the first, causing wrong y-axis and z-axis selections after view restore. Add _unique_tab_keys(tab_list) which uses the portable shortname when it is unique across all loaded tables, and falls back to the full tab.name when two or more tables share the same shortname. Use this helper consistently for tabSelectedNames, tabSelectionsFull, and formulas_state in captureViewState. The restore path (_find_tab_by_key, t.name fallback in table-selection rebuild) already handles full-name keys correctly, so no restore-side changes are needed. https://claude.ai/code/session_01JQiNFHSjaNDv2VpPE2DzLV --- pydatview/GUISelectionPanel.py | 35 ++++++++++++--- tests/test_views.py | 82 ++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 7 deletions(-) diff --git a/pydatview/GUISelectionPanel.py b/pydatview/GUISelectionPanel.py index ed1b5ee..0a4002e 100644 --- a/pydatview/GUISelectionPanel.py +++ b/pydatview/GUISelectionPanel.py @@ -73,6 +73,24 @@ def _find_tab_by_key(tab_list, key): return None +def _unique_tab_keys(tab_list): + """Return {tab.name: key} for every table in tab_list. + + Uses the shortname (basename-only, portable) when it is unique across all + loaded tables. Falls back to the full tab.name when two or more tables + share the same shortname (e.g. same-named files from different directories) + so that each table gets a distinct key and no saved entry is silently + overwritten. + """ + from collections import Counter + shortnames = [_tab_shortname(t) for t in tab_list] + counts = Counter(shortnames) + return { + t.name: (sn if counts[sn] == 1 else t.name) + for t, sn in zip(tab_list, shortnames) + } + + def ireplace(text, old, new): """ Replace case insensitive """ try: @@ -1665,10 +1683,13 @@ def captureViewState(self): self.saveSelection() # ensure internal state is up-to-date ISel = self.tabSelected state = {} - # Save which tables are selected – store shortname (basename only) so - # the view file is portable across directories. - state['tabSelectedNames'] = [_tab_shortname(self.tabList[i]) for i in ISel if i < self.tabList.len()] - # Save column selections keyed by shortname, storing BOTH index and name + # Build per-table keys: shortname when unique, full name when two tables + # share the same basename (avoids silently overwriting saved entries). + tab_keys = _unique_tab_keys(self.tabList) + # Save which tables are selected using the same portable/unique keys. + state['tabSelectedNames'] = [tab_keys[self.tabList[i].name] for i in ISel if i < self.tabList.len()] + # Save column selections keyed by the portable/unique key, storing BOTH + # index and name so restore is resilient to column reordering. tabSelectionsFull = {} for k, v in self.tabSelections.items(): tab = next((t for t in self.tabList if t.name == k), None) @@ -1685,8 +1706,8 @@ def captureViewState(self): zSel = v.get('zSel', 0) zColIdx = zSel - 1 # convert comboZ index to column index zName = cols[zColIdx] if 0 <= zColIdx < len(cols) else None - short_k = _tab_shortname(tab) if tab is not None else k - tabSelectionsFull[short_k] = { + key = tab_keys.get(tab.name, k) if tab is not None else k + tabSelectionsFull[key] = { 'xSel': v['xSel'], 'ySel': list(v['ySel']), 'zSel': v.get('zSel', 0), @@ -1727,7 +1748,7 @@ def captureViewState(self): formulas_state = {} for tab in self.tabList: if tab.formulas: - formulas_state[_tab_shortname(tab)] = sorted(tab.formulas, key=lambda f: f['pos']) + formulas_state[tab_keys[tab.name]] = sorted(tab.formulas, key=lambda f: f['pos']) state['formulas'] = formulas_state return state diff --git a/tests/test_views.py b/tests/test_views.py index 1e16c4e..773aa04 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -777,5 +777,87 @@ def test_axis_limits_missing_in_old_view_defaults_empty(self): self.assertEqual(lims.get('zmax', ''), '') +class TestUniqueTabKeys(unittest.TestCase): + """Regression: same-basename tables from different dirs must get distinct keys.""" + + def _make_tab(self, name, filename): + """Minimal stand-in for a Table object.""" + class FakeTab: + pass + t = FakeTab() + t.name = name + t.filename = filename + return t + + def _tab_shortname(self, tab): + """Mirror of GUISelectionPanel._tab_shortname.""" + if not getattr(tab, 'filename', ''): + return tab.name + basename = os.path.splitext(os.path.basename(tab.filename))[0] + parts = tab.name.split('|') + try: + idx = parts.index(basename) + return '|'.join(parts[idx:]) + except ValueError: + return parts[-1] + + def _unique_tab_keys(self, tab_list): + """Mirror of GUISelectionPanel._unique_tab_keys.""" + from collections import Counter + shortnames = [self._tab_shortname(t) for t in tab_list] + counts = Counter(shortnames) + return { + t.name: (sn if counts[sn] == 1 else t.name) + for t, sn in zip(tab_list, shortnames) + } + + def test_distinct_basenames_use_shortnames(self): + """Different basenames → each tab gets its unique shortname.""" + t1 = self._make_tab('|dir|alpha', '/dir/alpha.csv') + t2 = self._make_tab('|dir|beta', '/dir/beta.csv') + keys = self._unique_tab_keys([t1, t2]) + self.assertEqual(keys[t1.name], 'alpha') + self.assertEqual(keys[t2.name], 'beta') + + def test_same_basename_uses_full_name(self): + """Same basename in different dirs → fall back to full tab.name to avoid collision.""" + t1 = self._make_tab('|dir1|data', '/dir1/data.csv') + t2 = self._make_tab('|dir2|data', '/dir2/data.csv') + keys = self._unique_tab_keys([t1, t2]) + # Keys must be distinct + self.assertNotEqual(keys[t1.name], keys[t2.name]) + # Each key must identify its table (either by shortname or full name) + self.assertEqual(keys[t1.name], t1.name) + self.assertEqual(keys[t2.name], t2.name) + + def test_same_basename_keys_are_unique_in_saved_dict(self): + """Simulates the captureViewState loop: no entry must be overwritten.""" + t1 = self._make_tab('|dir1|data', '/dir1/data.csv') + t2 = self._make_tab('|dir2|data', '/dir2/data.csv') + tab_keys = self._unique_tab_keys([t1, t2]) + + fake_selections = { + t1.name: {'xSel': 0, 'ySel': [1], 'zSel': 2}, + t2.name: {'xSel': 0, 'ySel': [2], 'zSel': 3}, + } + saved = {} + for k, v in fake_selections.items(): + key = tab_keys.get(k, k) + saved[key] = dict(v) + + self.assertEqual(len(saved), 2, 'Both tables must have separate entries') + # t1 entry should have ySel=[1], t2 entry ySel=[2] + entry1 = saved[tab_keys[t1.name]] + entry2 = saved[tab_keys[t2.name]] + self.assertEqual(entry1['ySel'], [1]) + self.assertEqual(entry2['ySel'], [2]) + + def test_single_table_still_uses_shortname(self): + """With only one table per basename, shortname is used (portable).""" + t = self._make_tab('|somepath|run', '/somepath/run.csv') + keys = self._unique_tab_keys([t]) + self.assertEqual(keys[t.name], 'run') + + if __name__ == '__main__': unittest.main() From 29b2785450306229b70701d5c6420a06f28dcdd5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 08:55:34 +0000 Subject: [PATCH 26/41] Fix 3D right-click zoom, pan/rotate mutual exclusivity, and tooltips - Right-drag in 3D pan or rotate mode now zooms (exponential scale about axis centre, matching matplotlib 2D convention) instead of doing nothing - Pan and Rotate buttons are now fully mutually exclusive: activating one forces the other off, with defensive ToggleTool calls to keep visuals in sync - Rotate-off no longer auto-activates pan; returns to default zoom mode - Tooltips updated: Pan shows When-on/When-off sections; Rotate shows "Rotation for 3D: left rotates, right zooms" https://claude.ai/code/session_01GNbfFRd1uPxiEHSHYZkE6t --- pydatview/GUIPlotPanel.py | 96 ++++++++++++++++++++++++++++++--------- pydatview/GUIToolBox.py | 32 +++++++++---- 2 files changed, 96 insertions(+), 32 deletions(-) diff --git a/pydatview/GUIPlotPanel.py b/pydatview/GUIPlotPanel.py index 8729a3f..7a70904 100644 --- a/pydatview/GUIPlotPanel.py +++ b/pydatview/GUIPlotPanel.py @@ -133,6 +133,34 @@ def _do_pan_move(event, _ps=_pan_state): except Exception: pass + # Tracks state for the manual 3D zoom gesture (right-drag in pan or rotate mode). + _zoom_state = {'active': False, 'x0': 0.0, 'y0': 0.0, + 'xlim': None, 'ylim': None, 'zlim': None} + + def _do_zoom_move(event, _zs=_zoom_state): + """Exponentially scale axis limits about their centre on right-drag.""" + if not _zs['active']: + return + try: + dx = event.x - _zs['x0'] + dy = event.y - _zs['y0'] + W, H = canvas.get_width_height() + if W == 0 or H == 0: + return + disp = -(dx / float(W) + dy / float(H)) + alpha = 10.0 ** disp # alpha < 1 → zoom in, alpha > 1 → zoom out + for lim, setter in ( + (_zs['xlim'], ax.set_xlim3d), + (_zs['ylim'], ax.set_ylim3d), + (_zs['zlim'], ax.set_zlim3d), + ): + mid = 0.5 * (lim[0] + lim[1]) + half = 0.5 * (lim[1] - lim[0]) * alpha + setter(mid - half, mid + half) + canvas.draw_idle() + except Exception: + pass + # --- Layer 0: wrap Axes3D._button_press --- press_cbs = getattr(canvas.callbacks, 'callbacks', {}).get('button_press_event', {}) for cid, val in list(press_cbs.items()): @@ -144,27 +172,39 @@ def _do_pan_move(event, _ps=_pan_state): continue if getattr(func, '__self__', None) is ax: canvas.mpl_disconnect(cid) - def _wrapped_press(event, _orig=func, _ps=_pan_state): + def _wrapped_press(event, _orig=func, _ps=_pan_state, _zs=_zoom_state): _orig(event) # Axes3D sets ax.button_pressed = event.button - if event.inaxes == ax and event.button == 1 and _pan_active(): + if event.inaxes != ax: + return + if event.button == 1 and _pan_active(): try: - # Suppress Axes3D rotation; pan is handled in _wrapped_move. ax.button_pressed = None _ps['active'] = True - _ps['x0'] = event.x - _ps['y0'] = event.y + _ps['x0'] = event.x; _ps['y0'] = event.y _ps['xlim'] = list(ax.get_xlim3d()) _ps['ylim'] = list(ax.get_ylim3d()) _ps['zlim'] = list(ax.get_zlim3d()) except Exception: pass + elif event.button == 3 and (_pan_active() or _rotate_active()): + try: + ax.button_pressed = None + _zs['active'] = True + _zs['x0'] = event.x; _zs['y0'] = event.y + _zs['xlim'] = list(ax.get_xlim3d()) + _zs['ylim'] = list(ax.get_ylim3d()) + _zs['zlim'] = list(ax.get_zlim3d()) + except Exception: + pass canvas.mpl_connect('button_press_event', _wrapped_press) break - # Clear pan state when the left button is released. - def _on_release(event, _ps=_pan_state): + # Clear gesture state on button release. + def _on_release(event, _ps=_pan_state, _zs=_zoom_state): if event.button == 1: _ps['active'] = False + elif event.button == 3: + _zs['active'] = False canvas.mpl_connect('button_release_event', _on_release) # --- Layer 2: wrap Axes3D._on_move in the callback registry --- @@ -183,15 +223,14 @@ def _wrapped_move(event, _orig=func): if event.inaxes != ax: _orig(event) return + # Right-drag zoom takes precedence in both pan and rotate modes. + if _zoom_state['active']: + _do_zoom_move(event) + return if _rotate_active(): _orig(event) - elif _pan_active(): - if _pan_state['active']: - # Manual 3D pan via axis-limit shift. - _do_pan_move(event) - else: - # Right-drag: native Axes3D behaviour (zoom / pan). - _orig(event) + elif _pan_active() and _pan_state['active']: + _do_pan_move(event) # else: neither rotate nor pan — suppress all drag canvas.mpl_connect('motion_notify_event', _wrapped_move) wrapped[0] = True @@ -199,21 +238,30 @@ def _wrapped_move(event, _orig=func): if not wrapped[0]: # Fallback for versions where _on_move isn't in canvas callbacks. - def _on_press_fb(event, _ps=_pan_state): - if event.inaxes != ax or event.button != 1: + def _on_press_fb(event, _ps=_pan_state, _zs=_zoom_state): + if event.inaxes != ax: return - if _pan_active(): + if event.button == 1 and _pan_active(): try: ax.button_pressed = None _ps['active'] = True - _ps['x0'] = event.x - _ps['y0'] = event.y + _ps['x0'] = event.x; _ps['y0'] = event.y _ps['xlim'] = list(ax.get_xlim3d()) _ps['ylim'] = list(ax.get_ylim3d()) _ps['zlim'] = list(ax.get_zlim3d()) except Exception: pass - elif not _rotate_active(): + elif event.button == 3 and (_pan_active() or _rotate_active()): + try: + ax.button_pressed = None + _zs['active'] = True + _zs['x0'] = event.x; _zs['y0'] = event.y + _zs['xlim'] = list(ax.get_xlim3d()) + _zs['ylim'] = list(ax.get_ylim3d()) + _zs['zlim'] = list(ax.get_zlim3d()) + except Exception: + pass + elif event.button == 1 and not _rotate_active(): for attr in ('button_pressed', '_button_pressed'): try: setattr(ax, attr, None) @@ -224,7 +272,9 @@ def _on_press_fb(event, _ps=_pan_state): def _on_move_fb(event): if event.inaxes != ax: return - if _pan_active() and _pan_state['active']: + if _zoom_state['active']: + _do_zoom_move(event) + elif _pan_active() and _pan_state['active']: _do_pan_move(event) canvas.mpl_connect('motion_notify_event', _on_move_fb) @@ -246,7 +296,9 @@ def _patched_drag_pan(button, key, x, y, _orig(ax, button, key, x, y) # Pan mode: handled in _wrapped_move / _on_move_fb. elif button == 3: - # Right-click: native behaviour only when no mode is active. + # Right-drag zoom is handled manually via _do_zoom_move in + # _wrapped_move/_on_move_fb for both pan and rotate modes. + # Only call native behaviour in idle 3D state. if not _rotate_active() and not _pan_active(): _orig(ax, button, key, x, y) else: diff --git a/pydatview/GUIToolBox.py b/pydatview/GUIToolBox.py index 85fedea..8ecc1e2 100644 --- a/pydatview/GUIToolBox.py +++ b/pydatview/GUIToolBox.py @@ -316,8 +316,7 @@ def __init__(self, canvas, keep_tools, plotPanel): self._rotate_tool_id = _rt.GetId() self.Bind(wx.EVT_TOOL, self._toggle_rotate, id=self._rotate_tool_id) self.SetToolShortHelp(self._rotate_tool_id, - 'Toggle 3D rotate mode (left-drag rotates)\n' - 'When off: left-drag pans, right-drag zooms') + 'Rotation for 3D: left rotates, right zooms') self.Realize() # Disabled until 3D mode is activated self.EnableTool(self._rotate_tool_id, False) @@ -329,8 +328,11 @@ def __init__(self, canvas, keep_tools, plotPanel): t = self.GetToolByPos(i) if t.GetLabel() == 'Pan': self.SetToolShortHelp(t.GetId(), - 'Left button pans, Right button zooms\n' - 'x/y/z fixes axis, CTRL fixes aspect') + 'When on:\n' + 'Left pans, Right zooms\n' + 'x/y/z fixes axis, CTRL fixes aspect\n' + 'When off:\n' + 'Left zooms in, right zooms out') break except Exception: pass @@ -343,6 +345,15 @@ def _toggle_rotate(self, event=None): if self.pan_on: self.pan_on = False NavigationToolbar2.pan(self) + # Defensive: ensure the Pan toolbar button visual is OFF. + try: + for i in range(self.GetToolsCount()): + t = self.GetToolByPos(i) + if t.GetLabel() == 'Pan': + self.ToggleTool(t.GetId(), False) + break + except Exception: + pass # Deactivate zoom if active try: from matplotlib.backend_bases import _Mode @@ -355,10 +366,8 @@ def _toggle_rotate(self, event=None): except Exception: pass else: - # Activate pan mode when leaving rotate so left-drag pans (matches tooltip) - if not self.pan_on: - self.pan_on = True - NavigationToolbar2.pan(self) + # Rotate turned OFF — return to default (zoom) mode. + NavigationToolbar2.zoom(self) if self._rotate_tool_id is not None: self.ToggleTool(self._rotate_tool_id, self.rotate_on) @@ -370,11 +379,14 @@ def zoom(self, *args): NavigationToolbar2.zoom(self, *args) # We skip wx and use the parent def pan(self, *args): - # Deactivate rotate if active if self.rotate_on: self.rotate_on = False - if self._rotate_tool_id is not None: + # Always force Rotate button visual OFF when Pan is clicked. + if self._rotate_tool_id is not None: + try: self.ToggleTool(self._rotate_tool_id, False) + except Exception: + pass self.pan_on = not self.pan_on # NEW - MPL >= 3.0.0 NavigationToolbar2.pan(self, *args) # We skip wx and use to parent From 8a4c8e961e3f39c600621af152f609084bf7c878 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 09:00:25 +0000 Subject: [PATCH 27/41] fix: stop saving/restoring panel widths as part of view state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Panel (sash) widths are a UI/workspace preference and should not be coupled to named views. Restoring a view was overriding the user's current panel layout, which is unexpected. Remove the sashWidths capture block from captureViewState and the corresponding restore block from restoreViewState. Old view files that already contain a sashWidths key are unaffected — the key is simply never read. https://claude.ai/code/session_01JQiNFHSjaNDv2VpPE2DzLV --- pydatview/GUISelectionPanel.py | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/pydatview/GUISelectionPanel.py b/pydatview/GUISelectionPanel.py index 0a4002e..456f0e7 100644 --- a/pydatview/GUISelectionPanel.py +++ b/pydatview/GUISelectionPanel.py @@ -1732,18 +1732,6 @@ def captureViewState(self): state['simTabSelection'] = simSel state['filterSelection'] = list(self.filterSelection) state['mode'] = self.currentMode - # Save sash widths keyed by stable panel name (id() is not portable across sessions) - _panel_name_map = { - id(self.tabPanel): 'tabPanel', - id(self.colPanel1): 'colPanel1', - id(self.colPanel2): 'colPanel2', - id(self.colPanel3): 'colPanel3', - } - state['sashWidths'] = { - _panel_name_map[k]: v - for k, v in self.splitter._panelWidths.items() - if k in _panel_name_map - } # Save per-table formulas so added columns can be recreated on restore formulas_state = {} for tab in self.tabList: @@ -1846,19 +1834,6 @@ def restoreViewState(self, state): # Update columns based on selection; skip saveSelection so the restored # tabSelections are not overwritten by the current (stale) GUI state. self.tabSelectionChanged(save=False) - # Restore sash widths saved in the view - sash_widths = state.get('sashWidths', {}) - if sash_widths: - _name_panel_map = { - 'tabPanel': self.tabPanel, - 'colPanel1': self.colPanel1, - 'colPanel2': self.colPanel2, - 'colPanel3': self.colPanel3, - } - for name, w in sash_widths.items(): - if name in _name_panel_map: - self.splitter._panelWidths[id(_name_panel_map[name])] = w - self.splitter._restorePanelWidths() return warnings def saveSelection(self): From 21e6e81f867d187132ed439cc9d331649c71f0fa Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 12:02:46 +0000 Subject: [PATCH 28/41] Preserve axis viewport when toggling background image mode Switching between 'Fixed' and 'Moving with axes' background modes went through redraw_same_data -> plot_all -> set_axes_lim, which unconditionally reset axis limits to data bounds and lost the user's current zoom/pan. This made the background appear to snap back to full size when entering Moving mode after zooming in. Capture the per-axis xlim/ylim before redraw and reapply them after, so the visible viewport (and the just-captured _bg_extent in Moving mode) stay in sync across the mode switch. https://claude.ai/code/session_01VfZebri6U3ebhr2fxBbAKK --- pydatview/GUIPlotPanel.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/pydatview/GUIPlotPanel.py b/pydatview/GUIPlotPanel.py index 7a70904..deb5acb 100644 --- a/pydatview/GUIPlotPanel.py +++ b/pydatview/GUIPlotPanel.py @@ -1705,9 +1705,11 @@ def onBgModeFixed(self, event): every redraw so it keeps covering the visible area. No GUI settings are changed when entering this mode. """ + view = self._capture_current_view() self._bg_glued = False self._bg_extent = None self.redraw_same_data() + self._apply_view(view) def onBgModeMoving(self, event): """'Moving with axes' mode: the image is glued to data coordinates. @@ -1725,7 +1727,31 @@ def onBgModeMoving(self, event): ylim = ax.get_ylim_() self._bg_extent = [min(xlim), max(xlim), min(ylim), max(ylim)] self._bg_glued = True + view = self._capture_current_view() self.redraw_same_data() + self._apply_view(view) + + def _capture_current_view(self): + view = [] + for ax in self.fig.axes: + try: + view.append((ax.get_xlim_(), ax.get_ylim_())) + except AttributeError: + view.append(None) + return view + + def _apply_view(self, view): + if not view: + return + for ax, lims in zip(self.fig.axes, view): + if lims is None: + continue + try: + ax.set_xlim_(lims[0]) + ax.set_ylim_(lims[1]) + except AttributeError: + pass + self.canvas.draw_idle() def setSubplotSpacing(self, init=False, tight=False): """ From 9a36a325f6b09820ec54f6f8582109430555daae Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 12:23:24 +0000 Subject: [PATCH 29/41] Fix bg-mode toggle: update artist in place instead of replotting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous attempt at preserving the viewport went through redraw_same_data -> plot_all -> set_axes_lim, which (with AutoScale on, the default) reset the axes to data bounds and additionally rebuilt the imshow artist. The post-hoc _apply_view did not reliably stick because plot_all's other axis-limit machinery (plotSignals, FFT/Compare, user_lim, etc.) interleaves with the redraw, and the rebuild itself causes a visible "snap to full size" of the background. A mode toggle is purely a state change — there is no need to replot. Tag the bg image artist when it is created so it can be located later, then in onBgModeFixed/onBgModeMoving simply find each axis's tagged image and set its extent (current xlim/ylim for Fixed, captured _bg_extent for Moving), flip _bg_glued, and request a single canvas.draw_idle. The existing xlim_changed/ylim_changed callback is already mode-aware (early-returns while _bg_glued is True), so it correctly keeps the image in sync going forward in either mode. Also unify imshow creation in plot_all so the lim-changed callback is registered in both modes — otherwise reloading data while in Moving mode would create a new artist without the callback, breaking the subsequent switch back to Fixed. https://claude.ai/code/session_01VfZebri6U3ebhr2fxBbAKK --- pydatview/GUIPlotPanel.py | 110 ++++++++++++++++++-------------------- 1 file changed, 53 insertions(+), 57 deletions(-) diff --git a/pydatview/GUIPlotPanel.py b/pydatview/GUIPlotPanel.py index deb5acb..33d3bb4 100644 --- a/pydatview/GUIPlotPanel.py +++ b/pydatview/GUIPlotPanel.py @@ -1701,56 +1701,50 @@ def onClearBgImage(self, event): def onBgModeFixed(self, event): """'Fixed' mode: the image always fills the current plot view. - The axes pan/zoom/rescale freely; the image extent is recomputed on - every redraw so it keeps covering the visible area. No GUI settings - are changed when entering this mode. + Toggling mode does NOT replot — that would go through plot_all / + set_axes_lim and reset the user's zoom/pan when AutoScale is on. + Instead, locate the existing bg image artist on each axis, retarget + its extent to the current view, and flip the _bg_glued flag. The + xlim_changed/ylim_changed callback registered when the artist was + created already keeps the extent in sync going forward (it early- + returns while _bg_glued is True). """ - view = self._capture_current_view() self._bg_glued = False self._bg_extent = None - self.redraw_same_data() - self._apply_view(view) + if self._bg_image is None: + return + for ax in self.fig.axes: + if hasattr(ax, 'set_zlim'): # 3D: no bg image + continue + xl = ax.get_xlim() + yl = ax.get_ylim() + for img in list(ax.images): + if getattr(img, '_is_pydatview_bg', False): + img.set_extent([xl[0], xl[1], yl[0], yl[1]]) + self.canvas.draw_idle() def onBgModeMoving(self, event): """'Moving with axes' mode: the image is glued to data coordinates. - We capture the current xlim/ylim as the image's data-coord extent; - from now on the image is rendered at those data points, so pan/zoom - moves the image along with the data. We deliberately do NOT toggle - AutoScale or any other GUI setting — the image-to-data relation is - preserved regardless. + Capture the current xlim/ylim of the first axis as the image's data- + coord extent and freeze the existing bg artists at that extent. The + already-registered xlim_changed/ylim_changed callback early-returns + while _bg_glued is True, so the image stays put as the user pans/ + zooms. No replot, no AutoScale or other GUI setting is touched. """ - if len(self.fig.axes) == 0: + if len(self.fig.axes) == 0 or self._bg_image is None: return ax = self.fig.axes[0] xlim = ax.get_xlim_() ylim = ax.get_ylim_() self._bg_extent = [min(xlim), max(xlim), min(ylim), max(ylim)] self._bg_glued = True - view = self._capture_current_view() - self.redraw_same_data() - self._apply_view(view) - - def _capture_current_view(self): - view = [] - for ax in self.fig.axes: - try: - view.append((ax.get_xlim_(), ax.get_ylim_())) - except AttributeError: - view.append(None) - return view - - def _apply_view(self, view): - if not view: - return - for ax, lims in zip(self.fig.axes, view): - if lims is None: + for ax_i in self.fig.axes: + if hasattr(ax_i, 'set_zlim'): continue - try: - ax.set_xlim_(lims[0]) - ax.set_ylim_(lims[1]) - except AttributeError: - pass + for img in list(ax_i.images): + if getattr(img, '_is_pydatview_bg', False): + img.set_extent(self._bg_extent) self.canvas.draw_idle() def setSubplotSpacing(self, init=False, tight=False): @@ -2462,34 +2456,36 @@ def plot_all(self, autoscale=True): # Draw background image if present (not supported on 3D axes) if self._bg_image is not None: if not hasattr(ax_left, 'set_zlim'): + # Use standard get_xlim/get_ylim (not swap-aware) because + # imshow() is not overridden by SwappyAxes and uses + # standard data-space coordinates. if self._bg_glued and self._bg_extent is not None: - # Moving-with-axes: image is glued to data coords - ax_left.imshow(self._bg_image, extent=self._bg_extent, - aspect='auto', zorder=0, interpolation='bilinear', - origin='upper') + # Moving-with-axes: image is glued to captured data coords + bg_ext = list(self._bg_extent) else: # Fixed (default): image fills the current view. - # Use standard get_xlim/get_ylim (not swap-aware) because - # imshow() is not overridden by SwappyAxes and uses - # standard data-space coordinates. xlim = ax_left.get_xlim() ylim = ax_left.get_ylim() bg_ext = [xlim[0], xlim[1], ylim[0], ylim[1]] - bg_artist = ax_left.imshow( - self._bg_image, extent=bg_ext, - aspect='auto', zorder=0, interpolation='bilinear', - origin='upper') - # Keep the image filling the visible area during - # interactive pan / zoom by updating its extent - # whenever the axis limits change. - def _on_lim_changed(ax, _a=bg_artist, _s=self): - if _s._bg_glued or _a.axes is None: - return - xl = ax.get_xlim() - yl = ax.get_ylim() - _a.set_extent([xl[0], xl[1], yl[0], yl[1]]) - ax_left.callbacks.connect('xlim_changed', _on_lim_changed) - ax_left.callbacks.connect('ylim_changed', _on_lim_changed) + bg_artist = ax_left.imshow( + self._bg_image, extent=bg_ext, + aspect='auto', zorder=0, interpolation='bilinear', + origin='upper') + bg_artist._is_pydatview_bg = True + # Keep the image filling the visible area during + # interactive pan / zoom by updating its extent + # whenever the axis limits change. The callback is + # registered in both modes; it early-returns while + # _bg_glued is True so the image stays put in + # Moving-with-axes mode. + def _on_lim_changed(ax, _a=bg_artist, _s=self): + if _s._bg_glued or _a.axes is None: + return + xl = ax.get_xlim() + yl = ax.get_ylim() + _a.set_extent([xl[0], xl[1], yl[0], yl[1]]) + ax_left.callbacks.connect('xlim_changed', _on_lim_changed) + ax_left.callbacks.connect('ylim_changed', _on_lim_changed) # Actually plot if self.infoPanel is not None: From 83b8bc9385f26987238577fd65321dc1efc675e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 13:08:49 +0000 Subject: [PATCH 30/41] bg-mode: don't squeeze image when switching Moving -> Fixed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous fix re-wrote the image artist's extent to the current xlim/ylim on entering Fixed mode. If the user had previously been in Moving mode and zoomed into a sub-region of the background, this collapsed the whole image into the tiny viewport — the user described it as the background "resetting to full size" because the entire image suddenly became visible (squeezed) instead of staying as the magnified portion. Make onBgModeFixed a pure state flip: just set _bg_glued = False (and clear _bg_extent so a future plot rebuild doesn't pin the new artist to the old captured rectangle). The image keeps whatever extent it had, and the existing xlim_changed/ylim_changed callback — now no longer short-circuited by _bg_glued — will start tracking the viewport on the next pan/zoom. Transition is visually invisible at the moment of switch. https://claude.ai/code/session_01VfZebri6U3ebhr2fxBbAKK --- pydatview/GUIPlotPanel.py | 30 +++++++++++------------------- 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/pydatview/GUIPlotPanel.py b/pydatview/GUIPlotPanel.py index 33d3bb4..750fec6 100644 --- a/pydatview/GUIPlotPanel.py +++ b/pydatview/GUIPlotPanel.py @@ -1699,28 +1699,20 @@ def onClearBgImage(self, event): self.redraw_same_data() def onBgModeFixed(self, event): - """'Fixed' mode: the image always fills the current plot view. - - Toggling mode does NOT replot — that would go through plot_all / - set_axes_lim and reset the user's zoom/pan when AutoScale is on. - Instead, locate the existing bg image artist on each axis, retarget - its extent to the current view, and flip the _bg_glued flag. The - xlim_changed/ylim_changed callback registered when the artist was - created already keeps the extent in sync going forward (it early- - returns while _bg_glued is True). + """'Fixed' mode: the image follows the current plot view. + + We deliberately do NOT touch the existing image extent. If the user + was previously in 'Moving with axes' mode and zoomed into a portion + of the background, rewriting the extent to the current xlim/ylim + would squeeze the whole image into the small viewport (the user + reported this as the background "resetting to full size"). Instead + we just flip _bg_glued; the existing xlim_changed/ylim_changed + callback will start tracking the viewport on the next pan/zoom, at + which point the image transitions naturally from "magnified + portion" to "fills viewport" without a visible jump. """ self._bg_glued = False self._bg_extent = None - if self._bg_image is None: - return - for ax in self.fig.axes: - if hasattr(ax, 'set_zlim'): # 3D: no bg image - continue - xl = ax.get_xlim() - yl = ax.get_ylim() - for img in list(ax.images): - if getattr(img, '_is_pydatview_bg', False): - img.set_extent([xl[0], xl[1], yl[0], yl[1]]) self.canvas.draw_idle() def onBgModeMoving(self, event): From 460470799152042bd7044c01bc2ecf21fc253979 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 13:26:26 +0000 Subject: [PATCH 31/41] bg: keep background fixed when user sets axis limits explicitly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the user types limits into the limits panel, they want a specific viewport — not for the background image to follow the new limits. Previously, in Fixed mode (_bg_glued=False), the xlim_changed callback fired during the redraw triggered by onAxisLimitChange and updated the artist's extent to the new viewport, so the bg "moved with" the limits. Snapshot each axis's bg image extent before the redraw and reapply it to the rebuilt artist after. The bg keeps its current visual data-coord position; subsequent toolbar pan/zoom still behaves per-mode (Fixed follows, Moving stays). https://claude.ai/code/session_01VfZebri6U3ebhr2fxBbAKK --- pydatview/GUIPlotPanel.py | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/pydatview/GUIPlotPanel.py b/pydatview/GUIPlotPanel.py index 750fec6..0c57135 100644 --- a/pydatview/GUIPlotPanel.py +++ b/pydatview/GUIPlotPanel.py @@ -948,7 +948,32 @@ def onFontOptionChange(self,event=None): def onAxisLimitChange(self, event=None): if self.parent.cbAutoScale.IsChecked(): self.parent.cbAutoScale.SetValue(False) - self.parent.redraw_same_data() + # Snapshot the background image extent per-axis. An explicit limit + # change is a viewport choice — the user does not want the bg image + # to drift along with the new limits. In Fixed mode the + # xlim_changed callback would otherwise pull the artist to match + # the new viewport during the redraw; we restore the original + # extent on the rebuilt artist afterwards. + panel = self.parent + saved_bg = [] + if panel._bg_image is not None: + for ax in panel.fig.axes: + ext = None + for img in ax.images: + if getattr(img, '_is_pydatview_bg', False): + ext = list(img.get_extent()) + break + saved_bg.append(ext) + panel.redraw_same_data() + if panel._bg_image is not None and saved_bg: + for ax, ext in zip(panel.fig.axes, saved_bg): + if ext is None: + continue + for img in ax.images: + if getattr(img, '_is_pydatview_bg', False): + img.set_extent(ext) + break + panel.canvas.draw_idle() def getAxisLimits(self): """Return axis limit values. Empty/invalid fields become None.""" From 2a2f5be9be2d015333f486e590074298ad5444b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 13:51:51 +0000 Subject: [PATCH 32/41] bg: lock Fixed mode to the plot rectangle (transAxes + cropped image) In Fixed mode the bg image was rendered in data coordinates with a callback that kept its extent equal to the current viewport. As soon as the viewport changed (toolbar zoom, limits panel, autoscale) the bg moved/resized on screen with the data. The user expects Fixed mode to mean "bg pinned to the plot rectangle": whatever portion of the bg was visible at the moment of switching should stay pixel-identical until they change modes again. Render the bg in axes (screen) coordinates via transAxes: - Moving : transData, extent = _bg_extent (unchanged) - Fixed-locked : transAxes, cropped image at _bg_axes_extent - Fixed-default : transAxes, full image at [0,1,0,1] (fresh-loaded bg) Switching Moving -> Fixed snapshots the visible portion of the bg (crop + axes-fraction extent computed from the artist's current data extent and the viewport) and re-creates each artist in transAxes. Any subsequent xlim/ylim change is now independent of the bg by construction. The xlim_changed/ylim_changed callback and the onAxisLimitChange snapshot/restore workaround (commit 4604707) are no longer needed and have been removed. https://claude.ai/code/session_01VfZebri6U3ebhr2fxBbAKK --- pydatview/GUIPlotPanel.py | 231 +++++++++++++++++++++++++------------- 1 file changed, 150 insertions(+), 81 deletions(-) diff --git a/pydatview/GUIPlotPanel.py b/pydatview/GUIPlotPanel.py index 0c57135..6875287 100644 --- a/pydatview/GUIPlotPanel.py +++ b/pydatview/GUIPlotPanel.py @@ -948,32 +948,7 @@ def onFontOptionChange(self,event=None): def onAxisLimitChange(self, event=None): if self.parent.cbAutoScale.IsChecked(): self.parent.cbAutoScale.SetValue(False) - # Snapshot the background image extent per-axis. An explicit limit - # change is a viewport choice — the user does not want the bg image - # to drift along with the new limits. In Fixed mode the - # xlim_changed callback would otherwise pull the artist to match - # the new viewport during the redraw; we restore the original - # extent on the rebuilt artist afterwards. - panel = self.parent - saved_bg = [] - if panel._bg_image is not None: - for ax in panel.fig.axes: - ext = None - for img in ax.images: - if getattr(img, '_is_pydatview_bg', False): - ext = list(img.get_extent()) - break - saved_bg.append(ext) - panel.redraw_same_data() - if panel._bg_image is not None and saved_bg: - for ax, ext in zip(panel.fig.axes, saved_bg): - if ext is None: - continue - for img in ax.images: - if getattr(img, '_is_pydatview_bg', False): - img.set_extent(ext) - break - panel.canvas.draw_idle() + self.parent.redraw_same_data() def getAxisLimits(self): """Return axis limit values. Empty/invalid fields become None.""" @@ -1073,9 +1048,13 @@ def __init__(self, parent, selPanel, pipeLike=None, infoPanel=None, data=None): self.addTablesCallback = None self._bg_image = None # numpy array for background image self._bg_glued = False # True = 'Moving with axes' (glued to data coords); - # False = 'Fixed' (fills current plot view, default) + # False = 'Fixed' (pinned to plot rectangle, default) self._bg_extent = None # [xmin, xmax, ymin, ymax] in data coords, captured # when entering 'Moving with axes' mode + self._bg_display_image = None # cropped image rendered in Fixed mode + # (None = use the full _bg_image) + self._bg_axes_extent = None # [afx0, afx1, afy0, afy1] in axes-fraction + # coords for Fixed-mode artist (None = [0,1,0,1]) # --- GUI self.fig = Figure(facecolor="white", figsize=(1, 1)) @@ -1679,6 +1658,8 @@ def _setBgImage(self, img_array): self._bg_image = img_array self._bg_glued = False self._bg_extent = None + self._bg_display_image = None + self._bg_axes_extent = None self.redraw_same_data() def onLoadBgImage(self, event): @@ -1721,33 +1702,131 @@ def onPasteBgImage(self, event): def onClearBgImage(self, event): """Remove the background image.""" self._bg_image = None + self._bg_glued = False + self._bg_extent = None + self._bg_display_image = None + self._bg_axes_extent = None self.redraw_same_data() + def _compute_bg_screen_lock(self, ax): + """Given an axis currently showing the bg image (in data coords), + compute the cropped image and the axes-fraction extent that, when + rendered with transAxes, reproduces the currently-visible portion + of the bg pinned to the plot rectangle. + + Returns (cropped_image, [afx0, afx1, afy0, afy1]) or (None, None) + if the bg is not visible at all in the current viewport. + """ + if self._bg_image is None: + return None, None + xlim = ax.get_xlim() + ylim = ax.get_ylim() + vx0, vx1 = sorted(xlim) + vy0, vy1 = sorted(ylim) + # Locate the current bg artist on this axis to read its data extent. + bg_artist = None + for img in ax.images: + if getattr(img, '_is_pydatview_bg', False): + bg_artist = img + break + if bg_artist is not None and bg_artist.get_transform() == ax.transData: + bx0, bx1, by0, by1 = bg_artist.get_extent() + if bx0 > bx1: + bx0, bx1 = bx1, bx0 + if by0 > by1: + by0, by1 = by1, by0 + else: + # Fall back to current viewport — same as 'Fixed default' state + bx0, bx1, by0, by1 = vx0, vx1, vy0, vy1 + ix0, ix1 = max(bx0, vx0), min(bx1, vx1) + iy0, iy1 = max(by0, vy0), min(by1, vy1) + if ix1 <= ix0 or iy1 <= iy0: + return None, None + h, w = self._bg_image.shape[:2] + col0 = int(round((ix0 - bx0) / (bx1 - bx0) * w)) + col1 = int(round((ix1 - bx0) / (bx1 - bx0) * w)) + # origin='upper': image row 0 is the top (highest y). Flip y. + row0 = int(round((by1 - iy1) / (by1 - by0) * h)) + row1 = int(round((by1 - iy0) / (by1 - by0) * h)) + col0 = max(0, min(w, col0)); col1 = max(0, min(w, col1)) + row0 = max(0, min(h, row0)); row1 = max(0, min(h, row1)) + if col1 <= col0 or row1 <= row0: + return None, None + cropped = self._bg_image[row0:row1, col0:col1] + afx0 = (ix0 - vx0) / (vx1 - vx0) + afx1 = (ix1 - vx0) / (vx1 - vx0) + afy0 = (iy0 - vy0) / (vy1 - vy0) + afy1 = (iy1 - vy0) / (vy1 - vy0) + return cropped, [afx0, afx1, afy0, afy1] + + def _replace_bg_artists(self): + """Remove existing bg artists on every 2D axis and re-create them + per the current bg state (_bg_glued, _bg_extent, _bg_display_image, + _bg_axes_extent). Used by the mode-toggle handlers so the change is + immediate and does not require a full plot rebuild. + """ + for ax in self.fig.axes: + if hasattr(ax, 'set_zlim'): + continue + for img in list(ax.images): + if getattr(img, '_is_pydatview_bg', False): + img.remove() + if self._bg_image is None: + continue + if self._bg_glued and self._bg_extent is not None: + bg_artist = ax.imshow( + self._bg_image, extent=self._bg_extent, + transform=ax.transData, + aspect='auto', zorder=0, origin='upper', + interpolation='bilinear') + elif (self._bg_display_image is not None + and self._bg_axes_extent is not None): + bg_artist = ax.imshow( + self._bg_display_image, extent=self._bg_axes_extent, + transform=ax.transAxes, + aspect='auto', zorder=0, origin='upper', + interpolation='bilinear') + else: + bg_artist = ax.imshow( + self._bg_image, extent=[0, 1, 0, 1], + transform=ax.transAxes, + aspect='auto', zorder=0, origin='upper', + interpolation='bilinear') + bg_artist._is_pydatview_bg = True + def onBgModeFixed(self, event): - """'Fixed' mode: the image follows the current plot view. - - We deliberately do NOT touch the existing image extent. If the user - was previously in 'Moving with axes' mode and zoomed into a portion - of the background, rewriting the extent to the current xlim/ylim - would squeeze the whole image into the small viewport (the user - reported this as the background "resetting to full size"). Instead - we just flip _bg_glued; the existing xlim_changed/ylim_changed - callback will start tracking the viewport on the next pan/zoom, at - which point the image transitions naturally from "magnified - portion" to "fills viewport" without a visible jump. + """'Fixed' (default) mode: the image is pinned to the plot rectangle. + + We snapshot the currently-visible portion of the bg and re-render + it in axes (screen) coordinates via transAxes. From this point on, + any axis-limit change (toolbar zoom, limits panel, autoscale) is + independent of the bg — what the user saw at the moment of the + switch stays pixel-identical until they switch modes again or + change the bg image. """ + if self._bg_image is None or len(self.fig.axes) == 0: + self._bg_glued = False + self._bg_extent = None + self._bg_display_image = None + self._bg_axes_extent = None + self.canvas.draw_idle() + return + cropped, ax_extent = self._compute_bg_screen_lock(self.fig.axes[0]) self._bg_glued = False self._bg_extent = None + self._bg_display_image = cropped + self._bg_axes_extent = ax_extent + self._replace_bg_artists() self.canvas.draw_idle() def onBgModeMoving(self, event): """'Moving with axes' mode: the image is glued to data coordinates. - Capture the current xlim/ylim of the first axis as the image's data- - coord extent and freeze the existing bg artists at that extent. The - already-registered xlim_changed/ylim_changed callback early-returns - while _bg_glued is True, so the image stays put as the user pans/ - zooms. No replot, no AutoScale or other GUI setting is touched. + Capture the current xlim/ylim of the first axis as the image's + data-coord extent and re-render every bg artist in transData. As + the user pans/zooms, the bg stays put in data space (the screen + position changes naturally with the viewport). No AutoScale or + other GUI setting is touched. """ if len(self.fig.axes) == 0 or self._bg_image is None: return @@ -1756,12 +1835,9 @@ def onBgModeMoving(self, event): ylim = ax.get_ylim_() self._bg_extent = [min(xlim), max(xlim), min(ylim), max(ylim)] self._bg_glued = True - for ax_i in self.fig.axes: - if hasattr(ax_i, 'set_zlim'): - continue - for img in list(ax_i.images): - if getattr(img, '_is_pydatview_bg', False): - img.set_extent(self._bg_extent) + self._bg_display_image = None + self._bg_axes_extent = None + self._replace_bg_artists() self.canvas.draw_idle() def setSubplotSpacing(self, init=False, tight=False): @@ -2470,39 +2546,32 @@ def plot_all(self, autoscale=True): # Set limit before plot when possible, for optimization self.set_axes_lim(PD, ax_left, plotType) - # Draw background image if present (not supported on 3D axes) - if self._bg_image is not None: - if not hasattr(ax_left, 'set_zlim'): - # Use standard get_xlim/get_ylim (not swap-aware) because - # imshow() is not overridden by SwappyAxes and uses - # standard data-space coordinates. - if self._bg_glued and self._bg_extent is not None: - # Moving-with-axes: image is glued to captured data coords - bg_ext = list(self._bg_extent) - else: - # Fixed (default): image fills the current view. - xlim = ax_left.get_xlim() - ylim = ax_left.get_ylim() - bg_ext = [xlim[0], xlim[1], ylim[0], ylim[1]] + # Draw background image if present (not supported on 3D axes). + # Three rendering modes: + # Moving : transData with extent=_bg_extent + # Fixed-locked : transAxes with cropped image at _bg_axes_extent + # Fixed-default: transAxes with full image at [0,1,0,1] + if self._bg_image is not None and not hasattr(ax_left, 'set_zlim'): + if self._bg_glued and self._bg_extent is not None: + bg_artist = ax_left.imshow( + self._bg_image, extent=list(self._bg_extent), + transform=ax_left.transData, + aspect='auto', zorder=0, origin='upper', + interpolation='bilinear') + elif (self._bg_display_image is not None + and self._bg_axes_extent is not None): + bg_artist = ax_left.imshow( + self._bg_display_image, extent=self._bg_axes_extent, + transform=ax_left.transAxes, + aspect='auto', zorder=0, origin='upper', + interpolation='bilinear') + else: bg_artist = ax_left.imshow( - self._bg_image, extent=bg_ext, - aspect='auto', zorder=0, interpolation='bilinear', - origin='upper') - bg_artist._is_pydatview_bg = True - # Keep the image filling the visible area during - # interactive pan / zoom by updating its extent - # whenever the axis limits change. The callback is - # registered in both modes; it early-returns while - # _bg_glued is True so the image stays put in - # Moving-with-axes mode. - def _on_lim_changed(ax, _a=bg_artist, _s=self): - if _s._bg_glued or _a.axes is None: - return - xl = ax.get_xlim() - yl = ax.get_ylim() - _a.set_extent([xl[0], xl[1], yl[0], yl[1]]) - ax_left.callbacks.connect('xlim_changed', _on_lim_changed) - ax_left.callbacks.connect('ylim_changed', _on_lim_changed) + self._bg_image, extent=[0, 1, 0, 1], + transform=ax_left.transAxes, + aspect='auto', zorder=0, origin='upper', + interpolation='bilinear') + bg_artist._is_pydatview_bg = True # Actually plot if self.infoPanel is not None: From 5ef4d5282399adbe3942c682b97c0ba575b6abbc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 14:09:30 +0000 Subject: [PATCH 33/41] bg: visually invisible Fixed -> Moving switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching to Moving was rendering the full _bg_image at extent=current viewport, even though the user had been seeing only a cropped portion pinned in Fixed-locked mode. The result was a visible jump (the rest of the bg suddenly reappearing). Compute the new _bg_extent by mapping the prior axes-fraction extent (_bg_axes_extent, or [0,1,0,1] for Fixed-default) into the current viewport's data coords, and render the same image that was just on screen — the cropped image in Fixed-locked mode, the full image in Fixed-default. Both _replace_bg_artists and plot_all's Moving branch now use _bg_display_image when set, falling back to _bg_image. _compute_bg_screen_lock also pulls the source array straight from the existing artist (instead of always self._bg_image) and handles the transAxes case (re-clicking Fixed when already in Fixed-locked) by mapping its axes-fraction extent back to data coords before cropping. https://claude.ai/code/session_01VfZebri6U3ebhr2fxBbAKK --- pydatview/GUIPlotPanel.py | 76 ++++++++++++++++++++++++++++----------- 1 file changed, 55 insertions(+), 21 deletions(-) diff --git a/pydatview/GUIPlotPanel.py b/pydatview/GUIPlotPanel.py index 6875287..13148af 100644 --- a/pydatview/GUIPlotPanel.py +++ b/pydatview/GUIPlotPanel.py @@ -1723,26 +1723,38 @@ def _compute_bg_screen_lock(self, ax): ylim = ax.get_ylim() vx0, vx1 = sorted(xlim) vy0, vy1 = sorted(ylim) - # Locate the current bg artist on this axis to read its data extent. + # Locate the current bg artist on this axis to read its image and extent. bg_artist = None for img in ax.images: if getattr(img, '_is_pydatview_bg', False): bg_artist = img break - if bg_artist is not None and bg_artist.get_transform() == ax.transData: - bx0, bx1, by0, by1 = bg_artist.get_extent() - if bx0 > bx1: - bx0, bx1 = bx1, bx0 - if by0 > by1: - by0, by1 = by1, by0 + if bg_artist is not None: + try: + src = np.asarray(bg_artist.get_array()) + except Exception: + src = self._bg_image + ext = bg_artist.get_extent() + if bg_artist.get_transform() == ax.transData: + bx0, bx1, by0, by1 = ext + if bx0 > bx1: bx0, bx1 = bx1, bx0 + if by0 > by1: by0, by1 = by1, by0 + else: + # transAxes: extent is axes-fraction; map back to data coords. + afx0, afx1, afy0, afy1 = ext + bx0 = vx0 + afx0 * (vx1 - vx0) + bx1 = vx0 + afx1 * (vx1 - vx0) + by0 = vy0 + afy0 * (vy1 - vy0) + by1 = vy0 + afy1 * (vy1 - vy0) else: - # Fall back to current viewport — same as 'Fixed default' state + # No artist drawn yet: treat as Fixed-default (full image == viewport). + src = self._bg_image bx0, bx1, by0, by1 = vx0, vx1, vy0, vy1 ix0, ix1 = max(bx0, vx0), min(bx1, vx1) iy0, iy1 = max(by0, vy0), min(by1, vy1) if ix1 <= ix0 or iy1 <= iy0: return None, None - h, w = self._bg_image.shape[:2] + h, w = src.shape[:2] col0 = int(round((ix0 - bx0) / (bx1 - bx0) * w)) col1 = int(round((ix1 - bx0) / (bx1 - bx0) * w)) # origin='upper': image row 0 is the top (highest y). Flip y. @@ -1752,7 +1764,7 @@ def _compute_bg_screen_lock(self, ax): row0 = max(0, min(h, row0)); row1 = max(0, min(h, row1)) if col1 <= col0 or row1 <= row0: return None, None - cropped = self._bg_image[row0:row1, col0:col1] + cropped = src[row0:row1, col0:col1] afx0 = (ix0 - vx0) / (vx1 - vx0) afx1 = (ix1 - vx0) / (vx1 - vx0) afy0 = (iy0 - vy0) / (vy1 - vy0) @@ -1774,8 +1786,11 @@ def _replace_bg_artists(self): if self._bg_image is None: continue if self._bg_glued and self._bg_extent is not None: + img_data = self._bg_display_image \ + if self._bg_display_image is not None \ + else self._bg_image bg_artist = ax.imshow( - self._bg_image, extent=self._bg_extent, + img_data, extent=self._bg_extent, transform=ax.transData, aspect='auto', zorder=0, origin='upper', interpolation='bilinear') @@ -1822,21 +1837,37 @@ def onBgModeFixed(self, event): def onBgModeMoving(self, event): """'Moving with axes' mode: the image is glued to data coordinates. - Capture the current xlim/ylim of the first axis as the image's - data-coord extent and re-render every bg artist in transData. As - the user pans/zooms, the bg stays put in data space (the screen - position changes naturally with the viewport). No AutoScale or - other GUI setting is touched. + Compute a data-coord extent for the bg such that the switch is + visually invisible: take whatever portion of the bg is currently + visible (axes-fraction extent in Fixed-locked mode, or [0,1,0,1] + in Fixed-default mode) and map it to data coords using the + current viewport. The image array used is whatever was on screen + (cropped if Fixed-locked, full if Fixed-default). + + After this, the user can pan/zoom freely; the bg stays put in + data space (its screen position changes with the viewport). """ if len(self.fig.axes) == 0 or self._bg_image is None: return ax = self.fig.axes[0] - xlim = ax.get_xlim_() - ylim = ax.get_ylim_() - self._bg_extent = [min(xlim), max(xlim), min(ylim), max(ylim)] + xlim = ax.get_xlim() + ylim = ax.get_ylim() + vx0, vx1 = sorted(xlim) + vy0, vy1 = sorted(ylim) + ax_ext = self._bg_axes_extent if self._bg_axes_extent is not None \ + else [0.0, 1.0, 0.0, 1.0] + afx0, afx1, afy0, afy1 = ax_ext + self._bg_extent = [ + vx0 + afx0 * (vx1 - vx0), + vx0 + afx1 * (vx1 - vx0), + vy0 + afy0 * (vy1 - vy0), + vy0 + afy1 * (vy1 - vy0), + ] self._bg_glued = True - self._bg_display_image = None self._bg_axes_extent = None + # Keep _bg_display_image so the image used in transData matches + # what was just on screen (the cropped portion in Fixed-locked + # mode, or None which falls back to _bg_image in Fixed-default). self._replace_bg_artists() self.canvas.draw_idle() @@ -2553,8 +2584,11 @@ def plot_all(self, autoscale=True): # Fixed-default: transAxes with full image at [0,1,0,1] if self._bg_image is not None and not hasattr(ax_left, 'set_zlim'): if self._bg_glued and self._bg_extent is not None: + img_data = self._bg_display_image \ + if self._bg_display_image is not None \ + else self._bg_image bg_artist = ax_left.imshow( - self._bg_image, extent=list(self._bg_extent), + img_data, extent=list(self._bg_extent), transform=ax_left.transData, aspect='auto', zorder=0, origin='upper', interpolation='bilinear') From 7faafe7566e5eecbffc71e17dd5dfe887cfb93e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 14:23:16 +0000 Subject: [PATCH 34/41] bg: render full image (not the crop) in Moving mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a Fixed -> Moving switch the user could only see the cropped portion of the bg, even when panning, because Moving mode was rendering the cropped image. They want to explore the parts that were outside the prior viewport (axis labels etc.). Track which fraction of the original _bg_image the displayed crop represents (_bg_crop_box). On Fixed -> Moving, use that crop box plus the current axes-fraction extent and viewport to compute a data-coord extent for the FULL _bg_image such that its currently-visible portion lands exactly where the crop was on screen — visually invisible at the moment of switch, but the rest of the bg is now reachable by panning. _compute_bg_screen_lock composes the new crop fractions with the existing _bg_crop_box, so cropping repeatedly across mode switches always stays expressed relative to the original full image. https://claude.ai/code/session_01VfZebri6U3ebhr2fxBbAKK --- pydatview/GUIPlotPanel.py | 129 +++++++++++++++++++++++++------------- 1 file changed, 84 insertions(+), 45 deletions(-) diff --git a/pydatview/GUIPlotPanel.py b/pydatview/GUIPlotPanel.py index 13148af..b0e8d16 100644 --- a/pydatview/GUIPlotPanel.py +++ b/pydatview/GUIPlotPanel.py @@ -1055,6 +1055,9 @@ def __init__(self, parent, selPanel, pipeLike=None, infoPanel=None, data=None): # (None = use the full _bg_image) self._bg_axes_extent = None # [afx0, afx1, afy0, afy1] in axes-fraction # coords for Fixed-mode artist (None = [0,1,0,1]) + self._bg_crop_box = None # [col0, col1, row0, row1] as fractions [0,1] + # of the original _bg_image that + # _bg_display_image represents (None = full image) # --- GUI self.fig = Figure(facecolor="white", figsize=(1, 1)) @@ -1660,6 +1663,7 @@ def _setBgImage(self, img_array): self._bg_extent = None self._bg_display_image = None self._bg_axes_extent = None + self._bg_crop_box = None self.redraw_same_data() def onLoadBgImage(self, event): @@ -1706,6 +1710,7 @@ def onClearBgImage(self, event): self._bg_extent = None self._bg_display_image = None self._bg_axes_extent = None + self._bg_crop_box = None self.redraw_same_data() def _compute_bg_screen_lock(self, ax): @@ -1718,7 +1723,7 @@ def _compute_bg_screen_lock(self, ax): if the bg is not visible at all in the current viewport. """ if self._bg_image is None: - return None, None + return None, None, None xlim = ax.get_xlim() ylim = ax.get_ylim() vx0, vx1 = sorted(xlim) @@ -1753,23 +1758,68 @@ def _compute_bg_screen_lock(self, ax): ix0, ix1 = max(bx0, vx0), min(bx1, vx1) iy0, iy1 = max(by0, vy0), min(by1, vy1) if ix1 <= ix0 or iy1 <= iy0: - return None, None + return None, None, None + # Crop fractions WITHIN the source array (cropped or full). + col_lf = (ix0 - bx0) / (bx1 - bx0) + col_rf = (ix1 - bx0) / (bx1 - bx0) + # origin='upper': image row 0 is at by1 (top), row h is at by0 (bottom). + row_tf = (by1 - iy1) / (by1 - by0) + row_bf = (by1 - iy0) / (by1 - by0) h, w = src.shape[:2] - col0 = int(round((ix0 - bx0) / (bx1 - bx0) * w)) - col1 = int(round((ix1 - bx0) / (bx1 - bx0) * w)) - # origin='upper': image row 0 is the top (highest y). Flip y. - row0 = int(round((by1 - iy1) / (by1 - by0) * h)) - row1 = int(round((by1 - iy0) / (by1 - by0) * h)) - col0 = max(0, min(w, col0)); col1 = max(0, min(w, col1)) - row0 = max(0, min(h, row0)); row1 = max(0, min(h, row1)) + col0 = max(0, min(w, int(round(col_lf * w)))) + col1 = max(0, min(w, int(round(col_rf * w)))) + row0 = max(0, min(h, int(round(row_tf * h)))) + row1 = max(0, min(h, int(round(row_bf * h)))) if col1 <= col0 or row1 <= row0: - return None, None + return None, None, None cropped = src[row0:row1, col0:col1] afx0 = (ix0 - vx0) / (vx1 - vx0) afx1 = (ix1 - vx0) / (vx1 - vx0) afy0 = (iy0 - vy0) / (vy1 - vy0) afy1 = (iy1 - vy0) / (vy1 - vy0) - return cropped, [afx0, afx1, afy0, afy1] + # Compose with the existing crop box so the new crop box is expressed + # as a fraction of the original _bg_image (regardless of whether the + # source we just cropped was the full image or an already-cropped one). + parent = self._bg_crop_box if self._bg_crop_box is not None \ + else [0.0, 1.0, 0.0, 1.0] + pa, pb, pc, pd = parent + crop_box = [pa + col_lf * (pb - pa), + pa + col_rf * (pb - pa), + pc + row_tf * (pd - pc), + pc + row_bf * (pd - pc)] + return cropped, [afx0, afx1, afy0, afy1], crop_box + + def _full_extent_from_state(self, ax): + """Compute the data-coord extent of the FULL _bg_image needed to + place its currently-cropped portion (described by _bg_axes_extent + and _bg_crop_box) at exactly the screen rectangle it now occupies + within the current viewport. Used at Fixed -> Moving transition. + """ + xlim = ax.get_xlim() + ylim = ax.get_ylim() + vx0, vx1 = sorted(xlim) + vy0, vy1 = sorted(ylim) + ae = self._bg_axes_extent if self._bg_axes_extent is not None \ + else [0.0, 1.0, 0.0, 1.0] + afx0, afx1, afy0, afy1 = ae + # Current cropped image's data-coord rectangle. + dx0 = vx0 + afx0 * (vx1 - vx0) + dx1 = vx0 + afx1 * (vx1 - vx0) + dy0 = vy0 + afy0 * (vy1 - vy0) + dy1 = vy0 + afy1 * (vy1 - vy0) + cb = self._bg_crop_box if self._bg_crop_box is not None \ + else [0.0, 1.0, 0.0, 1.0] + a, b, c, d = cb + if (b - a) <= 0 or (d - c) <= 0: + return [vx0, vx1, vy0, vy1] + full_w = (dx1 - dx0) / (b - a) + full_h = (dy1 - dy0) / (d - c) + Dx0 = dx0 - a * full_w + Dx1 = Dx0 + full_w + # origin='upper': row 0 (fraction c) corresponds to data y = dy1 (top). + Dy1 = dy1 + c * full_h + Dy0 = Dy1 - full_h + return [Dx0, Dx1, Dy0, Dy1] def _replace_bg_artists(self): """Remove existing bg artists on every 2D axis and re-create them @@ -1786,11 +1836,8 @@ def _replace_bg_artists(self): if self._bg_image is None: continue if self._bg_glued and self._bg_extent is not None: - img_data = self._bg_display_image \ - if self._bg_display_image is not None \ - else self._bg_image bg_artist = ax.imshow( - img_data, extent=self._bg_extent, + self._bg_image, extent=self._bg_extent, transform=ax.transData, aspect='auto', zorder=0, origin='upper', interpolation='bilinear') @@ -1824,50 +1871,45 @@ def onBgModeFixed(self, event): self._bg_extent = None self._bg_display_image = None self._bg_axes_extent = None + self._bg_crop_box = None self.canvas.draw_idle() return - cropped, ax_extent = self._compute_bg_screen_lock(self.fig.axes[0]) + cropped, ax_extent, crop_box = self._compute_bg_screen_lock( + self.fig.axes[0]) self._bg_glued = False self._bg_extent = None self._bg_display_image = cropped self._bg_axes_extent = ax_extent + self._bg_crop_box = crop_box self._replace_bg_artists() self.canvas.draw_idle() def onBgModeMoving(self, event): """'Moving with axes' mode: the image is glued to data coordinates. - Compute a data-coord extent for the bg such that the switch is - visually invisible: take whatever portion of the bg is currently - visible (axes-fraction extent in Fixed-locked mode, or [0,1,0,1] - in Fixed-default mode) and map it to data coords using the - current viewport. The image array used is whatever was on screen - (cropped if Fixed-locked, full if Fixed-default). - - After this, the user can pan/zoom freely; the bg stays put in - data space (its screen position changes with the viewport). + Render the FULL _bg_image at a data-coord extent chosen so the + currently-displayed crop lands exactly where it was on screen. The + previously-hidden portions of the bg (axis labels etc.) become + accessible by panning/zooming the axes. As the user pans/zooms, + the bg stays put in data space (its screen position changes + naturally with the viewport). """ if len(self.fig.axes) == 0 or self._bg_image is None: return ax = self.fig.axes[0] - xlim = ax.get_xlim() - ylim = ax.get_ylim() - vx0, vx1 = sorted(xlim) - vy0, vy1 = sorted(ylim) - ax_ext = self._bg_axes_extent if self._bg_axes_extent is not None \ - else [0.0, 1.0, 0.0, 1.0] - afx0, afx1, afy0, afy1 = ax_ext - self._bg_extent = [ - vx0 + afx0 * (vx1 - vx0), - vx0 + afx1 * (vx1 - vx0), - vy0 + afy0 * (vy1 - vy0), - vy0 + afy1 * (vy1 - vy0), - ] + if (self._bg_axes_extent is not None + or self._bg_crop_box is not None): + self._bg_extent = self._full_extent_from_state(ax) + else: + xlim = ax.get_xlim() + ylim = ax.get_ylim() + vx0, vx1 = sorted(xlim) + vy0, vy1 = sorted(ylim) + self._bg_extent = [vx0, vx1, vy0, vy1] self._bg_glued = True + self._bg_display_image = None self._bg_axes_extent = None - # Keep _bg_display_image so the image used in transData matches - # what was just on screen (the cropped portion in Fixed-locked - # mode, or None which falls back to _bg_image in Fixed-default). + self._bg_crop_box = None self._replace_bg_artists() self.canvas.draw_idle() @@ -2584,11 +2626,8 @@ def plot_all(self, autoscale=True): # Fixed-default: transAxes with full image at [0,1,0,1] if self._bg_image is not None and not hasattr(ax_left, 'set_zlim'): if self._bg_glued and self._bg_extent is not None: - img_data = self._bg_display_image \ - if self._bg_display_image is not None \ - else self._bg_image bg_artist = ax_left.imshow( - img_data, extent=list(self._bg_extent), + self._bg_image, extent=list(self._bg_extent), transform=ax_left.transData, aspect='auto', zorder=0, origin='upper', interpolation='bilinear') From 4622c310eea187609b56f76a1b646146708501de Mon Sep 17 00:00:00 2001 From: SimonHH <38310787+SimonHH@users.noreply.github.com> Date: Fri, 8 May 2026 15:34:39 +0200 Subject: [PATCH 35/41] us np.testing.assert_array_equal in test_Tables.py to improve readability --- tests/test_Tables.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_Tables.py b/tests/test_Tables.py index 5e5c0eb..abcf107 100644 --- a/tests/test_Tables.py +++ b/tests/test_Tables.py @@ -62,7 +62,7 @@ def test_vstack(self): name, df = tablist.vstack(commonOnly=True) np.testing.assert_almost_equal(df['Index'], [0,1,2,3,4,5,6,7,8]) np.testing.assert_almost_equal(df['ColA'], np.concatenate((tab1.data['ColA'], tab2.data['ColA'], ))) - np.testing.assert_equal(df.columns.values, ['Index','ColA']) + np.testing.assert_array_equal(df.columns.values, ['Index','ColA']) def test_resample(self): @@ -109,12 +109,12 @@ def test_change_units(self): np.testing.assert_almost_equal(tab.data.values[:,1],[1]) np.testing.assert_almost_equal(tab.data.values[:,2],[2]) np.testing.assert_almost_equal(tab.data.values[:,3],[10]) - np.testing.assert_equal(tab.columns, ['Index','om [rpm]', 'F [kN]', 'angle_[deg]']) + np.testing.assert_array_equal(tab.columns, ['Index','om [rpm]', 'F [kN]', 'angle_[deg]']) def test_renameColumns(self): tab = Table.createDummy(n=3, columns=['RtFldCp [-]','B1FldFx [N]', 'angle [rad]']) tab.renameColumns(strReplDict={'Aero':'Fld'}) - np.testing.assert_equal(tab.columns, ['Index','RtAeroCp [-]', 'B1AeroFx [N]', 'angle [rad]']) + np.testing.assert_array_equal(tab.columns, ['Index','RtAeroCp [-]', 'B1AeroFx [N]', 'angle [rad]']) if __name__ == '__main__': # TestTable.setUpClass() From b4d4b57882f4a9f766119c23d10782d73af05b66 Mon Sep 17 00:00:00 2001 From: SimonHH <38310787+SimonHH@users.noreply.github.com> Date: Fri, 8 May 2026 15:38:04 +0200 Subject: [PATCH 36/41] use np.testing.assert_array_equal in test_signal.py --- tests/test_signal.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/test_signal.py b/tests/test_signal.py index 8d57d22..9c3fc33 100644 --- a/tests/test_signal.py +++ b/tests/test_signal.py @@ -26,17 +26,17 @@ def test_zero_crossings(self): def test_up_down_sample(self): name = 'Time-based' x, y = applySampler(range(0, 4), [5, 0, 5, 0], {'name': name, 'param': [2]}) - self.assertTrue(np.all(x==[0.5, 2.5])) - self.assertTrue(np.all(y==[2.5, 2.5])) + np.testing.assert_array_equal(x,[0.5, 2.5]) + np.testing.assert_array_equal(y,[2.5, 2.5]) x, y = applySampler(range(0, 3), [5, 0, 5], {'name': name, 'param': [0.5]}) - self.assertTrue(np.all(x==[0, 0.5, 1, 1.5, 2])) - self.assertTrue(np.all(y==[5, 2.5, 0, 2.5, 5])) + np.testing.assert_array_equal(x,[0, 0.5, 1, 1.5, 2]) + np.testing.assert_array_equal(y,[5, 2.5, 0, 2.5, 5]) x, df = applySampler(range(0, 6), None, {'name': name, 'param': [3]}, pd.DataFrame({"y": [0, 6, 6, 2, -4, -4]})) - self.assertTrue(np.all(x==[1, 4])) - self.assertTrue(np.all(df["y"]==[4, -2])) + np.testing.assert_array_equal(x,[1, 4]) + np.testing.assert_array_equal(df["y"],[4, -2]) x, df = applySampler(range(0, 3), None, {'name': name, 'param': [0.5]}, pd.DataFrame({"y": [0, 6, -6]})) - self.assertTrue(np.all(x==[0, 0.5, 1, 1.5, 2])) - self.assertTrue(np.all(df["y"]==[0, 3, 6, 0, -6])) + np.testing.assert_array_equal(x,[0, 0.5, 1, 1.5, 2]) + np.testing.assert_array_equal(df["y"],[0, 3, 6, 0, -6]) def test_interpDF(self): From 415cc576dfcd257111846700c8ac07ac5ff09003 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 8 May 2026 14:12:37 +0000 Subject: [PATCH 37/41] fix: use ms precision for Time-based resampler to support pandas 2.2+ In pandas 2.2+, pd.to_timedelta(x, unit='s') creates a timedelta64[s] index. Resampling at sub-second intervals then fails because the bin endpoint (e.g. 500ms) cannot be losslessly cast to seconds precision. Switch to milliseconds for both the time index and the resample offset string, which gives sufficient precision for sub-second intervals and removes the now-unnecessary pandas version-check workaround. https://claude.ai/code/session_01WvnvMa5iWDmswWc7PwbZNQ --- pydatview/tools/signal_analysis.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/pydatview/tools/signal_analysis.py b/pydatview/tools/signal_analysis.py index ea5a487..d86179d 100644 --- a/pydatview/tools/signal_analysis.py +++ b/pydatview/tools/signal_analysis.py @@ -259,13 +259,9 @@ def applySampler(x_old, y_old, sampDict, df_old=None): sample_time = float(param[0]) if sample_time <= 0: raise Exception('Error: sample time must be positive') - # --- Version dependency... - pdVer = [int(s) for s in pd.__version__.split('.')] - sSample = "{:f}s".format(sample_time) - if pdVer[0]<=1 or (pdVer[0]<=2 and pdVer[1]<2): - sSample = "{:f}S".format(sample_time) - - time_index = pd.to_timedelta(x_old, unit="s") + sample_time_ms = int(round(sample_time * 1000)) + sSample = "{}ms".format(sample_time_ms) + time_index = pd.to_timedelta(np.asarray(x_old, dtype=float) * 1000, unit="ms") x_new = pd.Series(x_old, index=time_index).resample(sSample).mean().interpolate().values if df_old is not None: From 0e15e21045f23b240f7eb81cd1f4a754df01b205 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 8 May 2026 14:44:19 +0000 Subject: [PATCH 38/41] fix: use explicit column assignment in changeUnits WE flavor In modern pandas (>= 2.0), df.iloc[:,i] returns a copy rather than a view, so the tuple-unpacking assignment and in-place multiplication in change_units_to_WE did not persist back to the dataframe. Align the WE path with the already-correct SI path by capturing the return value and explicitly writing it back with df.iloc[:,i] = col_new. https://claude.ai/code/session_017KyqXDVvGeHEoE2YM74Qup --- pydatview/tools/pandalib.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pydatview/tools/pandalib.py b/pydatview/tools/pandalib.py index de3dfdd..5d36fc8 100644 --- a/pydatview/tools/pandalib.py +++ b/pydatview/tools/pandalib.py @@ -222,7 +222,9 @@ def change_units_to_SI(s, c): if flavor == 'WE': cols = [] for i, colname in enumerate(df.columns): - colname_new, df.iloc[:,i] = change_units_to_WE(colname, df.iloc[:,i]) + colname_new, col_new = change_units_to_WE(colname, df.iloc[:,i]) + df[colname] = df[colname].astype(col_new.dtype) + df.iloc[:,i] = col_new cols.append(colname_new) df.columns = cols elif flavor == 'SI': From ff5540ee7665f72c21f9c38d1e6e133671e4d1b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 8 May 2026 14:50:30 +0000 Subject: [PATCH 39/41] fix: wrap tab.columns in list() for assert_equal comparison np.testing.assert_equal raises ValueError when comparing a pandas Index directly against a list because the truth value of an array is ambiguous. https://claude.ai/code/session_01R1Zn4U6BEuWmXXeCPy1mJs --- pydatview/plugins/tests/test_standardizeUnits.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pydatview/plugins/tests/test_standardizeUnits.py b/pydatview/plugins/tests/test_standardizeUnits.py index bfa9540..7c44785 100644 --- a/pydatview/plugins/tests/test_standardizeUnits.py +++ b/pydatview/plugins/tests/test_standardizeUnits.py @@ -18,7 +18,7 @@ def test_change_units(self): np.testing.assert_almost_equal(tab.data.values[:,1],[1]) np.testing.assert_almost_equal(tab.data.values[:,2],[2]) np.testing.assert_almost_equal(tab.data.values[:,3],[10]) - np.testing.assert_equal(tab.columns, ['Index','om [rpm]', 'F [kN]', 'angle_[deg]']) + np.testing.assert_equal(list(tab.columns), ['Index','om [rpm]', 'F [kN]', 'angle_[deg]']) if __name__ == '__main__': From 30d96ad951cc57806367ec40a61ed53f07ffc337 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 8 May 2026 14:56:46 +0000 Subject: [PATCH 40/41] Revert "fix: wrap tab.columns in list() for assert_equal comparison" This reverts commit ff5540ee7665f72c21f9c38d1e6e133671e4d1b6. --- pydatview/plugins/tests/test_standardizeUnits.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pydatview/plugins/tests/test_standardizeUnits.py b/pydatview/plugins/tests/test_standardizeUnits.py index 7c44785..bfa9540 100644 --- a/pydatview/plugins/tests/test_standardizeUnits.py +++ b/pydatview/plugins/tests/test_standardizeUnits.py @@ -18,7 +18,7 @@ def test_change_units(self): np.testing.assert_almost_equal(tab.data.values[:,1],[1]) np.testing.assert_almost_equal(tab.data.values[:,2],[2]) np.testing.assert_almost_equal(tab.data.values[:,3],[10]) - np.testing.assert_equal(list(tab.columns), ['Index','om [rpm]', 'F [kN]', 'angle_[deg]']) + np.testing.assert_equal(tab.columns, ['Index','om [rpm]', 'F [kN]', 'angle_[deg]']) if __name__ == '__main__': From f7b5107e35e5bd75a7152901a497a23d750fea6f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 8 May 2026 14:57:16 +0000 Subject: [PATCH 41/41] fix: convert tab.columns to list before assert_equal comparison MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tab.columns returns a numpy array (self.data.columns.values). Newer numpy raises ValueError when assert_equal internally evaluates the truth value of the element-wise comparison result. This is not masking a bug from PR #10 — the column renaming in changeUnits WE flavor works correctly, and the numerical value assertions on lines 18-20 independently verify correctness. https://claude.ai/code/session_01R1Zn4U6BEuWmXXeCPy1mJs --- pydatview/plugins/tests/test_standardizeUnits.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pydatview/plugins/tests/test_standardizeUnits.py b/pydatview/plugins/tests/test_standardizeUnits.py index bfa9540..7c44785 100644 --- a/pydatview/plugins/tests/test_standardizeUnits.py +++ b/pydatview/plugins/tests/test_standardizeUnits.py @@ -18,7 +18,7 @@ def test_change_units(self): np.testing.assert_almost_equal(tab.data.values[:,1],[1]) np.testing.assert_almost_equal(tab.data.values[:,2],[2]) np.testing.assert_almost_equal(tab.data.values[:,3],[10]) - np.testing.assert_equal(tab.columns, ['Index','om [rpm]', 'F [kN]', 'angle_[deg]']) + np.testing.assert_equal(list(tab.columns), ['Index','om [rpm]', 'F [kN]', 'angle_[deg]']) if __name__ == '__main__':