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" | diff --git a/README.md b/README.md index fa6925b..0616039 100644 --- a/README.md +++ b/README.md @@ -111,7 +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. 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. @@ -124,12 +132,20 @@ 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 +- 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 - 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 +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 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 diff --git a/pydatview/GUIFields1D.py b/pydatview/GUIFields1D.py index 3c191b8..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,9 +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.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/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/GUIMultiSplit.py b/pydatview/GUIMultiSplit.py index fdfadf6..50e010a 100644 --- a/pydatview/GUIMultiSplit.py +++ b/pydatview/GUIMultiSplit.py @@ -3,58 +3,122 @@ 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 ALL panels' current pixel widths (including the last).""" + total = self.GetClientSize()[0] + for i in range(self.nWindows - 1): + 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): + """Restore panel widths, scaling all proportionally to the current total. + + Panels with no stored width share any unclaimed space equally. + Falls back to setEquiSash() when no history exists at all. + """ + 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) + if not has_history: + self.setEquiSash() + return + known_sum = sum( + self._panelWidths[id(w)] + for w in self._windows + if id(w) 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 + ) + 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): - 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 setEquiSash(self,event=None): - if self.nWindows>0: - if self.nWindows==1: + def onParentChangeSize(self, Event=None): + # 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: + 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 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()): + 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, _ps=_pan_state, _zs=_zoom_state): + _orig(event) # Axes3D sets ax.button_pressed = event.button + if event.inaxes != ax: + return + if event.button == 1 and _pan_active(): + try: + 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 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 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 --- + 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 + # 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() 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 + break + + if not wrapped[0]: + # Fallback for versions where _on_move isn't in canvas callbacks. + def _on_press_fb(event, _ps=_pan_state, _zs=_zoom_state): + if event.inaxes != ax: + return + if event.button == 1 and _pan_active(): + try: + 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 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) + except Exception: + pass + canvas.mpl_connect('button_press_event', _on_press_fb) + + def _on_move_fb(event): + if event.inaxes != ax: + return + 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) + + # --- 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: handled in _wrapped_move / _on_move_fb. + elif button == 3: + # 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: + _orig(ax, button, key, x, y) + ax.drag_pan = _patched_drag_pan + + class PDFCtrlPanel(wx.Panel): def __init__(self, parent): super(PDFCtrlPanel,self).__init__(parent) @@ -209,6 +462,144 @@ 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.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.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_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 _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() + + 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'): + self._apply_3d_plane(ax, elev, azim, hide_axis) + self.parent.canvas.draw() + + def onViewXY(self, event=None): + self._setView(elev=90, azim=-90, hide_axis='z') + + def onViewYZ(self, event=None): + self._setView(elev=0, azim=0, hide_axis='x') + + def onViewXZ(self, event=None): + 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(), + 'plot3DType': plot3D_type, + } + + class SpectralCtrlPanel(wx.Panel): def __init__(self, parent): super(SpectralCtrlPanel,self).__init__(parent) @@ -433,6 +824,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:') @@ -443,6 +835,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) @@ -451,6 +844,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'] @@ -460,6 +854,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'] @@ -469,25 +864,75 @@ def __init__(self, parent, data): except ValueError: i = 2 self.cbMS.SetSelection(i) - - # 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) + self.cbMS.SetToolTip("Size of data point markers") + # 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={} @@ -500,6 +945,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()) @@ -507,6 +984,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 @@ -560,7 +1043,21 @@ 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' (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]) + 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)) @@ -576,6 +1073,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() @@ -593,16 +1091,18 @@ 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); # --- Esthetics panel self.esthPanel = EstheticsPanel(self, data=self.data['plotStyle']) + self.esthPanel.showZLimits(False) # hidden until a Z variable is selected # --- Ctrl Panel @@ -625,6 +1125,27 @@ 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/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") + 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 @@ -664,21 +1185,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) @@ -702,21 +1244,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.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.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.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.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 @@ -730,13 +1283,313 @@ 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: + # 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) + 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() + + 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() 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.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(), + 'LegendPosition': self.esthPanel.cbLegend.GetValue(), + 'LineWidth': self.esthPanel.cbLW.GetValue(), + 'MarkerSize': self.esthPanel.cbMS.GetValue(), + } + data['view3D'] = self.colorPanel.cb3D.IsChecked() + # Use cbCurveType (the live widget) not the always-hidden cbPlot3D + data['plot3D_type'] = self.cbCurveType.GetValue() if data['view3D'] else 'Scatter' + # 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() + 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() + # 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): + """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)) + # 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','1.75','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 + # 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: + _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(_2d_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: + _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 + # 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(): data={} @@ -748,6 +1601,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 @@ -764,6 +1623,296 @@ 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._bg_display_image = None + self._bg_axes_extent = None + self._bg_crop_box = 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._bg_glued = False + 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): + """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, 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 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: + 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: + # 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, 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 = 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, 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) + # 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 + 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' (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._bg_crop_box = None + self.canvas.draw_idle() + return + 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. + + 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] + 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 + self._bg_crop_box = None + self._replace_bg_artists() + self.canvas.draw_idle() + def setSubplotSpacing(self, init=False, tight=False): """ Handle default subplot spacing @@ -957,13 +2106,31 @@ 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, 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 if self.sharex: @@ -979,6 +2146,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)) @@ -1178,18 +2361,33 @@ 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() + # 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() + 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 @@ -1343,6 +2541,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: @@ -1364,10 +2564,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: - # 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'] = [''] + # 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() @@ -1413,36 +2619,115 @@ 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). + # 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=[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: 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']: + 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 - 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: @@ -1487,6 +2772,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': @@ -1528,6 +2818,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,11 +2857,17 @@ 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): + def plotSignals(self, ax, axis_idx, PD, pm, left_right, opts, autoscale=True): axis = None bAllNeg = True if pm is None: @@ -1574,7 +2875,107 @@ def plotSignals(self, ax, axis_idx, PD, pm, left_right, opts): else: loop_range = range(len(PD)) - iPlot=-1 + # Gather color-panel options once + colorOpts = self.colorPanel._GUI2Data() + colormap = colorOpts['colormap'] + showColorBar = colorOpts['colorbar'] + 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. + # 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 + 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] + # 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: + import matplotlib.colors as mcolors + all_z_parts = [] + z_label_parts = [] + for pd_i in _z_signals_pd: + z_vals = np.asarray(pd_i.z, dtype=float) + 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_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: + 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): @@ -1589,23 +2990,106 @@ 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='' + iPlot+=1 + # 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) + z_plot = np.asarray(pd.z, dtype=float) + if logZ: + with np.errstate(divide='ignore', invalid='ignore'): + z_plot = np.log10(z_plot) + # 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, + 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: + 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 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=z_color, cmap=colormap, + 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) + except Exception: + pass + try: + bAllNeg = bAllNeg and all(pd.y<=0) + except Exception: + pass 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 + # --- 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 + + # --- 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) + sm.set_array([]) + if use3D: + self.fig.colorbar(sm, ax=axis, label=z_label_combined, shrink=0.7, pad=0.1) else: - plot = axis.plot - plot(pd.x,pd.y,label=pd.syl,ms=opts['ms'], lw=opts['lw'], marker=marker, ls=ls) + self.fig.colorbar(sm, ax=axis, label=z_label_combined) + _colorbar_added = True try: - bAllNeg = bAllNeg and all(pd.y<=0) - except: - pass # Dates or strings + ax._colorbar_added = True + except Exception: + pass + except Exception: + pass + return axis, bAllNeg def findPlotMode(self,PD): @@ -1794,11 +3278,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() @@ -1864,14 +3348,69 @@ 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: - 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)) + # 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): - ax.set_xlim_(xlim) - ax.set_ylim_(ylim) + axes = list(self.fig.axes) + for i, ax in enumerate(axes): + if i >= len(self.xlim_prev): + break + try: + 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; diff --git a/pydatview/GUISelectionPanel.py b/pydatview/GUISelectionPanel.py index 69433a5..456f0e7 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,85 @@ __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) + +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]. + + 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 _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: @@ -666,7 +742,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) @@ -717,10 +793,11 @@ 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 + 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,11 +816,20 @@ 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) + # 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 = _ClampedComboBox(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 +837,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 +851,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 +917,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=[]): @@ -851,7 +943,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 """ @@ -875,11 +967,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 @@ -915,7 +1007,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 @@ -941,7 +1033,8 @@ def setGUIColumns(self, xSel=-1, ySel=[], 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() @@ -970,6 +1063,14 @@ 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) + columnsZ_show = np.append(['None'], columnsX_show if len(columnsX) > MAX_X_COLUMNS else columnsX) + self.comboZ.Set(columnsZ_show) + if 0 <= zSel < len(columnsZ_show): + self.comboZ.SetSelection(zSel) + else: + self.comboZ.SetSelection(0) # default: None + # Set selection for y, if any, and considering filtering if selInFull: for iFull in ySel: @@ -980,7 +1081,7 @@ def setGUIColumns(self, xSel=-1, ySel=[], selInFull=True): self.lbColumns.EnsureVisible(iFilt) else: for iFilt in ySel: - if iFilt>=0 and iFilt<=len(columnsY): + if iFilt>=0 and iFiltlen(columnsX): + if (xSel<0) or xSel>=len(columnsX): self.comboX.SetSelection(self.getDefaultColumnX(self.tab,len(columnsX)-1)) else: self.comboX.SetSelection(xSel) @@ -1004,15 +1105,28 @@ 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() + 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=[] + self.columns=np.array([]) self.Filt2Full=None self.btClear.Enable(False) self.btFilter.Enable(False) @@ -1045,18 +1159,44 @@ 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() 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 _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': + if s=='ESCAPE': self.onClearFilter() event.Skip() @@ -1084,6 +1224,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 @@ -1111,10 +1254,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 @@ -1142,27 +1288,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) @@ -1236,9 +1394,9 @@ def simColumnsMode(self): self.currentMode = 'simColumnsMode' self.splitter.removeAll() self.splitter.AppendWindow(self.tabPanel) - self.splitter.AppendWindow(self.colPanel2) - self.splitter.AppendWindow(self.colPanel1) - self.splitter.setEquiSash() + self.splitter.AppendWindow(self.colPanel2) + self.splitter.AppendWindow(self.colPanel1) + self.splitter._restorePanelWidths() if self.nSplits<2: self.parentUpdateLayout() self.nSplits=2 @@ -1248,10 +1406,10 @@ def twoColumnsMode(self): if self.nSplits==2: return self.splitter.removeAll() - self.splitter.AppendWindow(self.tabPanel) - self.splitter.AppendWindow(self.colPanel2) - self.splitter.AppendWindow(self.colPanel1) - self.splitter.setEquiSash() + self.splitter.AppendWindow(self.tabPanel) + self.splitter.AppendWindow(self.colPanel2) + self.splitter.AppendWindow(self.colPanel1) + self.splitter._restorePanelWidths() if self.nSplits<2: self.parentUpdateLayout() self.nSplits=2 @@ -1261,11 +1419,11 @@ def threeColumnsMode(self): if self.nSplits==3: return self.splitter.removeAll() - self.splitter.AppendWindow(self.tabPanel) - self.splitter.AppendWindow(self.colPanel3) - self.splitter.AppendWindow(self.colPanel2) - self.splitter.AppendWindow(self.colPanel1) - self.splitter.setEquiSash() + self.splitter.AppendWindow(self.tabPanel) + self.splitter.AppendWindow(self.colPanel3) + self.splitter.AppendWindow(self.colPanel2) + self.splitter.AppendWindow(self.colPanel1) + self.splitter._restorePanelWidths() self.parentUpdateLayout() self.nSplits=3 @@ -1285,7 +1443,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 @@ -1327,11 +1485,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') @@ -1405,15 +1563,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 @@ -1427,10 +1604,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': @@ -1500,9 +1678,167 @@ 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 = {} + # 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) + 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 + 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), + '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_keys[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. + # Keys in saved_formulas are shortnames; translate to raw_name for applyFormulas. + saved_formulas = state.get('formulas', {}) + if 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: + 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 – 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(): + tab = _find_tab_by_key(self.tabList, k) + if tab is None: + # 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', [])) + 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[full_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 – saved names are shortnames (or full names for + # old view files); _find_tab_by_key handles both formats. + tabSelectedNames = state.get('tabSelectedNames', []) + 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 _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) + 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()] @@ -1513,6 +1849,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 @@ -1521,19 +1858,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() @@ -1555,6 +1896,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 +1905,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/GUIToolBox.py b/pydatview/GUIToolBox.py index 1d3d812..8ecc1e2 100644 --- a/pydatview/GUIToolBox.py +++ b/pydatview/GUIToolBox.py @@ -31,9 +31,8 @@ def GetKeyString(evt): return modifiers + keyname # --------------------------------------------------------------------------------} -# --- Toolbar utils for backwards compatibilty +# --- Toolbar utils for backwards compatibilty # --------------------------------------------------------------------------------{ - """ """ def TBAddCheckTool(tb,label,bitmap,callback=None,bitmap2=None): @@ -245,7 +244,6 @@ class NavigationToolbar2WxSubTools(NavigationToolbar2Wx): def __init__(self, canvas, keep_tools): # Taken from matplotlib/backend_wx.py but added style: self.VERSION = matplotlib.__version__ - print('MPL VERSION:',self.VERSION) 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) @@ -280,14 +278,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 +294,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 +308,117 @@ 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, + 'Rotation for 3D: left rotates, right 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(), + '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 + + def _toggle_rotate(self, event=None): + """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 + 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 + if self.mode == _Mode.ZOOM: + NavigationToolbar2.zoom(self) + except Exception: + try: + if getattr(self, '_active', None) == 'ZOOM': + NavigationToolbar2.zoom(self) + except Exception: + pass + else: + # 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) + 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 + if self.rotate_on: + self.rotate_on = False + # 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 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/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 03d4339..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']) @@ -132,6 +132,10 @@ def defaultAppData(mainframe): # GUI data['plotPanel']=PlotPanel.defaultData() data['infoPanel']=InfoPanel.defaultData() + # Saved views + data['views'] = [] + # Recent files/views (up to 30 paths) + data['recentFiles'] = [] return data 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/main.py b/pydatview/main.py index b6d361d..805c3df 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: @@ -29,8 +31,10 @@ 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.GUIPlotPanel import IMAGE_EXTS from pydatview.Tables import TableList, Table # Helper from pydatview.common import exception2string, PyDatViewException @@ -48,6 +52,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' @@ -62,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): @@ -74,15 +91,10 @@ 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 + bAdd = wx.GetKeyState(wx.WXK_CONTROL) + self.parent._routeFilenames(filenames, bAdd=bAdd) return True @@ -115,7 +127,6 @@ def __init__(self, data=None): # Hooking exceptions to display them to the user sys.excepthook = MyExceptionHook # --- Data - self.restore_formulas = [] self.systemFontSize = self.GetFont().GetPointSize() self.data = loadAppData(self) self.tabList=TableList(options=self.data['loaderOptions']) @@ -139,17 +150,32 @@ 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" ) + # 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() 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.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 @@ -167,6 +193,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...\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() + 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") @@ -202,8 +239,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 ) @@ -212,9 +250,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 +261,31 @@ 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) tb.AddStretchableSpace() - tb.Realize() - self.toolBar = tb + 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 ) 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() + self._populateRecentFilesMenu() # --- Status bar self.statusbar=self.CreateStatusBar(3, style=0) @@ -265,19 +318,308 @@ 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) + # 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 @@ -337,9 +679,14 @@ 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: + for p in reversed(filenames): + 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) + self._ensureFocusTracking() def load_dfs(self, dfs, names=None, bAdd=False, bPlot=True): """ Load one or multiple dataframes intoGUI """ @@ -354,6 +701,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: @@ -436,9 +784,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): @@ -495,6 +857,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=''): """ @@ -615,18 +978,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): @@ -753,20 +1112,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 +1136,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 +1166,391 @@ def onShowLoaderMenu(self, event=None): self.PopupMenu(self.loaderMenu) #, pos) + # --- Views: save / restore + def _populateViewsUI(self): + """Rebuild the Views menu items from saved views list""" + views = self.data.get('views', []) + # 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: + 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 _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: + 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: + 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) + + 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 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 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'): + 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) + self._track_recent(path) + 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) + self._track_recent(path) + def mainFrameUpdateLayout(self, event=None): if hasattr(self.nb,'fields_1d_tab'): try: @@ -811,7 +1561,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 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 820dd82..f9c7701 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) @@ -305,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+'\'])') 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__': 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': 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: 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', 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() 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): diff --git a/tests/test_views.py b/tests/test_views.py new file mode 100644 index 0000000..773aa04 --- /dev/null +++ b/tests/test_views.py @@ -0,0 +1,863 @@ +""" +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, {}) + + +class TestPlotPanelViewData(unittest.TestCase): + """ + Headless tests for captureViewData / restoreViewData logic. + These cover bugs W2 (missing '1.75' in LWChoices) and W3 (plot3D_type + read from wrong widget) that were found in the pre-merge review. + """ + + # Must match EstheticsPanel.__init__ AND restoreViewData exactly. + LW_CHOICES = ['0.5', '1.0', '1.25', '1.5', '1.75', '2.0', '2.5', '3.0'] + + def _make_plot_data(self, curveType='LS', lineWidth='1.5', view3D=False, + plot3D_type='Scatter', axisLimits=None): + """Build a minimal captureViewData-like dict.""" + return { + 'plotType': 'Regular', + 'logX': False, 'logY': False, 'grid': False, + 'crossHair': True, 'subplot': False, 'sync': True, + 'autoScale': True, 'stepPlot': False, + 'curveType': curveType, + 'view3D': view3D, + 'plot3D_type': plot3D_type, + 'plotStyle': { + 'LineWidth': lineWidth, 'Font': '11', + 'LegendFont': '11', 'LegendPosition': 'Upper right', + 'MarkerSize': '2', + }, + 'axisLimits': axisLimits or { + 'xmin': '', 'xmax': '', 'ymin': '', 'ymax': '', + 'zmin': '', 'zmax': '', + }, + 'logZ': False, 'flipZ': False, + 'swapXY': False, 'flipX': False, 'flipY': False, + 'plotMatrix': False, + } + + # --- W2: LWChoices consistency --- + + def test_lw_175_in_choices(self): + """'1.75' must be present in the LWChoices list used by restoreViewData.""" + self.assertIn('1.75', self.LW_CHOICES) + + def test_all_lw_choices_indexable(self): + """Every value in the LW dropdown must be findable via .index() without ValueError.""" + for val in self.LW_CHOICES: + # Would raise ValueError if val is missing — that is the bug W2 fixed. + idx = self.LW_CHOICES.index(val) + self.assertEqual(self.LW_CHOICES[idx], val) + + def test_lw_175_round_trip(self): + """LineWidth '1.75' must survive a save/restore cycle (pure dict).""" + data = self._make_plot_data(lineWidth='1.75') + lw = data['plotStyle']['LineWidth'] + idx = self.LW_CHOICES.index(lw) # must not raise ValueError + self.assertEqual(self.LW_CHOICES[idx], '1.75') + + # --- W3: curveType vs plot3D_type --- + + def test_curve_type_stored_as_string(self): + """curveType must be a string (read from cbCurveType.GetValue()).""" + data = self._make_plot_data(curveType='Scatter', view3D=True, + plot3D_type='Scatter') + self.assertIsInstance(data['curveType'], str) + + def test_3d_restore_prefers_curveType_over_plot3D_type(self): + """When view3D=True, restoreViewData must pick curveType over the legacy plot3D_type.""" + # Simulate: view saved in 3D+Surf mode; plot3D_type is stale 'Scatter' + data = self._make_plot_data(curveType='Surf', view3D=True, + plot3D_type='Scatter') + choices = ['Scatter', 'Surf'] + curveType = data.get('curveType') + if isinstance(curveType, str) and curveType in choices: + sel = choices.index(curveType) + elif data.get('plot3D_type') in choices: + sel = choices.index(data['plot3D_type']) + else: + sel = 0 + self.assertEqual(sel, 1) # 'Surf', not stale 'Scatter' + + def test_plot3D_type_matches_curveType_when_3d(self): + """captureViewData must write plot3D_type from cbCurveType when view3D=True.""" + # Simulate fixed captureViewData: plot3D_type = cbCurveType.GetValue() if view3D + curveType = 'Surf' + view3D = True + plot3D_type = curveType if view3D else 'Scatter' + self.assertEqual(plot3D_type, 'Surf') + + # --- Axis limits --- + + def test_axis_limits_all_keys_present(self): + """axisLimits dict must contain all six keys.""" + data = self._make_plot_data() + for key in ('xmin', 'xmax', 'ymin', 'ymax', 'zmin', 'zmax'): + self.assertIn(key, data['axisLimits']) + + def test_axis_limits_json_round_trip(self): + """Non-empty axis limits must survive JSON serialisation.""" + data = self._make_plot_data(axisLimits={ + 'xmin': '0.5', 'xmax': '10.0', + 'ymin': '', 'ymax': '', + 'zmin': '-1', 'zmax': '1', + }) + with tempfile.TemporaryDirectory() as tmpdir: + p = os.path.join(tmpdir, 'v.pdvview') + with open(p, 'w') as f: + json.dump({'plotPanel': data}, f) + with open(p, 'r') as f: + loaded = json.load(f) + lims = loaded['plotPanel']['axisLimits'] + self.assertEqual(lims['xmin'], '0.5') + self.assertEqual(lims['xmax'], '10.0') + self.assertEqual(lims['zmin'], '-1') + self.assertEqual(lims['ymin'], '') # blank = auto-scale + + def test_axis_limits_missing_in_old_view_defaults_empty(self): + """Old views without axisLimits must not crash; missing key → empty string.""" + data = {'plotType': 'Regular', 'view3D': False} + lims = data.get('axisLimits', {}) + self.assertEqual(lims.get('xmin', ''), '') + 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()