diff --git a/src/sas/qtgui/MainWindow/media/graph_help.rst b/src/sas/qtgui/MainWindow/media/graph_help.rst index 288fe2d01a..ff61ef1b8b 100755 --- a/src/sas/qtgui/MainWindow/media/graph_help.rst +++ b/src/sas/qtgui/MainWindow/media/graph_help.rst @@ -340,8 +340,14 @@ and the third is to send all sliced data to a single batch fit window. Clicking *Apply* will create a slicer for each selected plot with the parameters entered in the 'Slicer Parameters' window of the 'Slicer' tab. Depending on the options selected the data may then be saved, loaded as separate data sets in the data manager panel, and finally sent to fitting. -To remove a 'slicer', bring back the *Dataset menu* and select *Clear Slicer*. Alternatively, you can select *None* in the 'Slicer type' dropdown -list in the 'Slicer Parameters' dialog. +To remove a 'slicer', bring back the *Dataset menu* and select *Clear Slicers*. + +If a slicer is active, another slicer can be added to the same plot by repeating the process above. *Clear Slicers* will remove all slicers from the plot. +Individual slicers can be removed by selecting the slicer and clicking on *Delete* in the Slicer Parameters window. Additional slicers can also be added by selecting a slicer from the drop down list labelled *Create slicer*. +Changing the slicer on the drop down list labelled *Change slicer* will change the currently selected slicer to the one selected from the drop down list. + +.. note:: + The Delete slicer action cannot be undone. Unmasked circular average ^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -402,4 +408,4 @@ is, the central value of each bin on the x-axis. .. ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ -.. note:: This help document was last modified by Paul Butler, 05 September, 2016 +.. note:: This help document was last modified by Sujaya Shrestha, 17 November 2025 \ No newline at end of file diff --git a/src/sas/qtgui/Plotting/BoxSum.py b/src/sas/qtgui/Plotting/BoxSum.py index bfd69310f9..0f355fab12 100644 --- a/src/sas/qtgui/Plotting/BoxSum.py +++ b/src/sas/qtgui/Plotting/BoxSum.py @@ -45,4 +45,3 @@ def __init__(self, parent=None, model=None): # Handle the Close button click self.buttonBox.button(QtWidgets.QDialogButtonBox.Close).clicked.connect(lambda:self.closeWidgetSignal.emit()) - diff --git a/src/sas/qtgui/Plotting/Plotter2D.py b/src/sas/qtgui/Plotting/Plotter2D.py index 7e7b052b4a..5a72348f00 100644 --- a/src/sas/qtgui/Plotting/Plotter2D.py +++ b/src/sas/qtgui/Plotting/Plotter2D.py @@ -1,5 +1,6 @@ import copy import functools +import logging import matplotlib as mpl import numpy @@ -20,11 +21,32 @@ from sas.qtgui.Plotting.Slicers.SectorSlicer import SectorInteractor from sas.qtgui.Plotting.Slicers.WedgeSlicer import WedgeInteractorPhi, WedgeInteractorQ +logger = logging.getLogger(__name__) + DEFAULT_CMAP = mpl.cm.jet # Minimum value of Z for which we will present data. MIN_Z = -32 +# Color cycle for slicers (colorblind-friendly palette) +SLICER_COLORS = [ + '#E69F00', # orange + '#56B4E9', # sky blue + '#009E73', # bluish green + '#F0E442', # yellow + '#0072B2', # blue + '#D55E00', # vermillion + '#CC79A7', # reddish purple + '#000000', # black + '#E31A1C', # red + '#1F78B4', # light blue + '#33A02C', # green + '#FF7F00', # orange + '#6A3D9A', # purple + '#B15928', # brown + '#FDBF6F' # light orange +] + class Plotter2DWidget(PlotterBase): """ @@ -41,6 +63,7 @@ def __init__(self, parent=None, manager=None, quickplot=False, dimension=2): self.slicer_z = 5 # Reference to the current slicer self.slicer = None + self.slicers = {} # Change from list to dict self.slicer_widget = None self.vmin = None self.vmax = None @@ -52,6 +75,9 @@ def __init__(self, parent=None, manager=None, quickplot=False, dimension=2): self.manager = manager + # Track color index for slicer color cycling + self._slicer_color_index = 0 + @property def data(self): return self._data @@ -182,8 +208,8 @@ def createContextMenu(self): plot_slicer_menu.addSeparator() # Additional items for slicer interaction - if self.slicer: - plot_slicer_menu.actionClearSlicer = plot_slicer_menu.addAction("&Clear Slicer") + if (self.slicer is not None) or (hasattr(self.slicer, 'slicers') and len(self.slicers) > 0): + plot_slicer_menu.actionClearSlicer = plot_slicer_menu.addAction("&Clear Slicers") plot_slicer_menu.actionClearSlicer.triggered.connect(self.onClearSlicer) plot_slicer_menu.actionEditSlicer = plot_slicer_menu.addAction("&Edit Slicer Parameters") plot_slicer_menu.actionEditSlicer.triggered.connect(self.onEditSlicer) @@ -247,16 +273,37 @@ def onToggleScale(self, event): def onClearSlicer(self): """ - Remove all sclicers from the chart + Remove all slicers from the chart """ - if self.slicer is None: - return + # Clear all existing slicers + for slicer in self.slicers.values(): + slicer.clear() + self.slicers = {} + + # Clear box sum which is not stored in the dict + if self.slicer is not None: + self.slicer.clear() + if self.slicer is BoxSumCalculator: + self.boxwidget.close() + self.boxwidget_subwindow.close() + self.boxwidget = None + self.boxwidget_subwindow = None - self.slicer.clear() - self.canvas.draw() self.slicer = None + # Reset color index when all slicers are cleared + self._slicer_color_index = 0 + self._removeSlicerPlots() + + self.canvas.draw() + if self.slicer_widget: - self.slicer_widget.setModel(None) + self.slicer_widget.close() + self.slicer_widget = None + + # Close the box sum widget if it exists + if hasattr(self, 'boxwidget') and self.boxwidget is not None: + self.boxwidget.close() + self.boxwidget = None def getActivePlots(self): ''' utility method for manager query of active plots ''' @@ -411,81 +458,71 @@ def updateSlicer(self): if not has_plot: return - # Now that we've identified the right plot, update the 2D data the slicer uses - self.slicer.data = self.data0 - # Replot now that the 2D data is updated - self.slicer._post_data() - - def setSlicer(self, slicer, reset=True): - """ - Clear the previous slicer and create a new one. - slicer: slicer class to create + # Now that we've identified the right plot, update the 2D data the slicers uses + for slicer in self.slicers.values(): + slicer.data = self.data0 + # Replot now that the 2D data is updated + slicer._post_data() + + def _get_next_slicer_color(self): + """ + Get the next color for a new slicer and increment the index + """ + color = SLICER_COLORS[self._slicer_color_index % len(SLICER_COLORS)] # so colours don't overflow + self._slicer_color_index += 1 + return color + + def _recurse_plots_to_remove(self, item): + temp_plots_to_remove = [] + slicer_type_id = 'Slicer' + self.data0.name + for item_index in range(item.rowCount()): + child = item.child(item_index) + if child.hasChildren(): + self._recurse_plots_to_remove(child) + if (isinstance(child.data(), (Data1D, Data2D)) + and hasattr(child.data(), 'type_id') + and slicer_type_id in child.data().type_id + and child.rowCount() > 0 + and child.child(0).text()): + temp_plots_to_remove.append(item.child(item_index)) + + for plot in temp_plots_to_remove: + item.removeRow(plot.row()) + + def _removeSlicerPlots(self): + """ + Clear the previous slicer plots """ - # Clear current slicer - if self.slicer is not None: - self.slicer.clear() - # Clear the old slicer plots so they don't reappear later - if hasattr(self, '_item'): - item = self._item - if self._item.parent() is not None: - item = self._item.parent() - - # Go through all items and see if they are a plot. The checks done here are not as thorough - # as GuiUtils.deleteRedundantPlots (which this takes a lot from). Will this cause problems? - # Primary concern is the check (plot_data.plot_role == DataRole.ROLE_DELETABLE) as I don't - # know what it does. The other checks seem to be related to keeping the new plots for that function - # TODO: generalize this and put it in GuiUtils so that we can use it elsewhere - tempPlotsToRemove = [] - slicer_type_id = 'Slicer' + self.data0.name - for itemIndex in range(item.rowCount()): - # GuiUtils.plotsFromModel tests if the data is of type Data1D or Data2D to determine - # if it is a plot, so let's try that - if isinstance(item.child(itemIndex).data(), (Data1D, Data2D)): - # First take care of this item, then we'll take care of its children - if hasattr(item.child(itemIndex).data(), 'type_id'): - if slicer_type_id in item.child(itemIndex).data().type_id: - # At the time of writing, this should never be the case, but at some point the slicers may - # have relevant children (e.g. plots). We don't want to delete these slicers. - tempHasImportantChildren = False - for tempChildCheck in range(item.child(itemIndex).rowCount()): - # The data explorer uses the "text" attribute to set the name. If this has text='' then - # it can be deleted. - if item.child(itemIndex).child(tempChildCheck).text(): - tempHasImportantChildren = True - if not tempHasImportantChildren: - # Store this plot to be removed later. Removing now - # will cause the next plot to be skipped - tempPlotsToRemove.append(item.child(itemIndex)) - # It looks like the slicers are children of items that do not have data of instance Data1D or Data2D. - # Now do the children (1 level deep as is done in GuiUtils.plotsFromModel). Note that the slicers always - # seem to be the first entry (index2 == 0) - for itemIndex2 in range(item.child(itemIndex).rowCount()): - # Repeat what we did above (these if statements could probably be combined - # into one, but I'm not confident enough with how these work to say it wouldn't - # have issues if combined) - if isinstance(item.child(itemIndex).child(itemIndex2).data(), (Data1D, Data2D)): - if hasattr(item.child(itemIndex).child(itemIndex2).data(), 'type_id'): - if slicer_type_id in item.child(itemIndex).child(itemIndex2).data().type_id: - # Check for children we might want to keep (see the above loop) - tempHasImportantChildren = False - for tempChildCheck in range(item.child(itemIndex).child(itemIndex2).rowCount()): - # The data explorer uses the "text" attribute to set the name. If this has text='' - # then it can be deleted. - if item.child(itemIndex).child(itemIndex2).child(tempChildCheck).text(): - tempHasImportantChildren = True - if not tempHasImportantChildren: - # Remove the parent since each slicer seems to generate a new entry in item - tempPlotsToRemove.append(item.child(itemIndex)) - # Remove all the parent plots with matching criteria - for plot in tempPlotsToRemove: - item.removeRow(plot.row()) - # Delete the temporary list of plots to remove - del tempPlotsToRemove - - # Create a new slicer + if not hasattr(self, '_item'): + return + item = self._item + if self._item.parent() is not None: + item = self._item.parent() + self._recurse_plots_to_remove(item) + + def setSlicer(self, slicer): + """ Create a new slicer without removing the old one """ + # Clear the previous slicers if BoxSumCalculator is used or clear BoxSum if other slicer is used + if isinstance(self.slicer, BoxSumCalculator): + self.onClearSlicer() + elif self.slicer is not None and slicer == BoxSumCalculator: + # If we're creating a BoxSum but there are other slicers, clear them + self.onClearSlicer() + self.slicer_z += 1 - self.slicer = slicer(self, self.ax, item=self._item, zorder=self.slicer_z) + + # Get next color for this slicer + slicer_color = self._get_next_slicer_color() + + self.slicer = slicer(self, self.ax, item=self._item, color=slicer_color, zorder=self.slicer_z) + + # Generate a unique name for this slicer + slicer_name = f"{slicer.__name__}_{self.data0.name}_{len(self.slicers)}" + + # Store in dictionary with unique name as key + self.slicers[slicer_name] = self.slicer + self.ax.set_ylim(self.data0.ymin, self.data0.ymax) self.ax.set_xlim(self.data0.xmin, self.data0.xmax) # Draw slicer @@ -494,8 +531,23 @@ def setSlicer(self, slicer, reset=True): # Reset the model on the Edit slicer parameters widget self.param_model = self.slicer.model() - if self.slicer_widget and reset: - self.slicer_widget.setModel(self.param_model) + if self.slicer_widget: + # Update the slicers list and auto-check the newly created slicer + self.slicer_widget.updateSlicersList() + self.slicer_widget.checkSlicerByName(slicer_name) + self.setParamOnModel() + + def notifySlicerModified(self, slicer_obj): + """Notify the parameters dialog that a slicer was interacted with.""" + try: + # Find the slicer name from the mapping + for name, obj in self.slicers.items(): + if obj is slicer_obj: + if self.slicer_widget: + self.slicer_widget.checkSlicerByName(name) + break + except Exception as e: + logger.error("Error in notifySlicerModified: %s", str(e)) def onSectorView(self): """ @@ -503,6 +555,11 @@ def onSectorView(self): """ self.setSlicer(slicer=SectorInteractor) + def setParamOnModel(self): + """ Set the model on the slicer widget """ + if self.slicer_widget: + self.slicer_widget.setModel(self.param_model) + def onAnnulusView(self): """ Perform sector averaging on Phi and draw annulus slicer @@ -514,9 +571,22 @@ def onBoxSum(self): Perform 2D Data averaging Qx and Qy. Display box slicer details. """ + if self.slicer is not None or len(self.slicers) > 0: + # Pop up a confirmation dialog + reply = QtWidgets.QMessageBox.question(self, 'Delete Slicer', + 'Are you sure you want to delete this slicer?', + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, + QtWidgets.QMessageBox.No) + if reply == QtWidgets.QMessageBox.No: + return + self.onClearSlicer() self.slicer_z += 1 - self.slicer = BoxSumCalculator(self, self.ax, zorder=self.slicer_z) + + # Get next color for this slicer + slicer_color = self._get_next_slicer_color() + + self.slicer = BoxSumCalculator(self, self.ax, color=slicer_color, zorder=self.slicer_z) self.ax.set_ylim(self.data0.ymin, self.data0.ymax) self.ax.set_xlim(self.data0.xmin, self.data0.xmax) @@ -529,6 +599,9 @@ def boxWidgetClosed(): # reset box on "Edit Slicer Parameters" window close self.manager.parent.workspace().removeSubWindow(self.boxwidget_subwindow) self.boxwidget = None + # Clear the reference in the slicer + if self.slicer is not None: + self.slicer.widget = None # Get the BoxSumCalculator model. self.box_sum_model = self.slicer.model() @@ -538,6 +611,9 @@ def boxWidgetClosed(): self.boxwidget_subwindow = self.manager.parent.workspace().addSubWindow(self.boxwidget) self.boxwidget.closeWidgetSignal.connect(boxWidgetClosed) + # Store widget reference in the slicer so it can close it when cleared + self.slicer.widget = self.boxwidget + self.boxwidget.show() def onBoxAveragingX(self): diff --git a/src/sas/qtgui/Plotting/SlicerParameters.py b/src/sas/qtgui/Plotting/SlicerParameters.py index b1c9f24420..9f2099e22d 100644 --- a/src/sas/qtgui/Plotting/SlicerParameters.py +++ b/src/sas/qtgui/Plotting/SlicerParameters.py @@ -66,6 +66,9 @@ def __init__(self, parent=None, model=None, active_plots=None, validate_method=N # Initially, Apply is disabled self.cmdApply.setEnabled(False) + # Store models for each slicer - add this line + self.slicer_models = {} + # Mapping combobox index -> slicer module self.callbacks = { 0: None, @@ -109,6 +112,9 @@ def __init__(self, parent=None, model=None, active_plots=None, validate_method=N # Set up plots list self.setPlotsList() + # Set up slicers list - add this line + self.setSlicersList() + def setParamsList(self): """ Create and initially populate the list of parameters @@ -128,6 +134,87 @@ def updatePlotList(self): self.active_plots = self.parent.getActivePlots() self.setPlotsList() + def getCurrentSlicerDict(self): + """ + Returns a dictionary of currently shown slicers + {slicer_name:checkbox_status} + """ + current_slicers = {} + for row in range(self.lstSlicers.count()): + item = self.lstSlicers.item(row) + isChecked = item.checkState() != QtCore.Qt.Unchecked + slicer = item.text() + current_slicers[slicer] = isChecked + return current_slicers + + def setSlicersList(self): + """ + Create and initially populate the list of slicers with radio button behavior + """ + + self.lstSlicers.clear() + + # Create a button group for radio button behavior + if not hasattr(self, 'slicerButtonGroup'): + self.slicerButtonGroup = QtWidgets.QButtonGroup(self) + self.slicerButtonGroup.setExclusive(True) + + # Determine which slicer should be selected based on the current model + slicer_to_select = None + if self.model is not None: + # Find which slicer has this model + for slicer_name, slicer_obj in self.parent.slicers.items(): + if hasattr(slicer_obj, '_model') and slicer_obj._model is self.model: + slicer_to_select = slicer_name + break + + # Fill out list of slicers + for idx, item in enumerate(self.parent.slicers): + # Create a widget to hold the radio button + widget = QtWidgets.QWidget() + layout = QtWidgets.QHBoxLayout(widget) + layout.setContentsMargins(5, 2, 5, 2) + + # Create radio button + radio = QtWidgets.QRadioButton(str(item)) + + # Check if this should be selected + should_select = (slicer_to_select == str(item)) or (slicer_to_select is None and idx == 0) + radio.setChecked(should_select) + + # Add to button group + self.slicerButtonGroup.addButton(radio, idx) + + layout.addWidget(radio) + layout.addStretch() + + # Create list item + listItem = QtWidgets.QListWidgetItem(self.lstSlicers) + listItem.setSizeHint(widget.sizeHint()) + + # Add to list + self.lstSlicers.addItem(listItem) + self.lstSlicers.setItemWidget(listItem, widget) + + # Store the slicer's model + if item in self.parent.slicers: + slicer_obj = self.parent.slicers[item] + if hasattr(slicer_obj, '_model'): + self.slicer_models[str(item)] = slicer_obj._model + + # Connect radio button to update handler + radio.toggled.connect(lambda checked, name=str(item): self.onSlicerRadioToggled(checked, name)) + + def getCheckedSlicer(self): + """ + Returns the currently checked slicer (radio button) + """ + if not hasattr(self, 'slicerButtonGroup'): + return None + + checked_button = self.slicerButtonGroup.checkedButton() + return checked_button.text() if checked_button else None + def getCurrentPlotDict(self): """ Returns a dictionary of currently shown plots @@ -181,6 +268,9 @@ def setSlots(self): # Apply slicer to selected plots self.cmdApply.clicked.connect(self.onApply) + # Delete slicer + self.cmdDelete.clicked.connect(self.onDelete) + # Initialize slicer combobox to the current slicer current_slicer = type(self.parent.slicer) for index in self.callbacks: @@ -190,6 +280,9 @@ def setSlots(self): # change the slicer type self.cbSlicer.currentIndexChanged.connect(self.onSlicerChanged) + # Replace slicer type + self.cbSlicerReplace.currentIndexChanged.connect(self.onSlicerReplaceChanged) + # Updates to the slicer moder must propagate to all plotters if self.model is not None: self.model.itemChanged.connect(self.onParamChange) @@ -204,16 +297,41 @@ def onFocus(self, row, column): self.lstParams.setSelectionModel(selection_model) self.lstParams.setCurrentIndex(self.model.index(row, column)) - def onSlicerChanged(self, index): + def onSlicerChanged(self, index: int): """change the parameters based on the slicer chosen""" if index == 0: # No interactor - self.parent.onClearSlicer() - self.setModel(None) - self.onGeneratePlots(False) + return + # self.parent.onClearSlicer() + # self.setModel(None) + # self.onGeneratePlots(False) else: slicer = self.callbacks[index] if self.active_plots.keys(): self.parent.setSlicer(slicer=slicer) + # Reset combo box back to "None" after setting slicer + self.cbSlicer.blockSignals(True) + self.cbSlicer.setCurrentIndex(0) + self.cbSlicer.blockSignals(False) + self.onParamChange() + + def onSlicerReplaceChanged(self, index: int): + """replace the slicer with the one chosen""" + if index == 0: # No interactor + return + # self.parent.onClearSlicer() + # self.setModel(None) + # self.onGeneratePlots(False) + else: + # delete the currently selected slicer + self.onDelete() + # add the new slicer + slicer = self.callbacks[index] + if self.active_plots.keys(): + self.parent.setSlicer(slicer=slicer) + # Reset combo box back to "None" after setting slicer + self.cbSlicerReplace.blockSignals(True) + self.cbSlicerReplace.setCurrentIndex(0) + self.cbSlicerReplace.blockSignals(False) self.onParamChange() def onGeneratePlots(self, isChecked): @@ -289,6 +407,46 @@ def onApply(self): self.save1DPlotsForPlot(plots) pass # debug anchor + def onDelete(self): + """ + Delete the current slicer + """ + # Pop up a confirmation dialog + reply = QtWidgets.QMessageBox.question(self, 'Delete Slicer', + 'Are you sure you want to delete this slicer?', + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, + QtWidgets.QMessageBox.No) + if reply == QtWidgets.QMessageBox.No: + return + # Get the current slicer name + slicer_item = self.getCheckedSlicer() + # Remove the slicer from the dictionary + if slicer_item in self.parent.slicers: + slicer_obj = self.parent.slicers[slicer_item] + # Clear only this slicer's markers without affecting other slicers + # Use clear_markers() which uses connect.clear(*markers) instead of clearall() + self._clear_slicer_markers(slicer_obj) + # Remove from dictionary + del self.parent.slicers[slicer_item] + # Remove from slicer_models cache + if slicer_item in self.slicer_models: + del self.slicer_models[slicer_item] + # update the canvas + self.parent.canvas.draw() + # update the slicer list + self.updateSlicersList() + # Select the next remaining slicer if any exist + if len(self.parent.slicers) > 0: + # Get the first remaining slicer + next_slicer_name = next(iter(self.parent.slicers.keys())) + # Update self.parent.slicer to point to the remaining slicer + self.parent.slicer = self.parent.slicers[next_slicer_name] + self.checkSlicerByName(next_slicer_name) + else: + # No slicers left, clear the model and slicer reference + self.parent.slicer = None + self.setModel(None) + def applyPlotter(self, plot): """ Apply the current slicer to a plot @@ -437,6 +595,55 @@ def onHelp(self): url = "/user/qtgui/MainWindow/graph_help.html#d-data-averaging" self.manager.parent.showHelp(url) + def _clear_slicer_markers(self, slicer_obj): + """ + Clear the slicer by calling its clear() method. + The slicer's clear() method handles all cleanup for that specific slicer type. + """ + if hasattr(slicer_obj, 'clear'): + slicer_obj.clear() + + def updateSlicersList(self): + """ + Update the slicers list when slicers are added or removed + """ + self.setSlicersList() + + def checkSlicerByName(self, slicer_name): + """ + Check (select) a slicer radio button by name + """ + if not hasattr(self, 'slicerButtonGroup'): + return + + # Find and check the radio button with this slicer name + for button in self.slicerButtonGroup.buttons(): + if button.text() == slicer_name: + button.setChecked(True) + break + + def onSlicerRadioToggled(self, checked, slicer_name): + """ + Update parameter list when a slicer radio button is toggled + """ + if not checked: + return + + # Check if "None" was selected - don't clear everything, just update the view + if slicer_name not in self.parent.slicers: + # No valid slicer selected, but don't clear + return + + # Update the parameter model to show this slicer's parameters + if slicer_name in self.slicer_models: + self.setModel(self.slicer_models[slicer_name]) + elif slicer_name in self.parent.slicers: + # Get the model from the slicer object + slicer_obj = self.parent.slicers[slicer_name] + if hasattr(slicer_obj, '_model'): + if slicer_name not in self.slicer_models: + self.slicer_models[slicer_name] = slicer_obj._model + self.setModel(slicer_obj._model) class ProxyModel(QtCore.QIdentityProxyModel): """ diff --git a/src/sas/qtgui/Plotting/Slicers/AnnulusSlicer.py b/src/sas/qtgui/Plotting/Slicers/AnnulusSlicer.py index 470f94da43..7713738704 100644 --- a/src/sas/qtgui/Plotting/Slicers/AnnulusSlicer.py +++ b/src/sas/qtgui/Plotting/Slicers/AnnulusSlicer.py @@ -3,6 +3,7 @@ import sas.qtgui.Utilities.GuiUtils as GuiUtils from sas.qtgui.Plotting.PlotterData import Data1D, DataRole from sas.qtgui.Plotting.SlicerModel import SlicerModel +from sas.qtgui.Plotting.Slicers.SlicerUtils import generate_unique_plot_id from .BaseInteractor import BaseInteractor @@ -17,8 +18,8 @@ class AnnulusInteractor(BaseInteractor, SlicerModel): r1 and r2 (Q1 and Q2). All Q points at a constant angle phi from the x-axis are averaged together to provide a 1D array in phi from 0 to 180 degrees. """ - def __init__(self, base, axes, item=None, color='black', zorder=3): + def __init__(self, base, axes, item=None, color="black", zorder=3): BaseInteractor.__init__(self, base, axes, color=color) SlicerModel.__init__(self) @@ -26,13 +27,14 @@ def __init__(self, base, axes, item=None, color='black', zorder=3): self.axes = axes self.base = base self._item = item - self.qmax = max(numpy.fabs(self.data.xmax), - numpy.fabs(self.data.xmin)) # must be positive + self.qmax = max(numpy.fabs(self.data.xmax), numpy.fabs(self.data.xmin)) # must be positive self.dqmin = min(numpy.fabs(self.data.qx_data)) self.connect = self.base.connect # Number of points on the plot self.nbins = 100 + # Store the plot ID so it doesn't change when parameters are updated + self._plot_id = None # Cursor position of Rings (Left(-1) or Right(1)) self.xmaxd = self.data.xmax self.xmind = self.data.xmin @@ -42,13 +44,9 @@ def __init__(self, base, axes, item=None, color='black', zorder=3): else: self.sign = -1 # Inner circle - self.inner_circle = RingInteractor(self, self.axes, - zorder=zorder, - r=self.qmax / 2.0, sign=self.sign) + self.inner_circle = RingInteractor(self, self.axes, color=color, zorder=zorder, r=self.qmax / 2.0, sign=self.sign) self.inner_circle.qmax = self.qmax - self.outer_circle = RingInteractor(self, self.axes, - zorder=zorder + 1, r=self.qmax / 1.8, - sign=self.sign) + self.outer_circle = RingInteractor(self, self.axes, color=color, zorder=zorder + 1, r=self.qmax / 1.8, sign=self.sign) self.outer_circle.qmax = self.qmax * 1.2 self.update() self._post_data() @@ -103,10 +101,9 @@ def _post_data(self, nbins=None): return from sasdata.data_util.manipulations import Ring - rmin = min(numpy.fabs(self.inner_circle.get_radius()), - numpy.fabs(self.outer_circle.get_radius())) - rmax = max(numpy.fabs(self.inner_circle.get_radius()), - numpy.fabs(self.outer_circle.get_radius())) + + rmin = min(numpy.fabs(self.inner_circle.get_radius()), numpy.fabs(self.outer_circle.get_radius())) + rmax = max(numpy.fabs(self.inner_circle.get_radius()), numpy.fabs(self.outer_circle.get_radius())) if nbins is not None: self.nbins = nbins # Create the data1D Q average of data2D @@ -121,27 +118,29 @@ def _post_data(self, nbins=None): dxw = sector.dxw else: dxw = None - new_plot = Data1D(x=(sector.x - numpy.pi) * 180 / numpy.pi, - y=sector.y, dy=sector.dy) + new_plot = Data1D(x=(sector.x - numpy.pi) * 180 / numpy.pi, y=sector.y, dy=sector.dy) new_plot.dxl = dxl new_plot.dxw = dxw - new_plot.name = "AnnulusPhi" + "(" + self.data.name + ")" - new_plot.title = "AnnulusPhi" + "(" + self.data.name + ")" new_plot.source = self.data.source new_plot.interactive = True new_plot.detector = self.data.detector # If the data file does not tell us what the axes are, just assume... - new_plot.xaxis(r"\rm{\phi}", 'degrees') + new_plot.xaxis(r"\rm{\phi}", "degrees") new_plot.yaxis(r"\rm{Intensity} ", "cm^{-1}") new_plot.plot_role = DataRole.ROLE_ANGULAR_SLICE - if hasattr(data, "scale") and data.scale == 'linear' and \ - self.data.name.count("Residuals") > 0: - new_plot.ytransform = 'y' + if hasattr(data, "scale") and data.scale == "linear" and self.data.name.count("Residuals") > 0: + new_plot.ytransform = "y" new_plot.yaxis(r"\rm{Residuals} ", "/") - new_plot.id = "AnnulusPhi" + self.data.name - new_plot.type_id = "Slicer" + self.data.name # Used to remove plots after changing slicer so they don't keep showing up after closed + # Assign unique id per slicer instance and use it as the display name + if self._plot_id is None: + base_id = "AnnulusPhi" + self.data.name + self._plot_id = generate_unique_plot_id(base_id, self._item) + + new_plot.id = self._plot_id + new_plot.name = new_plot.id + new_plot.title = new_plot.id new_plot.is_data = True new_plot.xtransform = "x" new_plot.ytransform = "y" @@ -159,27 +158,27 @@ def validate(self, param_name, param_value): """ Test the proposed new value "value" for row "row" of parameters """ - #Set minimum difference in outer/inner ring to ensure data exists in annulus + # Set minimum difference in outer/inner ring to ensure data exists in annulus MIN_DIFFERENCE = self.dqmin isValid = True - if param_name == 'inner_radius': + if param_name == "inner_radius": # First, check the closeness - if numpy.fabs(param_value - self.getParams()['outer_radius']) < MIN_DIFFERENCE: + if numpy.fabs(param_value - self.getParams()["outer_radius"]) < MIN_DIFFERENCE: print("Inner and outer radii too close. Please adjust.") isValid = False elif param_value > self.qmax: print("Inner radius exceeds maximum range. Please adjust.") isValid = False - elif param_name == 'outer_radius': + elif param_name == "outer_radius": # First, check the closeness - if numpy.fabs(param_value - self.getParams()['inner_radius']) < MIN_DIFFERENCE: + if numpy.fabs(param_value - self.getParams()["inner_radius"]) < MIN_DIFFERENCE: print("Inner and outer radii too close. Please adjust.") isValid = False elif param_value > self.qmax: print("Outer radius exceeds maximum range. Please adjust.") isValid = False - elif param_name == 'nbins': + elif param_name == "nbins": # Can't be 0 if param_value < 1: print("Number of bins cannot be less than or equal to 0. Please adjust.") @@ -241,16 +240,16 @@ def setParams(self, params): self.draw() def draw(self): - """ - """ + """ """ self.base.draw() class RingInteractor(BaseInteractor): """ - Draw a ring on a data2D plot centered at (0,0) given a radius + Draw a ring on a data2D plot centered at (0,0) given a radius """ - def __init__(self, base, axes, color='black', zorder=5, r=1.0, sign=1): + + def __init__(self, base, axes, color="black", zorder=5, r=1.0, sign=1): """ :param: the color of the line that defined the ring :param r: the radius of the ring @@ -275,14 +274,21 @@ def __init__(self, base, axes, color='black', zorder=5, r=1.0, sign=1): # # Create a marker # Inner circle marker x_value = [self.sign * numpy.fabs(self._inner_mouse_x)] - self.inner_marker = self.axes.plot(x_value, [0], linestyle='', - marker='s', markersize=10, - color=self.color, alpha=0.6, - pickradius=5, label="pick", - zorder=zorder, - visible=True)[0] + self.inner_marker = self.axes.plot( + x_value, + [0], + linestyle="", + marker="s", + markersize=10, + color=self.color, + alpha=0.6, + pickradius=5, + label="pick", + zorder=zorder, + visible=True, + )[0] # Draw a circle - [self.inner_circle] = self.axes.plot([], [], linestyle='-', marker='', color=self.color) + [self.inner_circle] = self.axes.plot([], [], linestyle="-", marker="", color=self.color) # The number of points that make the ring line self.npts = 40 @@ -304,8 +310,10 @@ def clear(self): Clear the slicer and all connected events related to this slicer """ self.clear_markers() - self.inner_marker.remove() - self.inner_circle.remove() + if self.inner_marker.axes is not None: + self.inner_marker.remove() + if self.inner_circle.axes is not None: + self.inner_circle.remove() def get_radius(self): """ @@ -329,8 +337,7 @@ def update(self): x.append(xval) y.append(yval) - self.inner_marker.set(xdata=[self.sign * numpy.fabs(self._inner_mouse_x)], - ydata=[0]) + self.inner_marker.set(xdata=[self.sign * numpy.fabs(self._inner_mouse_x)], ydata=[0]) self.inner_circle.set_data(x, y) def save(self, ev): @@ -390,5 +397,3 @@ def setParams(self, params): """ x = params["radius"] self.set_cursor(x, self._inner_mouse_y) - - diff --git a/src/sas/qtgui/Plotting/Slicers/ArcInteractor.py b/src/sas/qtgui/Plotting/Slicers/ArcInteractor.py index 6a4106a03a..51e47cf835 100644 --- a/src/sas/qtgui/Plotting/Slicers/ArcInteractor.py +++ b/src/sas/qtgui/Plotting/Slicers/ArcInteractor.py @@ -12,8 +12,8 @@ class ArcInteractor(BaseInteractor): param theta: angle from x-axis of the central point on the arc param phi: angle from the centre point on the arc to each of its edges """ - def __init__(self, base, axes, color='black', zorder=5, r=1.0, - theta=np.pi / 3, phi=np.pi / 8): + + def __init__(self, base, axes, color="black", zorder=5, r=1.0, theta=np.pi / 3, phi=np.pi / 8): BaseInteractor.__init__(self, base, axes, color=color) self.markers = [] self.axes = axes @@ -30,13 +30,21 @@ def __init__(self, base, axes, color='black', zorder=5, r=1.0, self.phi = phi self.radius = r # Calculate the marker coordinates and define the marker - self.marker = self.axes.plot([], [], linestyle='', - marker='s', markersize=10, - color=self.color, alpha=0.6, pickradius=5, - label='pick', zorder=zorder, - visible=True)[0] + self.marker = self.axes.plot( + [], + [], + linestyle="", + marker="s", + markersize=10, + color=self.color, + alpha=0.6, + pickradius=5, + label="pick", + zorder=zorder, + visible=True, + )[0] # Define the arc - self.arc = self.axes.plot([], [], linestyle='-', marker='', color=self.color)[0] + self.arc = self.axes.plot([], [], linestyle="-", marker="", color=self.color)[0] # The number of points that make the arc line self.npts = 40 # Flag to keep track of motion @@ -57,8 +65,10 @@ def clear(self): Clear this slicer and its markers """ self.clear_markers() - self.marker.remove() - self.arc.remove() + if self.marker.axes is not None: + self.marker.remove() + if self.arc.axes is not None: + self.arc.remove() def update(self, theta=None, phi=None, r=None): """ @@ -114,8 +124,7 @@ def move(self, x, y, ev): """ self._mouse_x = x self._mouse_y = y - self.radius = np.sqrt(np.power(self._mouse_x, 2) + \ - np.power(self._mouse_y, 2)) + self.radius = np.sqrt(np.power(self._mouse_x, 2) + np.power(self._mouse_y, 2)) self.has_move = True self.base.update() self.base.draw() diff --git a/src/sas/qtgui/Plotting/Slicers/BaseInteractor.py b/src/sas/qtgui/Plotting/Slicers/BaseInteractor.py index 3f9f69b64c..52a21937aa 100755 --- a/src/sas/qtgui/Plotting/Slicers/BaseInteractor.py +++ b/src/sas/qtgui/Plotting/Slicers/BaseInteractor.py @@ -1,13 +1,17 @@ - -interface_color = 'black' -disable_color = 'gray' -active_color = 'red' -rho_color = 'black' -mu_color = 'green' -P_color = 'blue' -theta_color = 'orange' +import logging + +interface_color = "black" +disable_color = "gray" +active_color = "red" +rho_color = "black" +mu_color = "green" +P_color = "blue" +theta_color = "orange" profile_colors = [rho_color, mu_color, P_color, theta_color] +logger = logging.getLogger(__name__) + + class BaseInteractor: """ Share some functions between the interface interactor and various layer @@ -40,9 +44,9 @@ class BaseInteractor: markers - list of handles for the interactor """ - def __init__(self, base, axes, color='black'): - """ - """ + + def __init__(self, base, axes, color="black"): + """ """ self.base = base self.axes = axes self.color = color @@ -60,44 +64,43 @@ def clear_markers(self): Clear old markers and interfaces. """ for h in self.markers: - h.remove() + try: + h.remove() + except (ValueError, AttributeError): + logger.warning("Failed to remove marker: %s", h) if self.markers: self.base.connect.clear(*self.markers) self.markers = [] def save(self, ev): - """ - """ + """ """ pass def restore(self, ev): - """ - """ + """ """ pass def move(self, x, y, ev): - """ - """ + """ """ pass def moveend(self, ev): - """ - """ + """ """ pass def connect_markers(self, markers): """ Connect markers to callbacks """ - for h in markers: connect = self.base.connect - connect('enter', h, self.onHilite) - connect('leave', h, self.onLeave) - connect('click', h, self.onClick) - connect('release', h, self.onRelease) - connect('drag', h, self.onDrag) - connect('key', h, self.onKey) + connect("enter", h, self.onHilite) + connect("leave", h, self.onLeave) + connect("click", h, self.onClick) + connect("release", h, self.onRelease) + connect("drag", h, self.onDrag) + connect("key", h, self.onKey) + self.markers.extend(markers) def onHilite(self, ev): """ @@ -119,16 +122,29 @@ def onLeave(self, ev): def onClick(self, ev): """ Prepare to move the artist. Calls save() to preserve the state for - later restore(). + later restore(). Also notify plotter of slicer interaction. """ self.clickx, self.clicky = ev.xdata, ev.ydata self.save(ev) + try: + # Case 1: this interactor is the slicer (base is the plotter) + if hasattr(self.base, 'notifySlicerModified'): + self.base.notifySlicerModified(self) + # Case 2: this interactor is a child of the slicer (base is the slicer) + elif hasattr(self.base, 'base') and hasattr(self.base.base, 'notifySlicerModified'): + self.base.base.notifySlicerModified(self.base) + except (ValueError, AttributeError): + pass return True def onRelease(self, ev): - """ - """ + """Notify plotter on end of interaction.""" self.moveend(ev) + try: + if hasattr(self.base, 'notifySlicerModified'): + self.base.notifySlicerModified(self) + except (ValueError, AttributeError): + logger.warning("Failed to notify slicer modified: %s", self.base) return True def onDrag(self, ev): @@ -149,15 +165,15 @@ def onKey(self, ev): Calls move() to update the state. Calls restore() on escape. """ - if ev.key == 'escape': + if ev.key == "escape": self.restore(ev) - elif ev.key in ['up', 'down', 'right', 'left']: + elif ev.key in ["up", "down", "right", "left"]: dx, dy = self.dpixel(self.clickx, self.clicky, nudge=ev.control) - if ev.key == 'up': + if ev.key == "up": self.clicky += dy - elif ev.key == 'down': + elif ev.key == "down": self.clicky -= dy - elif ev.key == 'right': + elif ev.key == "right": self.clickx += dx else: self.clickx -= dx @@ -182,4 +198,3 @@ def dpixel(self, x, y, nudge=False): nx, ny = ax.transData.xy_tup((px + 1.0, py + 1.0)) dx, dy = nx - x, ny - y return dx, dy - diff --git a/src/sas/qtgui/Plotting/Slicers/BoxSlicer.py b/src/sas/qtgui/Plotting/Slicers/BoxSlicer.py index 59a8f60e07..cf8fdc2f80 100644 --- a/src/sas/qtgui/Plotting/Slicers/BoxSlicer.py +++ b/src/sas/qtgui/Plotting/Slicers/BoxSlicer.py @@ -6,6 +6,7 @@ from sas.qtgui.Plotting.PlotterData import Data1D from sas.qtgui.Plotting.SlicerModel import SlicerModel from sas.qtgui.Plotting.Slicers.BaseInteractor import BaseInteractor +from sas.qtgui.Plotting.Slicers.SlicerUtils import generate_unique_plot_id logger = logging.getLogger(__name__) @@ -24,7 +25,7 @@ class BoxInteractor(BaseInteractor, SlicerModel): x1 to x2 as a function of Q_y """ - def __init__(self, base, axes, item=None, color='black', zorder=3, direction=None): + def __init__(self, base, axes, item=None, color="black", zorder=3, direction=None): BaseInteractor.__init__(self, base, axes, color=color) SlicerModel.__init__(self) # Class initialization @@ -48,8 +49,8 @@ def __init__(self, base, axes, item=None, color='black', zorder=3, direction=Non # center of the box # puts the center of box at the middle of the data q-range - self.center_x = (self.data.xmin + self.data.xmax) /2 - self.center_y = (self.data.ymin + self.data.ymax) /2 + self.center_x = (self.data.xmin + self.data.xmax) / 2 + self.center_y = (self.data.ymin + self.data.ymax) / 2 # Number of points on the plot self.nbins = 100 @@ -66,31 +67,35 @@ def __init__(self, base, axes, item=None, color='black', zorder=3, direction=Non # currently does a pass. Default to False at initialize anyway # (nothing has moved yet) for possible future implementation. self.has_move = False + # Store the plot ID so it doesn't change when parameters are updated + self._plot_id = None # Create vertical and horizontal lines for the rectangle - self.horizontal_lines = HorizontalDoubleLine(self, - self.axes, - color='blue', - zorder=zorder, - half_height=self.half_height, - half_width=self.half_width, - center_x=self.center_x, - center_y=self.center_y) - - self.vertical_lines = VerticalDoubleLine(self, - self.axes, - color='black', - zorder=zorder, - half_height=self.half_height, - half_width=self.half_width, - center_x=self.center_x, - center_y=self.center_y) + self.horizontal_lines = HorizontalDoubleLine( + self, + self.axes, + color=color, + zorder=zorder, + half_height=self.half_height, + half_width=self.half_width, + center_x=self.center_x, + center_y=self.center_y, + ) + + self.vertical_lines = VerticalDoubleLine( + self, + self.axes, + color=color, + zorder=zorder, + half_height=self.half_height, + half_width=self.half_width, + center_x=self.center_x, + center_y=self.center_y, + ) # PointInteractor determines the center of the box - self.center = PointInteractor(self, - self.axes, color='grey', - zorder=zorder, - center_x=self.center_x, - center_y=self.center_y) + self.center = PointInteractor( + self, self.axes, color=color, zorder=zorder, center_x=self.center_x, center_y=self.center_y + ) # draw the rectangle and plot the data 1D resulting # from averaging of the data2D @@ -121,13 +126,14 @@ def clear(self): """ Clear the slicer and all connected events related to this slicer """ - self.averager = None self.clear_markers() - self.horizontal_lines.clear() - self.vertical_lines.clear() - self.base.connect.clearall() - self.center.clear() - + if self.center.axes is not None: + self.center.clear() + if self.horizontal_lines.axes is not None: + self.horizontal_lines.clear() + if self.vertical_lines.axes is not None: + self.vertical_lines.clear() + self.averager = None def update(self): """ @@ -144,16 +150,16 @@ def update(self): # update the figure accordingly if self.horizontal_lines.has_move: self.horizontal_lines.update() - self.vertical_lines.update(y1=self.horizontal_lines.y1, - y2=self.horizontal_lines.y2, - half_height=self.horizontal_lines.half_height) + self.vertical_lines.update( + y1=self.horizontal_lines.y1, y2=self.horizontal_lines.y2, half_height=self.horizontal_lines.half_height + ) # check if the vertical lines have moved and # update the figure accordingly if self.vertical_lines.has_move: self.vertical_lines.update() - self.horizontal_lines.update(x1=self.vertical_lines.x1, - x2=self.vertical_lines.x2, - half_width=self.vertical_lines.half_width) + self.horizontal_lines.update( + x1=self.vertical_lines.x1, x2=self.vertical_lines.x2, half_width=self.vertical_lines.half_width + ) def save(self, ev): """ @@ -202,7 +208,7 @@ def _post_data(self, new_slab=None, nbins=None, direction=None): if self.direction == "X": if self.fold and (x_max * x_min <= 0): x_low = 0 - x_high = max(abs(x_min),abs(x_max)) + x_high = max(abs(x_min), abs(x_max)) else: x_low = x_min x_high = x_max @@ -210,7 +216,7 @@ def _post_data(self, new_slab=None, nbins=None, direction=None): elif self.direction == "Y": if self.fold and (y_max * y_min >= 0): y_low = 0 - y_high = max(abs(y_min),abs(y_max)) + y_high = max(abs(y_min), abs(y_max)) else: y_low = y_min y_high = y_max @@ -220,8 +226,7 @@ def _post_data(self, new_slab=None, nbins=None, direction=None): raise ValueError(msg) # Average data2D given Qx or Qy - box = self.averager(x_min=x_min, x_max=x_max, y_min=y_min, y_max=y_max, - bin_width=bin_width) + box = self.averager(x_min=x_min, x_max=x_max, y_min=y_min, y_max=y_max, bin_width=bin_width) box.fold = self.fold # Check for data inside ROI. A bit of a kludge but faster than # checking twice: once to check and once to do the calculation @@ -242,8 +247,8 @@ def _post_data(self, new_slab=None, nbins=None, direction=None): return # Now that we know the move valid, update the half_width and half_height - self.half_width = numpy.fabs(x_max - x_min)/2 - self.half_height = numpy.fabs(y_max - y_min)/2 + self.half_width = numpy.fabs(x_max - x_min) / 2 + self.half_height = numpy.fabs(y_max - y_min) / 2 # Create Data1D to plot if hasattr(boxavg, "dxl"): @@ -257,10 +262,15 @@ def _post_data(self, new_slab=None, nbins=None, direction=None): new_plot = Data1D(x=boxavg.x, y=boxavg.y, dy=boxavg.dy) new_plot.dxl = dxl new_plot.dxw = dxw - new_plot.name = str(self.averager.__name__) + \ - "(" + self.data.name + ")" - new_plot.title = str(self.averager.__name__) + \ - "(" + self.data.name + ")" + + # Assign unique id per slicer instance and use it as the display name + if self._plot_id is None: + base_id = "BoxAverage" + self.data.name + self._plot_id = generate_unique_plot_id(base_id, self._item) + + new_plot.id = self._plot_id + new_plot.name = self._plot_id + new_plot.title = self._plot_id new_plot.source = self.data.source new_plot.interactive = True new_plot.detector = self.data.detector @@ -273,12 +283,10 @@ def _post_data(self, new_slab=None, nbins=None, direction=None): new_plot.yaxis("\\rm{Intensity} ", "cm^{-1}") data = self.data - if hasattr(data, "scale") and data.scale == 'linear' and \ - self.data.name.count("Residuals") > 0: - new_plot.ytransform = 'y' + if hasattr(data, "scale") and data.scale == "linear" and self.data.name.count("Residuals") > 0: + new_plot.ytransform = "y" new_plot.yaxis("\\rm{Residuals} ", "/") - new_plot.id = (self.averager.__name__) + self.data.name # Create id to remove plots after changing slicer so they don't keep # showing up after being closed new_plot.type_id = "Slicer" + self.data.name @@ -369,10 +377,8 @@ def setParams(self, params): self.vertical_lines.has_move = True # Now update the ROI based on the change self.center.update(center_x=self.center_x, center_y=self.center_y) - self.horizontal_lines.update(center=self.center, - half_width=self.half_width, half_height=self.half_height) - self.vertical_lines.update(center=self.center, - half_width=self.half_width, half_height=self.half_height) + self.horizontal_lines.update(center=self.center, half_width=self.half_width, half_height=self.half_height) + self.vertical_lines.update(center=self.center, half_width=self.half_width, half_height=self.half_height) # Compute and plot the 1D average based on these parameters self._post_data() # Now move is over so turn off flags @@ -400,35 +406,33 @@ def validate(self, param_name, param_value): """ isValid = True - if param_name =='half_width': + if param_name == "half_width": # Can't be negative for sure. Also, it should not be so small that # there remains no points to average in the ROI. We leave this # second check to manipulations.py if param_value <= 0: logger.warning("The box width is too small. Please adjust.") isValid = False - elif param_name =='half_height': + elif param_name == "half_height": # Can't be negative for sure. Also, it should not be so small that # there remains no points to average in the ROI. We leave this # second check to manipulations.py if param_value <= 0: logger.warning("The box height is too small. Please adjust.") isValid = False - elif param_name == 'nbins': + elif param_name == "nbins": # Can't be negative or 0 if param_value < 1: logger.warning("Number of bins cannot be less than or equal to 0. Please adjust.") isValid = False - elif param_name == 'center_x': + elif param_name == "center_x": # Keep the full ROI box within the data (only moving x here) - if (param_value + self.half_width) >= self.data.xmax or \ - (param_value- self.half_width) <= self.data.xmin: + if (param_value + self.half_width) >= self.data.xmax or (param_value - self.half_width) <= self.data.xmin: logger.warning("The ROI must be fully contained within the 2D data. Please adjust") isValid = False - elif param_name == 'center_y': + elif param_name == "center_y": # Keep the full ROI box within the data (only moving y here) - if (param_value + self.half_height) >= self.data.ymax or \ - (param_value - self.half_height) <= self.data.ymin: + if (param_value + self.half_height) >= self.data.ymax or (param_value - self.half_height) <= self.data.ymin: logger.warning("The ROI must be fully contained within the 2D data. Please adjust") isValid = False return isValid @@ -441,14 +445,13 @@ def draw(self): self.base.draw() - class PointInteractor(BaseInteractor): """ Draw a point that can be dragged with the marker. this class controls the motion the center of the BoxSum """ - def __init__(self, base, axes, color='black', zorder=5, center_x=0.0, - center_y=0.0): + + def __init__(self, base, axes, color="black", zorder=5, center_x=0.0, center_y=0.0): BaseInteractor.__init__(self, base, axes, color=color) # Initialization the class self.markers = [] @@ -460,17 +463,21 @@ def __init__(self, base, axes, color='black', zorder=5, center_x=0.0, self.save_x = center_x self.save_y = center_y # Create a marker - self.center_marker = self.axes.plot([self.x], [self.y], linestyle='', - marker='s', markersize=10, - color=self.color, alpha=0.6, - pickradius=5, label="pick", - zorder=zorder, - visible=True)[0] + self.center_marker = self.axes.plot( + [self.x], + [self.y], + linestyle="", + marker="s", + markersize=10, + color=self.color, + alpha=0.6, + pickradius=5, + label="pick", + zorder=zorder, + visible=True, + )[0] # Draw a point - self.center = self.axes.plot([self.x], [self.y], - linestyle='-', marker='', - color=self.color, - visible=True)[0] + self.center = self.axes.plot([self.x], [self.y], linestyle="-", marker="", color=self.color, visible=True)[0] # Flag to determine if this point has moved self.has_move = False # Flag to verify if the last move was valid @@ -493,8 +500,8 @@ def clear(self): Clear this figure and its markers """ self.clear_markers() - self.center.remove() - self.center_marker.remove() + if self.center.axes is not None: + self.center.remove() def update(self, center_x=None, center_y=None): """ @@ -516,8 +523,7 @@ def save(self, ev): self.save_y = self.y def moveend(self, ev): - """ - """ + """ """ self.has_move = False self.base.moveend(ev) @@ -558,13 +564,16 @@ def setCursor(self, x, y): self.move(x, y, None) self.update() + class VerticalDoubleLine(BaseInteractor): """ Draw 2 vertical lines that can move symmetrically in opposite directions in x and centered on a point (PointInteractor). It also defines the top and bottom y positions of a box. """ - def __init__(self, base, axes, color='black', zorder=5, half_width=0.5, half_height=0.5, - center_x=0.0, center_y=0.0): + + def __init__( + self, base, axes, color="black", zorder=5, half_width=0.5, half_height=0.5, center_x=0.0, center_y=0.0 + ): BaseInteractor.__init__(self, base, axes, color=color) # Initialization of the class self.markers = [] @@ -590,19 +599,27 @@ def __init__(self, base, axes, color='black', zorder=5, half_width=0.5, half_hei # save the color of the line self.color = color # Create marker - self.right_marker = self.axes.plot([self.x1], [0], linestyle='', - marker='s', markersize=10, - color=self.color, alpha=0.6, - pickradius=5, label="pick", - zorder=zorder, visible=True)[0] + self.right_marker = self.axes.plot( + [self.x1], + [0], + linestyle="", + marker="s", + markersize=10, + color=self.color, + alpha=0.6, + pickradius=5, + label="pick", + zorder=zorder, + visible=True, + )[0] # Define the left and right lines of the rectangle - self.right_line = self.axes.plot([self.x1, self.x1], [self.y1, self.y2], - linestyle='-', marker='', - color=self.color, visible=True)[0] - self.left_line = self.axes.plot([self.x2, self.x2], [self.y1, self.y2], - linestyle='-', marker='', - color=self.color, visible=True)[0] + self.right_line = self.axes.plot( + [self.x1, self.x1], [self.y1, self.y2], linestyle="-", marker="", color=self.color, visible=True + )[0] + self.left_line = self.axes.plot( + [self.x2, self.x2], [self.y1, self.y2], linestyle="-", marker="", color=self.color, visible=True + )[0] # Flag to determine if the lines have moved self.has_move = False # Flag to verify if the last move was valid @@ -624,12 +641,12 @@ def clear(self): Clear this slicer and its markers """ self.clear_markers() - self.right_marker.remove() - self.right_line.remove() - self.left_line.remove() + if self.right_line.axes is not None: + self.right_line.remove() + if self.left_line.axes is not None: + self.left_line.remove() - def update(self, x1=None, x2=None, y1=None, y2=None, half_width=None, - half_height=None, center=None): + def update(self, x1=None, x2=None, y1=None, y2=None, half_width=None, half_height=None, center=None): """ Draw the new roughness on the graph. :param x1: new maximum value of x coordinates @@ -656,10 +673,8 @@ def update(self, x1=None, x2=None, y1=None, y2=None, half_width=None, self.y2 = self.center_y - self.half_height self.right_marker.set(xdata=[self.x1], ydata=[self.center_y]) - self.right_line.set(xdata=[self.x1, self.x1], - ydata=[self.y1, self.y2]) - self.left_line.set(xdata=[self.x2, self.x2], - ydata=[self.y1, self.y2]) + self.right_line.set(xdata=[self.x1, self.x1], ydata=[self.y1, self.y2]) + self.left_line.set(xdata=[self.x2, self.x2], ydata=[self.y1, self.y2]) return # if x1, y1, x2, y2 are given draw the rectangle with these values if x1 is not None: @@ -740,14 +755,16 @@ def setCursor(self, x, y): self.move(x, y, None) self.update() + class HorizontalDoubleLine(BaseInteractor): """ Draw 2 vertical lines that can move symmetrically in opposite directions in y and centered on a point (PointInteractor). It also defines the left and right x positions of a box. """ - def __init__(self, base, axes, color='black', zorder=5, half_width=0.5, half_height=0.5, - center_x=0.0, center_y=0.0): + def __init__( + self, base, axes, color="black", zorder=5, half_width=0.5, half_height=0.5, center_x=0.0, center_y=0.0 + ): BaseInteractor.__init__(self, base, axes, color=color) # Initialization of the class self.markers = [] @@ -770,20 +787,27 @@ def __init__(self, base, axes, color='black', zorder=5, half_width=0.5, half_hei self.save_x2 = self.x2 # Color self.color = color - self.top_marker = self.axes.plot([0], [self.y1], linestyle='', - marker='s', markersize=10, - color=self.color, alpha=0.6, - pickradius=5, label="pick", - zorder=zorder, visible=True)[0] + self.top_marker = self.axes.plot( + [0], + [self.y1], + linestyle="", + marker="s", + markersize=10, + color=self.color, + alpha=0.6, + pickradius=5, + label="pick", + zorder=zorder, + visible=True, + )[0] # Define 2 horizontal lines - self.top_line = self.axes.plot([self.x1, -self.x1], [self.y1, self.y1], - linestyle='-', marker='', - color=self.color, visible=True)[0] - self.bottom_line = self.axes.plot([self.x1, -self.x1], - [self.y2, self.y2], - linestyle='-', marker='', - color=self.color, visible=True)[0] + self.top_line = self.axes.plot( + [self.x1, -self.x1], [self.y1, self.y1], linestyle="-", marker="", color=self.color, visible=True + )[0] + self.bottom_line = self.axes.plot( + [self.x1, -self.x1], [self.y2, self.y2], linestyle="-", marker="", color=self.color, visible=True + )[0] # Flag to determine if the lines have moved self.has_move = False # Flag to verify if the last move was valid @@ -805,12 +829,12 @@ def clear(self): Clear this figure and its markers """ self.clear_markers() - self.top_marker.remove() - self.bottom_line.remove() - self.top_line.remove() + if self.top_line.axes is not None: + self.top_line.remove() + if self.bottom_line.axes is not None: + self.bottom_line.remove() - def update(self, x1=None, x2=None, y1=None, y2=None, - half_width=None, half_height=None, center=None): + def update(self, x1=None, x2=None, y1=None, y2=None, half_width=None, half_height=None, center=None): """ Draw the new roughness on the graph. :param x1: new maximum value of x coordinates @@ -838,10 +862,8 @@ def update(self, x1=None, x2=None, y1=None, y2=None, self.y2 = self.center_y - self.half_height self.top_marker.set(xdata=[self.center_x], ydata=[self.y1]) - self.top_line.set(xdata=[self.x1, self.x2], - ydata=[self.y1, self.y1]) - self.bottom_line.set(xdata=[self.x1, self.x2], - ydata=[self.y2, self.y2]) + self.top_line.set(xdata=[self.x1, self.x2], ydata=[self.y1, self.y1]) + self.bottom_line.set(xdata=[self.x1, self.x2], ydata=[self.y2, self.y2]) return # if x1, y1, x2, y2 are given draw the rectangle with these values if x1 is not None: @@ -923,7 +945,6 @@ def setCursor(self, x, y): self.update() - class BoxInteractorX(BoxInteractor): """ Average in Qx direction. The data for all Qy at a constant Qx are @@ -931,7 +952,7 @@ class BoxInteractorX(BoxInteractor): of Qx) """ - def __init__(self, base, axes, item=None, color='black', zorder=3): + def __init__(self, base, axes, item=None, color="black", zorder=3): BoxInteractor.__init__(self, base, axes, item=item, color=color, direction="X") self.base = base @@ -940,6 +961,7 @@ def _post_data(self, new_slab=None, nbins=None, direction=None): Post data creating by averaging in Qx direction """ from sasdata.data_util.manipulations import SlabX + super()._post_data(SlabX, direction="X") @@ -950,7 +972,7 @@ class BoxInteractorY(BoxInteractor): of Qy) """ - def __init__(self, base, axes, item=None, color='black', zorder=3): + def __init__(self, base, axes, item=None, color="black", zorder=3): BoxInteractor.__init__(self, base, axes, item=item, color=color, direction="Y") self.base = base @@ -959,4 +981,5 @@ def _post_data(self, new_slab=None, nbins=None, direction=None): Post data creating by averaging in Qy direction """ from sasdata.data_util.manipulations import SlabY + super()._post_data(SlabY, direction="Y") diff --git a/src/sas/qtgui/Plotting/Slicers/BoxSum.py b/src/sas/qtgui/Plotting/Slicers/BoxSum.py index d3e4f6902f..ed47933b54 100644 --- a/src/sas/qtgui/Plotting/Slicers/BoxSum.py +++ b/src/sas/qtgui/Plotting/Slicers/BoxSum.py @@ -1,3 +1,5 @@ +import logging + import numpy from PySide6 import QtGui @@ -6,6 +8,7 @@ from sas.qtgui.Plotting.Slicers.BaseInteractor import BaseInteractor from sas.qtgui.Utilities.GuiUtils import formatNumber, toDouble +logger = logging.getLogger(__name__) class BoxSumCalculator(BaseInteractor): """ @@ -27,7 +30,8 @@ class BoxSumCalculator(BaseInteractor): @param y_max: the maximum value of the y coordinate """ - def __init__(self, base, axes, color='black', zorder=3): + + def __init__(self, base, axes, color="black", zorder=3): BaseInteractor.__init__(self, base, axes, color=color) # list of Boxsmun markers @@ -37,19 +41,17 @@ def __init__(self, base, axes, color='black', zorder=3): self.update_model = False # connect the artist for the motion self.connect = self.base.connect + # Reference to the widget (if any) + self.widget = None # when qmax is reached the selected line is reset the its previous value self.qmax = min(self.data.xmax, self.data.xmin) # Define the boxsum limits - self.xmin = -1 * 0.5 * min(numpy.fabs(self.data.xmax), - numpy.fabs(self.data.xmin)) - self.ymin = -1 * 0.5 * min(numpy.fabs(self.data.xmax), - numpy.fabs(self.data.xmin)) - self.xmax = 0.5 * min(numpy.fabs(self.data.xmax), - numpy.fabs(self.data.xmin)) - self.ymax = 0.5 * min(numpy.fabs(self.data.xmax), - numpy.fabs(self.data.xmin)) + self.xmin = -1 * 0.5 * min(numpy.fabs(self.data.xmax), numpy.fabs(self.data.xmin)) + self.ymin = -1 * 0.5 * min(numpy.fabs(self.data.xmax), numpy.fabs(self.data.xmin)) + self.xmax = 0.5 * min(numpy.fabs(self.data.xmax), numpy.fabs(self.data.xmin)) + self.ymax = 0.5 * min(numpy.fabs(self.data.xmax), numpy.fabs(self.data.xmin)) # center of the boxSum self.center_x = 0.0002 self.center_y = 0.0003 @@ -65,31 +67,33 @@ def __init__(self, base, axes, color='black', zorder=3): # set to False == no motion , set to True== motion self.has_move = False # Create Boxsum edges - self.horizontal_lines = HorizontalDoubleLine(self, - self.axes, - color='blue', - zorder=zorder, - y=self.ymax, - x=self.xmax, - center_x=self.center_x, - center_y=self.center_y) + self.horizontal_lines = HorizontalDoubleLine( + self, + self.axes, + color=color, + zorder=zorder, + y=self.ymax, + x=self.xmax, + center_x=self.center_x, + center_y=self.center_y, + ) self.horizontal_lines.qmax = self.qmax - self.vertical_lines = VerticalDoubleLine(self, - self.axes, - color='black', - zorder=zorder, - y=self.ymax, - x=self.xmax, - center_x=self.center_x, - center_y=self.center_y) + self.vertical_lines = VerticalDoubleLine( + self, + self.axes, + color=color, + zorder=zorder, + y=self.ymax, + x=self.xmax, + center_x=self.center_x, + center_y=self.center_y, + ) self.vertical_lines.qmax = self.qmax - self.center = PointInteractor(self, - self.axes, color='grey', - zorder=zorder, - center_x=self.center_x, - center_y=self.center_y) + self.center = PointInteractor( + self, self.axes, color=color, zorder=zorder, center_x=self.center_x, center_y=self.center_y + ) # Save the name of the slicer panel associate with this slicer self.panel_name = "" # Update and post slicer parameters @@ -115,15 +119,15 @@ def setModelFromParams(self): """ parameters = self.getParams() # Crete/overwrite model items - self._model.setData(self._model.index(0, 0), formatNumber(parameters['Height'])) - self._model.setData(self._model.index(0, 1), formatNumber(parameters['Width'])) - self._model.setData(self._model.index(0, 2), formatNumber(parameters['center_x'])) - self._model.setData(self._model.index(0, 3), formatNumber(parameters['center_y'])) + self._model.setData(self._model.index(0, 0), formatNumber(parameters["Height"])) + self._model.setData(self._model.index(0, 1), formatNumber(parameters["Width"])) + self._model.setData(self._model.index(0, 2), formatNumber(parameters["center_x"])) + self._model.setData(self._model.index(0, 3), formatNumber(parameters["center_y"])) self.setReadOnlyParametersFromModel() def model(self): - ''' model accessor ''' + """model accessor""" return self._model def setReadOnlyParametersFromModel(self): @@ -131,11 +135,11 @@ def setReadOnlyParametersFromModel(self): Cast model content onto "read-only" subset of parameters """ parameters = self.getParams() - self._model.setData(self._model.index(0, 4), formatNumber(parameters['avg'])) - self._model.setData(self._model.index(0, 5), formatNumber(parameters['avg_error'])) - self._model.setData(self._model.index(0, 6), formatNumber(parameters['sum'])) - self._model.setData(self._model.index(0, 7), formatNumber(parameters['sum_error'])) - self._model.setData(self._model.index(0, 8), formatNumber(parameters['num_points'])) + self._model.setData(self._model.index(0, 4), formatNumber(parameters["avg"])) + self._model.setData(self._model.index(0, 5), formatNumber(parameters["avg_error"])) + self._model.setData(self._model.index(0, 6), formatNumber(parameters["sum"])) + self._model.setData(self._model.index(0, 7), formatNumber(parameters["sum_error"])) + self._model.setData(self._model.index(0, 8), formatNumber(parameters["num_points"])) def setParamsFromModel(self): """ @@ -174,7 +178,10 @@ def clear(self): self.horizontal_lines.clear() self.vertical_lines.clear() self.center.clear() - self.base.connect.clearall() + # Close the associated widget if it exists + if self.widget is not None: + self.widget.closeWidgetSignal.emit() + self.widget = None def update(self): """ @@ -190,16 +197,16 @@ def update(self): # update the figure accordingly if self.horizontal_lines.has_move: self.horizontal_lines.update() - self.vertical_lines.update(y1=self.horizontal_lines.y1, - y2=self.horizontal_lines.y2, - height=self.horizontal_lines.half_height) + self.vertical_lines.update( + y1=self.horizontal_lines.y1, y2=self.horizontal_lines.y2, height=self.horizontal_lines.half_height + ) # check if the vertical lines have moved and # update the figure accordingly if self.vertical_lines.has_move: self.vertical_lines.update() - self.horizontal_lines.update(x1=self.vertical_lines.x1, - x2=self.vertical_lines.x2, - width=self.vertical_lines.half_width) + self.horizontal_lines.update( + x1=self.vertical_lines.x1, x2=self.vertical_lines.x2, width=self.vertical_lines.half_width + ) def save(self, ev): """ @@ -220,7 +227,7 @@ def postData(self): x_max = self.horizontal_lines.x1 y_min = self.vertical_lines.y2 y_max = self.vertical_lines.y1 - #computation of the sum and its error + # computation of the sum and its error box = Boxavg(x_min=x_min, x_max=x_max, y_min=y_min, y_max=y_max) self.count, self.error = box(self.data) # Dig out number of points summed, SMK & PDB, 04/03/2013 @@ -288,26 +295,23 @@ def setParams(self, params): self.center_y = params["center_y"] # update the slicer given values of params self.center.update(center_x=self.center_x, center_y=self.center_y) - self.horizontal_lines.update(center=self.center, - width=x_max, height=y_max) - self.vertical_lines.update(center=self.center, - width=x_max, height=y_max) + self.horizontal_lines.update(center=self.center, width=x_max, height=y_max) + self.vertical_lines.update(center=self.center, width=x_max, height=y_max) # compute the new error and sum given values of params self.postData() def draw(self): - """ Redraw canvas""" + """Redraw canvas""" self.base.draw() - class PointInteractor(BaseInteractor): """ Draw a point that can be dragged with the marker. this class controls the motion the center of the BoxSum """ - def __init__(self, base, axes, color='black', zorder=5, center_x=0.0, - center_y=0.0): + + def __init__(self, base, axes, color="black", zorder=5, center_x=0.0, center_y=0.0): BaseInteractor.__init__(self, base, axes, color=color) # Initialization the class self.markers = [] @@ -319,17 +323,21 @@ def __init__(self, base, axes, color='black', zorder=5, center_x=0.0, self.save_x = center_x self.save_y = center_y # Create a marker - self.center_marker = self.axes.plot([self.x], [self.y], linestyle='', - marker='s', markersize=10, - color=self.color, alpha=0.6, - pickradius=5, label="pick", - zorder=zorder, - visible=True)[0] + self.center_marker = self.axes.plot( + [self.x], + [self.y], + linestyle="", + marker="s", + markersize=10, + color=self.color, + alpha=0.6, + pickradius=5, + label="pick", + zorder=zorder, + visible=True, + )[0] # Draw a point - self.center = self.axes.plot([self.x], [self.y], - linestyle='-', marker='', - color=self.color, - visible=True)[0] + self.center = self.axes.plot([self.x], [self.y], linestyle="-", marker="", color=self.color, visible=True)[0] # Flag to determine the motion this point self.has_move = False # connecting the marker to allow them to move @@ -350,8 +358,6 @@ def clear(self): Clear this figure and its markers """ self.clear_markers() - self.center.remove() - self.center_marker.remove() def update(self, center_x=None, center_y=None): """ @@ -373,8 +379,7 @@ def save(self, ev): self.save_y = self.y def moveend(self, ev): - """ - """ + """ """ self.has_move = False self.base.moveend(ev) @@ -396,18 +401,18 @@ def move(self, x, y, ev): self.base.draw() def setCursor(self, x, y): - """ - """ + """ """ self.move(x, y, None) self.update() + class VerticalDoubleLine(BaseInteractor): """ Draw 2 vertical lines moving in opposite direction and centered on a point (PointInteractor) """ - def __init__(self, base, axes, color='black', zorder=5, x=0.5, y=0.5, - center_x=0.0, center_y=0.0): + + def __init__(self, base, axes, color="black", zorder=5, x=0.5, y=0.5, center_x=0.0, center_y=0.0): BaseInteractor.__init__(self, base, axes, color=color) # Initialization the class self.markers = [] @@ -438,19 +443,27 @@ def __init__(self, base, axes, color='black', zorder=5, x=0.5, y=0.5, self.half_width = numpy.fabs(self.x1 - self.x2) / 2 self.save_half_width = numpy.fabs(self.x1 - self.x2) / 2 # Create marker - self.right_marker = self.axes.plot([self.x1], [0], linestyle='', - marker='s', markersize=10, - color=self.color, alpha=0.6, - pickradius=5, label="pick", - zorder=zorder, visible=True)[0] + self.right_marker = self.axes.plot( + [self.x1], + [0], + linestyle="", + marker="s", + markersize=10, + color=self.color, + alpha=0.6, + pickradius=5, + label="pick", + zorder=zorder, + visible=True, + )[0] # Define the left and right lines of the rectangle - self.right_line = self.axes.plot([self.x1, self.x1], [self.y1, self.y2], - linestyle='-', marker='', - color=self.color, visible=True)[0] - self.left_line = self.axes.plot([self.x2, self.x2], [self.y1, self.y2], - linestyle='-', marker='', - color=self.color, visible=True)[0] + self.right_line = self.axes.plot( + [self.x1, self.x1], [self.y1, self.y2], linestyle="-", marker="", color=self.color, visible=True + )[0] + self.left_line = self.axes.plot( + [self.x2, self.x2], [self.y1, self.y2], linestyle="-", marker="", color=self.color, visible=True + )[0] # Flag to determine if the lines have moved self.has_move = False # Connection the marker and draw the pictures @@ -470,12 +483,10 @@ def clear(self): Clear this slicer and its markers """ self.clear_markers() - self.right_marker.remove() self.right_line.remove() self.left_line.remove() - def update(self, x1=None, x2=None, y1=None, y2=None, width=None, - height=None, center=None): + def update(self, x1=None, x2=None, y1=None, y2=None, width=None, height=None, center=None): """ Draw the new roughness on the graph. :param x1: new maximum value of x coordinates @@ -502,10 +513,8 @@ def update(self, x1=None, x2=None, y1=None, y2=None, width=None, self.y2 = -self.half_height + self.center_y self.right_marker.set(xdata=[self.x1], ydata=[self.center_y]) - self.right_line.set(xdata=[self.x1, self.x1], - ydata=[self.y1, self.y2]) - self.left_line.set(xdata=[self.x2, self.x2], - ydata=[self.y1, self.y2]) + self.right_line.set(xdata=[self.x1, self.x1], ydata=[self.y1, self.y2]) + self.left_line.set(xdata=[self.x2, self.x2], ydata=[self.y1, self.y2]) return # if x1, y1, y2, y3 are given draw the rectangle with this value if x1 is not None: @@ -570,14 +579,14 @@ def setCursor(self, x, y): self.move(x, y, None) self.update() + class HorizontalDoubleLine(BaseInteractor): """ Draw 2 horizontal lines moving in opposite direction and centered on a point (PointInteractor) """ - def __init__(self, base, axes, color='black', zorder=5, x=0.5, y=0.5, - center_x=0.0, center_y=0.0): + def __init__(self, base, axes, color="black", zorder=5, x=0.5, y=0.5, center_x=0.0, center_y=0.0): BaseInteractor.__init__(self, base, axes, color=color) # Initialization the class self.markers = [] @@ -600,20 +609,27 @@ def __init__(self, base, axes, color='black', zorder=5, x=0.5, y=0.5, self.save_half_height = numpy.fabs(y) self.half_width = numpy.fabs(x) self.save_half_width = numpy.fabs(x) - self.top_marker = self.axes.plot([0], [self.y1], linestyle='', - marker='s', markersize=10, - color=self.color, alpha=0.6, - pickradius=5, label="pick", - zorder=zorder, visible=True)[0] + self.top_marker = self.axes.plot( + [0], + [self.y1], + linestyle="", + marker="s", + markersize=10, + color=self.color, + alpha=0.6, + pickradius=5, + label="pick", + zorder=zorder, + visible=True, + )[0] # Define 2 horizotnal lines - self.top_line = self.axes.plot([self.x1, -self.x1], [self.y1, self.y1], - linestyle='-', marker='', - color=self.color, visible=True)[0] - self.bottom_line = self.axes.plot([self.x1, -self.x1], - [self.y2, self.y2], - linestyle='-', marker='', - color=self.color, visible=True)[0] + self.top_line = self.axes.plot( + [self.x1, -self.x1], [self.y1, self.y1], linestyle="-", marker="", color=self.color, visible=True + )[0] + self.bottom_line = self.axes.plot( + [self.x1, -self.x1], [self.y2, self.y2], linestyle="-", marker="", color=self.color, visible=True + )[0] # Flag to determine if the lines have moved self.has_move = False # connection the marker and draw the pictures @@ -633,12 +649,10 @@ def clear(self): Clear this figure and its markers """ self.clear_markers() - self.top_marker.remove() self.bottom_line.remove() self.top_line.remove() - def update(self, x1=None, x2=None, y1=None, y2=None, - width=None, height=None, center=None): + def update(self, x1=None, x2=None, y1=None, y2=None, width=None, height=None, center=None): """ Draw the new roughness on the graph. :param x1: new maximum value of x coordinates @@ -666,10 +680,8 @@ def update(self, x1=None, x2=None, y1=None, y2=None, self.y2 = -self.half_height + self.center_y self.top_marker.set(xdata=[self.center_x], ydata=[self.y1]) - self.top_line.set(xdata=[self.x1, self.x2], - ydata=[self.y1, self.y1]) - self.bottom_line.set(xdata=[self.x1, self.x2], - ydata=[self.y2, self.y2]) + self.top_line.set(xdata=[self.x1, self.x2], ydata=[self.y1, self.y1]) + self.bottom_line.set(xdata=[self.x1, self.x2], ydata=[self.y2, self.y2]) return # if x1, y1, y2, y3 are given draw the rectangle with this value if x1 is not None: diff --git a/src/sas/qtgui/Plotting/Slicers/RadiusInteractor.py b/src/sas/qtgui/Plotting/Slicers/RadiusInteractor.py index 3549eec195..41ea692f11 100755 --- a/src/sas/qtgui/Plotting/Slicers/RadiusInteractor.py +++ b/src/sas/qtgui/Plotting/Slicers/RadiusInteractor.py @@ -16,8 +16,8 @@ class RadiusInteractor(BaseInteractor): :param theta: average angle of the lines from the x-axis :param phi: angular displacement of the lines either side of theta """ - def __init__(self, base, axes, color='black', zorder=5, r1=1.0, r2=2.0, - theta=np.pi / 3, phi=np.pi / 8): + + def __init__(self, base, axes, color="black", zorder=5, r1=1.0, r2=2.0, theta=np.pi / 3, phi=np.pi / 8): BaseInteractor.__init__(self, base, axes, color=color) self.markers = [] self.axes = axes @@ -40,27 +40,42 @@ def __init__(self, base, axes, color='black', zorder=5, r1=1.0, r2=2.0, r_x2 = self.r2 * np.cos(self.theta - self.phi) r_y2 = self.r2 * np.sin(self.theta - self.phi) # Define the left and right markers - self.l_marker = self.axes.plot([(l_x1+l_x2)/2], [(l_y1+l_y2)/2], - linestyle='', marker='s', markersize=10, - color=self.color, alpha=0.6, - pickradius=5, label='pick', - zorder=zorder, visible=True)[0] - self.r_marker = self.axes.plot([(r_x1+r_x2)/2], [(r_y1+r_y2)/2], - linestyle='', marker='s', markersize=10, - color=self.color, alpha=0.6, - pickradius=5, label='pick', - zorder=zorder, visible=True)[0] + self.l_marker = self.axes.plot( + [(l_x1 + l_x2) / 2], + [(l_y1 + l_y2) / 2], + linestyle="", + marker="s", + markersize=10, + color=self.color, + alpha=0.6, + pickradius=5, + label="pick", + zorder=zorder, + visible=True, + )[0] + self.r_marker = self.axes.plot( + [(r_x1 + r_x2) / 2], + [(r_y1 + r_y2) / 2], + linestyle="", + marker="s", + markersize=10, + color=self.color, + alpha=0.6, + pickradius=5, + label="pick", + zorder=zorder, + visible=True, + )[0] # Define the left and right lines - self.l_line = self.axes.plot([l_x1, l_x2], [l_y1, l_y2], - linestyle='-', marker='', - color=self.color, visible=True)[0] - self.r_line = self.axes.plot([r_x1, r_x2], [r_y1, r_y2], - linestyle='-', marker='', - color=self.color, visible=True)[0] + self.l_line = self.axes.plot( + [l_x1, l_x2], [l_y1, l_y2], linestyle="-", marker="", color=self.color, visible=True + )[0] + self.r_line = self.axes.plot( + [r_x1, r_x2], [r_y1, r_y2], linestyle="-", marker="", color=self.color, visible=True + )[0] # Flag to keep track of motion self.has_move = False - self.connect_markers([self.l_marker, self.l_line, - self.r_marker, self.r_line]) + self.connect_markers([self.l_marker, self.l_line, self.r_marker, self.r_line]) self.update() def set_layer(self, n): @@ -76,10 +91,10 @@ def clear(self): Clear this slicer and its markers """ self.clear_markers() - self.l_marker.remove() - self.l_line.remove() - self.r_marker.remove() - self.r_line.remove() + if self.l_line.axes is not None: + self.l_line.remove() + if self.r_line.axes is not None: + self.r_line.remove() def update(self, r1=None, r2=None, theta=None, phi=None): """ @@ -107,9 +122,9 @@ def update(self, r1=None, r2=None, theta=None, phi=None): r_x2 = self.r2 * np.cos(self.theta - self.phi) r_y2 = self.r2 * np.sin(self.theta - self.phi) # Draw the updated markers and lines - self.l_marker.set(xdata=[(l_x1+l_x2)/2], ydata=[(l_y1+l_y2)/2]) + self.l_marker.set(xdata=[(l_x1 + l_x2) / 2], ydata=[(l_y1 + l_y2) / 2]) self.l_line.set(xdata=[l_x1, l_x2], ydata=[l_y1, l_y2]) - self.r_marker.set(xdata=[(r_x1+r_x2)/2], ydata=[(r_y1+r_y2)/2]) + self.r_marker.set(xdata=[(r_x1 + r_x2) / 2], ydata=[(r_y1 + r_y2) / 2]) self.r_line.set(xdata=[r_x1, r_x2], ydata=[r_y1, r_y2]) def save(self, ev): @@ -149,4 +164,3 @@ def move(self, x, y, ev): def set_cursor(self, x, y): self.move(x, y, None) self.update() - diff --git a/src/sas/qtgui/Plotting/Slicers/SectorSlicer.py b/src/sas/qtgui/Plotting/Slicers/SectorSlicer.py index d96087c52a..1c98f6d119 100644 --- a/src/sas/qtgui/Plotting/Slicers/SectorSlicer.py +++ b/src/sas/qtgui/Plotting/Slicers/SectorSlicer.py @@ -1,9 +1,14 @@ +import logging + import numpy import sas.qtgui.Utilities.GuiUtils as GuiUtils from sas.qtgui.Plotting.PlotterData import Data1D from sas.qtgui.Plotting.SlicerModel import SlicerModel from sas.qtgui.Plotting.Slicers.BaseInteractor import BaseInteractor +from sas.qtgui.Plotting.Slicers.SlicerUtils import generate_unique_plot_id + +logger = logging.getLogger(__name__) MIN_PHI = 0.05 @@ -53,17 +58,15 @@ def __init__(self, base, axes, item=None, color="black", zorder=3): self.main_line = LineInteractor(self, self.axes, color="blue", zorder=zorder, r=self.qmax, theta=self.theta2) self.main_line.qmax = self.qmax # Right Side line - self.right_line = SideInteractor( - self, self.axes, color="black", zorder=zorder, r=self.qmax, phi=-1 * self.phi, theta2=self.theta2 - ) + self.right_line = SideInteractor(self, self.axes, color=color, zorder=zorder, r=self.qmax, phi=-1 * self.phi, theta2=self.theta2) self.right_line.update(right=True) self.right_line.qmax = self.qmax # Left Side line - self.left_line = SideInteractor( - self, self.axes, color="black", zorder=zorder, r=self.qmax, phi=self.phi, theta2=self.theta2 - ) + self.left_line = SideInteractor(self, self.axes, color=color, zorder=zorder, r=self.qmax, phi=self.phi, theta2=self.theta2) self.left_line.update(left=True) self.left_line.qmax = self.qmax + # Store the plot ID so it doesn't change when parameters are updated + self._plot_id = None # draw the sector self.update() self._post_data() @@ -81,13 +84,12 @@ def set_layer(self, n): def clear(self): """ - Clear the slicer and all connected events related to this slicer + Clear this slicer and its markers """ self.clear_markers() self.main_line.clear() self.left_line.clear() self.right_line.clear() - self.base.connect.clearall() def update(self): """ @@ -155,8 +157,6 @@ def _post_data(self, nbins=None): new_plot = Data1D(x=sector.x, y=sector.y, dy=sector.dy, dx=sector.dx) new_plot.dxl = dxl new_plot.dxw = dxw - new_plot.name = "SectorQ" + "(" + self.data.name + ")" - new_plot.title = "SectorQ" + "(" + self.data.name + ")" new_plot.source = self.data.source new_plot.interactive = True new_plot.detector = self.data.detector @@ -167,8 +167,14 @@ def _post_data(self, nbins=None): new_plot.ytransform = "y" new_plot.yaxis("\\rm{Residuals} ", "/") - new_plot.group_id = "2daverage" + self.data.name - new_plot.id = "SectorQ" + self.data.name + # Assign unique id per slicer instance and use it as the display name + if self._plot_id is None: + base_id = "SectorQ" + self.data.name + self._plot_id = generate_unique_plot_id(base_id, self._item) + + new_plot.id = self._plot_id + new_plot.name = new_plot.id + new_plot.title = new_plot.id new_plot.is_data = True item = self._item @@ -192,12 +198,12 @@ def validate(self, param_name, param_value): if param_name == "Delta_Phi [deg]": # First, check the closeness if numpy.fabs(param_value) < MIN_DIFFERENCE: - print("Sector angles too close. Please adjust.") + logger.warning("Sector angles too close. Please adjust.") isValid = False elif param_name == "nbins": # Can't be 0 if param_value < 1: - print("Number of bins cannot be less than or equal to 0. Please adjust.") + logger.warning("Number of bins cannot be less than or equal to 0. Please adjust.") isValid = False return isValid @@ -346,13 +352,10 @@ def clear(self): Clear the slicer and all connected events related to this slicer """ self.clear_markers() - try: - self.line.remove() + if self.inner_marker.axes is not None: self.inner_marker.remove() - except: - # Old version of matplotlib - for item in range(len(self.axes.lines)): - del self.axes.lines[0] + if self.line.axes is not None: + self.line.remove() def update(self, phi=None, delta=None, mline=None, side=False, left=False, right=False): """ @@ -529,13 +532,10 @@ def set_layer(self, n): def clear(self): self.clear_markers() - try: + if self.inner_marker.axes is not None: self.inner_marker.remove() + if self.line.axes is not None: self.line.remove() - except: - # Old version of matplotlib - for item in range(len(self.axes.lines)): - del self.axes.lines[0] def update(self, theta=None): """ diff --git a/src/sas/qtgui/Plotting/Slicers/SlicerUtils.py b/src/sas/qtgui/Plotting/Slicers/SlicerUtils.py new file mode 100644 index 0000000000..dfa11bd4e9 --- /dev/null +++ b/src/sas/qtgui/Plotting/Slicers/SlicerUtils.py @@ -0,0 +1,38 @@ +""" +Utility functions for slicers +""" + +def _count_matching_ids(item, base_id: str) -> int: + """ + Recursively count items with IDs starting with base_id. + + :param item: Tree item to search + :param base_id: The base identifier to match + :return: Count of matching items in this subtree + """ + count = 0 + + # Check current item + d = item.data() + if hasattr(d, "id") and isinstance(d.id, str) and d.id.startswith(base_id): + count += 1 + + # Recursively check all children + for i in range(item.rowCount()): + count += _count_matching_ids(item.child(i), base_id) + + return count + + +def generate_unique_plot_id(base_id: str, item) -> str: + """ + Generate a unique plot ID by checking existing plots in the data tree. + + :param base_id: The base identifier string (e.g., "SectorQ" + data.name) + :param item: The current item in the data explorer tree + :return: A unique ID string, either base_id or base_id_N where N is a number + """ + parent_item = item if item.parent() is None else item.parent() + existing = _count_matching_ids(parent_item, base_id) + + return base_id if existing == 0 else f"{base_id}_{existing + 1}" diff --git a/src/sas/qtgui/Plotting/Slicers/WedgeSlicer.py b/src/sas/qtgui/Plotting/Slicers/WedgeSlicer.py index 16b6a24dc4..851df79007 100644 --- a/src/sas/qtgui/Plotting/Slicers/WedgeSlicer.py +++ b/src/sas/qtgui/Plotting/Slicers/WedgeSlicer.py @@ -7,6 +7,7 @@ from sas.qtgui.Plotting.Slicers.BaseInteractor import BaseInteractor from sas.qtgui.Plotting.Slicers.RadiusInteractor import RadiusInteractor from sas.qtgui.Plotting.Slicers.SectorSlicer import LineInteractor +from sas.qtgui.Plotting.Slicers.SlicerUtils import generate_unique_plot_id class WedgeInteractor(BaseInteractor, SlicerModel): @@ -58,21 +59,23 @@ def __init__(self, base, axes, item=None, color="black", zorder=3): self.fold = True # reference of the current data averager self.averager = None + # Saves plot id so it doesn't get recreated each time a parameter changes + self.plot_id = None self.inner_arc = ArcInteractor( - self, self.axes, color="black", zorder=zorder, r=self.r1, theta=self.theta, phi=self.phi + self, self.axes, color=color, zorder=zorder, r=self.r1, theta=self.theta, phi=self.phi ) self.inner_arc.qmax = self.qmax self.outer_arc = ArcInteractor( - self, self.axes, color="black", zorder=zorder + 1, r=self.r2, theta=self.theta, phi=self.phi + self, self.axes, color=color, zorder=zorder + 1, r=self.r2, theta=self.theta, phi=self.phi ) self.outer_arc.qmax = self.qmax * 1.2 self.radial_lines = RadiusInteractor( - self, self.axes, color="black", zorder=zorder + 1, r1=self.r1, r2=self.r2, theta=self.theta, phi=self.phi + self, self.axes, color=color, zorder=zorder + 1, r1=self.r1, r2=self.r2, theta=self.theta, phi=self.phi ) self.radial_lines.qmax = self.qmax * 1.2 self.central_line = LineInteractor( - self, self.axes, color="black", zorder=zorder, r=self.qmax * 1.414, theta=self.theta, half_length=True + self, self.axes, color=color, zorder=zorder, r=self.qmax * 1.414, theta=self.theta, half_length=True ) self.central_line.qmax = self.qmax * 1.414 self.update() @@ -93,12 +96,10 @@ def clear(self): Clear the slicer and all connected events related to this slicer """ self.averager = None - self.clear_markers() self.outer_arc.clear() self.inner_arc.clear() self.radial_lines.clear() self.central_line.clear() - self.base.connect.clearall() def update(self): """ @@ -209,10 +210,15 @@ def _post_data(self, new_sector=None, nbins=None): new_plot.xaxis(r"\rm{Q}", "A^{-1}") new_plot.yaxis(r"\rm{Intensity} ", "cm^{-1}") - new_plot.id = "Wedge" + self.averager.__name__ + self.data.name - new_plot.type_id = ( - "Slicer" + self.data.name - ) # Used to remove plots after changing slicer so they don't keep showing up after closed + # Assign unique id per slicer instance and use it as the display name + if self.plot_id is None: + base_id = "Wedge" + self.averager.__name__ + self.data.name + self.plot_id = generate_unique_plot_id(base_id, self._item) + + new_plot.id = self.plot_id + new_plot.name = new_plot.id + new_plot.title = new_plot.id + new_plot.type_id = ("Slicer" + self.data.name) new_plot.is_data = True item = self._item if self._item.parent() is not None: diff --git a/src/sas/qtgui/Plotting/UI/SlicerParametersUI.ui b/src/sas/qtgui/Plotting/UI/SlicerParametersUI.ui index 61a7582901..d0e16284ef 100755 --- a/src/sas/qtgui/Plotting/UI/SlicerParametersUI.ui +++ b/src/sas/qtgui/Plotting/UI/SlicerParametersUI.ui @@ -6,8 +6,8 @@ 0 0 - 395 - 468 + 570 + 671 @@ -78,7 +78,7 @@ Slicer - + @@ -90,12 +90,22 @@ Slicer Parameters - + + + + + 0 + 0 + + + + + - Slicer type: + Change slicer: @@ -113,7 +123,7 @@ - + 0 @@ -159,16 +169,120 @@ - - + + + + + + + + 0 + 0 + + + + Slicers + + + + - + 0 0 + + + + + + Create new slicer: + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + 0 + 0 + + + + + None + + + + + Sector Interactor + + + + + Annulus Interactor + + + + + Box Interactor X + + + + + Box Interactor Y + + + + + Wedge Interactor Q + + + + + Wedge Interactor Phi + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Delete + + + + + @@ -354,7 +468,7 @@ tabWidget lstParams - cbSlicer + cbSlicerReplace cmdApply cmdClose cmdHelp