diff --git a/CHANGES.rst b/CHANGES.rst index d23f107741..31761344e8 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -11,6 +11,8 @@ New Features - Opacity for spatial subsets is now adjustable from within Plot Options. [#2663] +- Live-preview of aperture selection in plugins. [#2664] + Cubeviz ^^^^^^^ @@ -49,6 +51,9 @@ API Changes Cubeviz ^^^^^^^ +- ``spatial_subset`` in the spectral extraction plugin is now renamed to ``aperture`` and the deprecated name will + be removed in a future release. [#2664] + Imviz ^^^^^ diff --git a/docs/dev/ui_style_guide.rst b/docs/dev/ui_style_guide.rst index e970b72da2..f1709b03f5 100644 --- a/docs/dev/ui_style_guide.rst +++ b/docs/dev/ui_style_guide.rst @@ -22,7 +22,7 @@ try to adhere to the following principles: components are necessary in a single row. Always emphasize readability at the default/minimum width of the plugin tray, rather than using columns that result in a ton of text overflow. * Use ```` to align content to the right (such as action buttons). -* Action buttons should use ``text`` +* Action buttons should use ``text`` to control the color depending on whether the button affects things outside the plugin itself (adding/modifying data collection or subset entries, etc) or are isolated to within the plugin (adding model components in model fitting, etc). These buttons can be wrapped in tooltip components diff --git a/jdaviz/configs/cubeviz/plugins/slice/slice.py b/jdaviz/configs/cubeviz/plugins/slice/slice.py index 425afbafa6..1b98e6458c 100644 --- a/jdaviz/configs/cubeviz/plugins/slice/slice.py +++ b/jdaviz/configs/cubeviz/plugins/slice/slice.py @@ -11,7 +11,8 @@ from specutils.spectra.spectrum1d import Spectrum1D from jdaviz.core.events import (AddDataMessage, SliceToolStateMessage, - SliceSelectSliceMessage, GlobalDisplayUnitChanged) + SliceSelectSliceMessage, SliceWavelengthUpdatedMessage, + GlobalDisplayUnitChanged) from jdaviz.core.registries import tray_registry from jdaviz.core.template_mixin import PluginTemplateMixin from jdaviz.core.user_api import PluginUserApi @@ -217,6 +218,10 @@ def _on_slider_updated(self, event): for viewer in self._indicator_viewers: viewer._update_slice_indicator(value) + self.hub.broadcast(SliceWavelengthUpdatedMessage(slice=value, + wavelength=self.wavelength, + sender=self)) + def vue_goto_first(self, *args): if self.is_playing: return diff --git a/jdaviz/configs/cubeviz/plugins/spectral_extraction/spectral_extraction.py b/jdaviz/configs/cubeviz/plugins/spectral_extraction/spectral_extraction.py index acaee9a9ed..c8380a09e4 100644 --- a/jdaviz/configs/cubeviz/plugins/spectral_extraction/spectral_extraction.py +++ b/jdaviz/configs/cubeviz/plugins/spectral_extraction/spectral_extraction.py @@ -5,17 +5,19 @@ import numpy as np import astropy import astropy.units as u +from astropy.utils.decorators import deprecated from astropy.nddata import ( NDDataArray, StdDevUncertainty, NDUncertainty ) -from traitlets import Bool, List, Unicode, observe +from traitlets import Bool, Float, List, Unicode, observe -from jdaviz.core.events import SnackbarMessage +from jdaviz.core.custom_traitlets import FloatHandleEmpty +from jdaviz.core.events import SnackbarMessage, SliceWavelengthUpdatedMessage from jdaviz.core.registries import tray_registry from jdaviz.core.template_mixin import (PluginTemplateMixin, DatasetSelectMixin, SelectPluginComponent, - SpatialSubsetSelectMixin, + ApertureSubsetSelectMixin, AddResultsMixin, with_spinner) from jdaviz.core.user_api import PluginUserApi @@ -30,8 +32,8 @@ @tray_registry( 'cubeviz-spectral-extraction', label="Spectral Extraction", viewer_requirements='spectrum' ) -class SpectralExtraction(PluginTemplateMixin, DatasetSelectMixin, - SpatialSubsetSelectMixin, AddResultsMixin): +class SpectralExtraction(PluginTemplateMixin, ApertureSubsetSelectMixin, + DatasetSelectMixin, AddResultsMixin): """ See the :ref:`Spectral Extraction Plugin Documentation ` for more details. @@ -41,12 +43,20 @@ class SpectralExtraction(PluginTemplateMixin, DatasetSelectMixin, * :meth:`~jdaviz.core.template_mixin.PluginTemplateMixin.show` * :meth:`~jdaviz.core.template_mixin.PluginTemplateMixin.open_in_tray` * :meth:`~jdaviz.core.template_mixin.PluginTemplateMixin.close_in_tray` - * ``spatial_subset`` (:class:`~jdaviz.core.template_mixin.SubsetSelect`): - Subset to use for the spectral extraction, or ``No Subset``. + * ``aperture`` (:class:`~jdaviz.core.template_mixin.SubsetSelect`): + Subset to use for the spectral extraction, or ``Entire Cube``. * ``add_results`` (:class:`~jdaviz.core.template_mixin.AddResults`) * :meth:`collapse` """ template_file = __file__, "spectral_extraction.vue" + uses_active_status = Bool(True).tag(sync=True) + + # feature flag for cone support + dev_cone_support = Bool(False).tag(sync=True) + wavelength_dependent = Bool(False).tag(sync=True) + reference_wavelength = FloatHandleEmpty().tag(sync=True) + slice_wavelength = Float().tag(sync=True) + function_items = List().tag(sync=True) function_selected = Unicode('Sum').tag(sync=True) filename = Unicode().tag(sync=True) @@ -68,6 +78,12 @@ def __init__(self, *args, **kwargs): self.extracted_spec = None + # TODO: in the future this could be generalized with support in SelectPluginComponent + self.aperture._default_text = 'Entire Cube' + self.aperture._manual_options = ['Entire Cube'] + self.aperture.items = [{"label": "Entire Cube"}] + self.aperture.select_default() + self.function = SelectPluginComponent( self, items='function_items', @@ -77,6 +93,9 @@ def __init__(self, *args, **kwargs): self._set_default_results_label() self.add_results.viewer.filters = ['is_spectrum_viewer'] + self.session.hub.subscribe(self, SliceWavelengthUpdatedMessage, + handler=self._on_slice_changed) + if ASTROPY_LT_5_3_2: self.disabled_msg = "Spectral Extraction in Cubeviz requires astropy>=5.3.2" @@ -95,11 +114,44 @@ def user_api(self): return PluginUserApi( self, expose=( - 'function', 'spatial_subset', + 'function', 'spatial_subset', 'aperture', 'add_results', 'collapse_to_spectrum' ) ) + @property + @deprecated(since="3.9", alternative="aperture") + def spatial_subset(self): + return self.user_api.aperture + + @property + def slice_plugin(self): + return self.app._jdaviz_helper.plugins['Slice'] + + @observe('wavelength_dependent') + def _wavelength_dependent_changed(self, *args): + if self.wavelength_dependent: + self.reference_wavelength = self.slice_plugin.wavelength + # NOTE: this can be redundant in the case where reference_wavelength changed and triggers + # the observe, but we need to ensure it is updated if reference_wavelength is unchanged + self._update_mark_scale() + + def _on_slice_changed(self, msg): + self.slice_wavelength = msg.wavelength + + def vue_goto_reference_wavelength(self, *args): + self.slice_plugin.wavelength = self.reference_wavelength + + def vue_adopt_slice_as_reference(self, *args): + self.reference_wavelength = self.slice_plugin.wavelength + + @observe('reference_wavelength', 'slice_wavelength') + def _update_mark_scale(self, *args): + if not self.wavelength_dependent: + self.aperture.scale_factor = 1.0 + return + self.aperture.scale_factor = self.slice_wavelength/self.reference_wavelength + @with_spinner() def collapse_to_spectrum(self, add_data=True, **kwargs): """ @@ -121,12 +173,12 @@ def collapse_to_spectrum(self, add_data=True, **kwargs): # defaults to ``No Subset``). Since the Cubeviz parser puts the fluxes # and uncertainties in different glue Data objects, we translate the spectral # cube and its uncertainties into separate NDDataArrays, then combine them: - if self.spatial_subset_selected != self.spatial_subset.default_text: + if self.aperture.selected != self.aperture.default_text: nddata = spectral_cube.get_subset_object( - subset_id=self.spatial_subset_selected, cls=NDDataArray + subset_id=self.aperture.selected, cls=NDDataArray ) uncertainties = uncert_cube.get_subset_object( - subset_id=self.spatial_subset_selected, cls=StdDevUncertainty + subset_id=self.aperture.selected, cls=StdDevUncertainty ) else: nddata = spectral_cube.get_object(cls=NDDataArray) @@ -242,15 +294,15 @@ def _save_extracted_spec_to_fits(self, overwrite=False, *args): f"Extracted spectrum saved to {os.path.abspath(filename)}", sender=self, color="success")) - @observe('spatial_subset_selected') + @observe('aperture_selected') def _set_default_results_label(self, event={}): label = "Spectral extraction" if ( - hasattr(self, 'spatial_subset') and - self.spatial_subset.selected != self.spatial_subset.default_text + hasattr(self, 'aperture') and + self.aperture.selected != self.aperture.default_text ): - label += f' ({self.spatial_subset_selected})' + label += f' ({self.aperture_selected})' self.results_label_default = label diff --git a/jdaviz/configs/cubeviz/plugins/spectral_extraction/spectral_extraction.vue b/jdaviz/configs/cubeviz/plugins/spectral_extraction/spectral_extraction.vue index 553945daa8..41695b4510 100644 --- a/jdaviz/configs/cubeviz/plugins/spectral_extraction/spectral_extraction.vue +++ b/jdaviz/configs/cubeviz/plugins/spectral_extraction/spectral_extraction.vue @@ -2,6 +2,9 @@ @@ -18,13 +21,53 @@ +
+ cone support is under active development and hidden from users + + + + +
+ + + + Adopt Current Slice + + + + + + + + + + Slice to Reference Wavelength + + + +
+ +
+ 0 + + # sample cube only has 2 slices with wavelengths [4.62280007e-07 4.62360028e-07] m + slice_plg.slice = 1 + assert mark.x[1] == before_x[1] + + slice_plg.slice = 0 + extract_plg._obj.dev_cone_support = True + extract_plg._obj.wavelength_dependent = True + assert mark.x[1] == before_x[1] + + slice_plg.slice = 1 + assert mark.x[1] != before_x[1] + + extract_plg._obj.vue_goto_reference_wavelength() + assert slice_plg.slice == 0 + + slice_plg.slice = 1 + extract_plg._obj.vue_adopt_slice_as_reference() + extract_plg._obj.vue_goto_reference_wavelength() + assert slice_plg.slice == 1 diff --git a/jdaviz/configs/cubeviz/plugins/tests/test_cubeviz_helper.py b/jdaviz/configs/cubeviz/plugins/tests/test_cubeviz_helper.py index 4a654a85c2..0266860be1 100644 --- a/jdaviz/configs/cubeviz/plugins/tests/test_cubeviz_helper.py +++ b/jdaviz/configs/cubeviz/plugins/tests/test_cubeviz_helper.py @@ -27,6 +27,9 @@ def test_plugin_user_apis(cubeviz_helper): for plugin_name, plugin_api in cubeviz_helper.plugins.items(): plugin = plugin_api._obj for attr in plugin_api._expose: + if plugin_name == 'Spectral Extraction' and attr == 'spatial_subset': + # deprecated, so would raise a deprecation warning + continue assert hasattr(plugin, attr) diff --git a/jdaviz/configs/imviz/plugins/aper_phot_simple/aper_phot_simple.py b/jdaviz/configs/imviz/plugins/aper_phot_simple/aper_phot_simple.py index beaad83648..c909534f7d 100644 --- a/jdaviz/configs/imviz/plugins/aper_phot_simple/aper_phot_simple.py +++ b/jdaviz/configs/imviz/plugins/aper_phot_simple/aper_phot_simple.py @@ -22,7 +22,8 @@ from jdaviz.core.region_translators import regions2aperture, _get_region_from_spatial_subset from jdaviz.core.registries import tray_registry from jdaviz.core.template_mixin import (PluginTemplateMixin, DatasetMultiSelectMixin, - SubsetSelect, TableMixin, PlotMixin, with_spinner) + SubsetSelect, ApertureSubsetSelectMixin, + TableMixin, PlotMixin, with_spinner) from jdaviz.core.tools import ICON_DIR from jdaviz.utils import PRIHDR_KEY @@ -32,7 +33,8 @@ @tray_registry('imviz-aper-phot-simple', label="Aperture Photometry") -class SimpleAperturePhotometry(PluginTemplateMixin, DatasetMultiSelectMixin, TableMixin, PlotMixin): +class SimpleAperturePhotometry(PluginTemplateMixin, ApertureSubsetSelectMixin, + DatasetMultiSelectMixin, TableMixin, PlotMixin): """ The Aperture Photometry plugin performs aperture photometry for drawn regions. See the :ref:`Aperture Photometry Plugin Documentation ` for more details. @@ -45,10 +47,9 @@ class SimpleAperturePhotometry(PluginTemplateMixin, DatasetMultiSelectMixin, Tab * :meth:`~jdaviz.core.template_mixin.PluginTemplateMixin.close_in_tray` """ template_file = __file__, "aper_phot_simple.vue" + uses_active_status = Bool(True).tag(sync=True) multiselect = Bool(False).tag(sync=True) - aperture_items = List([]).tag(sync=True) - aperture_selected = Any('').tag(sync=True) aperture_area = Integer().tag(sync=True) background_items = List().tag(sync=True) background_selected = Unicode("").tag(sync=True) @@ -75,14 +76,6 @@ class SimpleAperturePhotometry(PluginTemplateMixin, DatasetMultiSelectMixin, Tab def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.aperture = SubsetSelect(self, - 'aperture_items', - 'aperture_selected', - multiselect='multiselect', - dataset='dataset', - default_text=None, - filters=['is_spatial', 'is_not_composite', 'is_not_annulus']) - self.background = SubsetSelect(self, 'background_items', 'background_selected', diff --git a/jdaviz/configs/imviz/plugins/aper_phot_simple/aper_phot_simple.vue b/jdaviz/configs/imviz/plugins/aper_phot_simple/aper_phot_simple.vue index cf022a417c..9d4236fe15 100644 --- a/jdaviz/configs/imviz/plugins/aper_phot_simple/aper_phot_simple.vue +++ b/jdaviz/configs/imviz/plugins/aper_phot_simple/aper_phot_simple.vue @@ -2,6 +2,9 @@ diff --git a/jdaviz/core/events.py b/jdaviz/core/events.py index 7e4440ebed..c8008e3a1e 100644 --- a/jdaviz/core/events.py +++ b/jdaviz/core/events.py @@ -4,7 +4,7 @@ __all__ = ['NewViewerMessage', 'ViewerAddedMessage', 'ViewerRemovedMessage', 'LoadDataMessage', 'AddDataMessage', 'SnackbarMessage', 'RemoveDataMessage', 'AddLineListMessage', 'RowLockMessage', - 'SliceSelectSliceMessage', + 'SliceSelectSliceMessage', 'SliceWavelengthUpdatedMessage', 'SliceToolStateMessage', 'TableClickMessage', 'LinkUpdatedMessage', 'ExitBatchLoadMessage', 'AstrowidgetMarkersChangedMessage', 'MarkersPluginUpdate', @@ -315,6 +315,14 @@ def slice(self): return self._slice +class SliceWavelengthUpdatedMessage(Message): + '''Message generated by the slice plugin when the selected slice and wavelength are updated''' + def __init__(self, slice, wavelength, *args, **kwargs): + super().__init__(*args, **kwargs) + self.slice = slice + self.wavelength = wavelength + + class SliceToolStateMessage(Message): '''Message generated by the select slice plot plugin when activated/deactivated''' def __init__(self, change, *args, **kwargs): diff --git a/jdaviz/core/marks.py b/jdaviz/core/marks.py index 1e75b4adcc..b6eccb0ef3 100644 --- a/jdaviz/core/marks.py +++ b/jdaviz/core/marks.py @@ -17,7 +17,8 @@ 'PluginMark', 'LinesAutoUnit', 'PluginLine', 'PluginScatter', 'LineAnalysisContinuum', 'LineAnalysisContinuumCenter', 'LineAnalysisContinuumLeft', 'LineAnalysisContinuumRight', - 'LineUncertainties', 'ScatterMask', 'SelectedSpaxel', 'MarkersMark', 'FootprintOverlay'] + 'LineUncertainties', 'ScatterMask', 'SelectedSpaxel', 'MarkersMark', 'FootprintOverlay', + 'ApertureMark'] accent_color = "#c75d2c" @@ -652,6 +653,11 @@ def overlay(self): return self._overlay +class ApertureMark(PluginLine): + def __init__(self, viewer, **kwargs): + super().__init__(viewer, **kwargs) + + class HistogramMark(Lines): def __init__(self, min_max_value, scales, **kwargs): # Vertical line in LinearScale diff --git a/jdaviz/core/template_mixin.py b/jdaviz/core/template_mixin.py index 61761de605..152cc879b4 100644 --- a/jdaviz/core/template_mixin.py +++ b/jdaviz/core/template_mixin.py @@ -28,9 +28,10 @@ from glue_jupyter.bqplot.image import BqplotImageView from glue_jupyter.registries import viewer_registry from glue_jupyter.widgets.linked_dropdown import get_choices as _get_glue_choices +from regions import PixelRegion from specutils import Spectrum1D from specutils.manipulation import extract_region -from traitlets import Any, Bool, HasTraits, List, Unicode, observe +from traitlets import Any, Bool, Float, HasTraits, List, Unicode, observe from ipywidgets import widget_serialization from ipypopout import PopoutButton @@ -41,13 +42,13 @@ from jdaviz.core.events import (AddDataMessage, RemoveDataMessage, ViewerAddedMessage, ViewerRemovedMessage, ViewerRenamedMessage, SnackbarMessage, - AddDataToViewerMessage) + AddDataToViewerMessage, ChangeRefDataMessage) from jdaviz.core.marks import (LineAnalysisContinuum, LineAnalysisContinuumCenter, LineAnalysisContinuumLeft, LineAnalysisContinuumRight, - ShadowLine) -from jdaviz.core.region_translators import _get_region_from_spatial_subset + ShadowLine, ApertureMark) +from jdaviz.core.region_translators import regions2roi, _get_region_from_spatial_subset from jdaviz.core.user_api import UserApiWrapper, PluginUserApi from jdaviz.style_registry import PopoutStyleWrapper from jdaviz.utils import get_subset_type @@ -59,7 +60,9 @@ 'BasePluginComponent', 'SelectPluginComponent', 'UnitSelectPluginComponent', 'EditableSelectPluginComponent', 'PluginSubcomponent', - 'SubsetSelect', 'SpatialSubsetSelectMixin', 'SpectralSubsetSelectMixin', + 'SubsetSelect', + 'SpatialSubsetSelectMixin', 'SpectralSubsetSelectMixin', + 'ApertureSubsetSelect', 'ApertureSubsetSelectMixin', 'DatasetSpectralSubsetValidMixin', 'SpectralContinuumMixin', 'ViewerSelect', 'ViewerSelectMixin', 'LayerSelect', 'LayerSelectMixin', @@ -519,6 +522,12 @@ def _clear_cache(self, *attrs): if attr in self.__dict__: del self.__dict__[attr] + def add_traitlets(self, **traitlets): + for k, v in traitlets.items(): + if v is None: + continue + self._plugin_traitlets[k] = v + def add_observe(self, traitlet_name, handler, first=False): self._plugin.observe(handler, traitlet_name) if first: @@ -1573,9 +1582,7 @@ class SubsetSelect(SelectPluginComponent): * :attr:`selected_obj` * :attr:`selected_subset_state` * :meth:`selected_min_max` - """ - """ Traitlets (in the object, custom traitlets in the plugin): * ``items`` (list of dicts with keys: label, color, type) @@ -1613,7 +1620,6 @@ class SubsetSelect(SelectPluginComponent): /> """ - def __init__(self, plugin, items, selected, multiselect=None, selected_has_subregions=None, dataset=None, viewers=None, default_text=None, manual_options=[], filters=[], default_mode='default_text'): @@ -1634,7 +1640,7 @@ def __init__(self, plugin, items, selected, multiselect=None, selected_has_subre the name of the dataset traitlet defined in ``plugin``, to be used for accessing how the subset is applied to the data (masks, etc), optional viewers : list - the reference names or ids of the viewer to extract the subregion. If not provided o + the reference names or ids of the viewer to extract the subregion. If not provided or None, will loop through all references. default_text : str or None the text to show for no selection. If not provided or None, no entry will be provided @@ -1683,7 +1689,8 @@ def _selected_changed(self, event): self._update_has_subregions() def _on_dataset_selected_changed(self, event): - self._clear_cache('selected_subset_mask', 'selected_spatial_region') + self._clear_cache('selected_subset_mask', + 'selected_spatial_region') def _subset_to_dict(self, subset): # find layer artist in default spectrum-viewer @@ -1825,6 +1832,8 @@ def _get_spatial_region(self, dataset, subset=None): subset_state = self.selected_subset_state else: subset_state = self._get_subset_state(subset) + if subset_state is None: + return None region = _get_region_from_spatial_subset(self.plugin, subset_state) region.meta['label'] = subset return region @@ -1834,13 +1843,11 @@ def selected_spatial_region(self): if not getattr(self, 'dataset', None): # pragma: no cover raise ValueError("Retrieving subset mask requires associated dataset") if self.is_multiselect and self.dataset.is_multiselect: # pragma: no cover - # technically this could work if either has length of one, but would require extra - # logic raise NotImplementedError("cannot access selected_spatial_region for multiple subsets and multiple datasets") # noqa types = self.selected_item.get('type') if not isinstance(types, list): types = [types] - if np.any([type != 'spatial' for type in types]): + if np.any([type not in ('spatial', None) for type in types]): raise TypeError("This action is only supported on spatial-type subsets") if self.is_multiselect: return [self._get_spatial_region(dataset=self.dataset.selected, subset=subset) for subset in self.selected] # noqa @@ -1942,6 +1949,250 @@ def __init__(self, *args, **kwargs): filters=['is_spatial']) +class ApertureSubsetSelect(SubsetSelect): + """ + Plugin select for aperture subsets, with support for single or multi-selection, as well as + live-preview rendered in the viewers. + + Useful API methods/attributes: + + * :meth:`~SelectPluginComponent.choices` + * ``selected`` + * :meth:`~SelectPluginComponent.is_multiselect` + * :meth:`~SelectPluginComponent.select_default` + * :meth:`~SelectPluginComponent.select_all` (only if ``is_multiselect``) + * :meth:`~SelectPluginComponent.select_none` (only if ``is_multiselect``) + * :attr:`~SubsetSelect.selected_obj` + * :attr:`~SubsetSelect.selected_subset_state` + * :meth:`~SubsetSelect.selected_min_max` + * :meth:`marks` + * :meth:`image_viewers` + + Traitlets (in the object, custom traitlets in the plugin): + + * ``items`` (list of dicts with keys: label, color, type) + * ``selected`` (string) + + Properties (in the object only): + + * ``labels`` (list of labels corresponding to items) + * ``selected_item`` (dictionary in ``items`` coresponding to ``selected``, cached) + * ``selected_obj`` (subset object corresponding to ``selected``, cached) + * ``marks`` (list of marks added to image viewers in the app to preview the apertures) + + Methods (in the object only): + + * ``selected_min_max(cube)`` (quantity, only applicable for spectral subsets) + + To use in a plugin: + + * create (empty) traitlets in the plugin + * register with all the automatic logic in the plugin's init by passing the string names + of the respective traitlets. + * use component in plugin template (see below) + * refer to properties above based on the interally stored reference to the + instantiated object of this component + * observe the traitlets created and defined in the plugin, as necessary + + Example template (label and hint are optional):: + + + + """ + def __init__(self, plugin, items, selected, scale_factor, multiselect=None, + dataset=None, viewers=None): + """ + Parameters + ---------- + plugin + the parent plugin object + items : str + the name of the items traitlet defined in ``plugin`` + selected : str + the name of the selected traitlet defined in ``plugin`` + scale_factor : str + the name of the traitlet defining the radius factor for the drawn aperture + multiselect : str + the name of the traitlet defining whether the dropdown should accept multiple selections + dataset : str + the name of the dataset traitlet defined in ``plugin``, to be used for accessing how + the subset is applied to the data (masks, etc), optional + viewers : list + the reference names or ids of the viewer to extract the subregion. If not provided or + None, will loop through all references. + """ + # NOTE: is_not_composite is assumed in _get_mark_coords + super().__init__(plugin, + items=items, + selected=selected, + multiselect=multiselect, + filters=['is_spatial', 'is_not_composite', 'is_not_annulus'], + dataset=dataset, + viewers=viewers, + default_text=None) + + self.add_traitlets(scale_factor=scale_factor) + + self.add_observe('is_active', self._plugin_active_changed) + self.add_observe(selected, self._update_mark_coords) + self.add_observe(scale_factor, self._update_mark_coords) + # add marks to any new viewers + self.hub.subscribe(self, ViewerAddedMessage, handler=self._update_mark_coords) + # update coordinates when reference data is changed + # NOTE: when link type is changed, all subsets are required to be dropped + self.hub.subscribe(self, ChangeRefDataMessage, handler=self._update_mark_coords) + + def _update_subset(self, *args, **kwargs): + # update coordinates when subset is modified (with subset tools plugin or drag event) + super()._update_subset(*args, **kwargs) + self._update_mark_coords() + + def _on_dataset_selected_changed(self, event): + super()._on_dataset_selected_changed(event) + self._update_mark_coords() + + def _plugin_active_changed(self, *args): + for mark in self.marks: + mark.visible = self.plugin.is_active + + @property + def image_viewers(self): + return [viewer for viewer in self.app._viewer_store.values() + if isinstance(viewer, BqplotImageView)] + + @property + def marks(self): + # NOTE: this will require additional logic if we want to support multiple independent + # ApertureSubsetSelect instances per-plugin + all_aperture_marks = [] + for viewer in self.image_viewers: + # search for existing mark + matches = [mark for mark in viewer.figure.marks + if isinstance(mark, ApertureMark)] + if len(matches): + all_aperture_marks += matches + continue + + x_coords, y_coords = self._get_mark_coords(viewer) + + mark = ApertureMark( + viewer, + x=x_coords, + y=y_coords, + colors=['#c75109'], + fill_opacities=[0.0], + visible=self.plugin.is_active) + all_aperture_marks.append(mark) + viewer.figure.marks = viewer.figure.marks + [mark] + return all_aperture_marks + + def _get_mark_coords(self, viewer): + if not len(self.selected) or not len(self.dataset.selected): + return [], [] + if self.selected in self._manual_options: + return [], [] + + if getattr(self, 'multiselect', False): + # assume first dataset (for retrieving the region object) + # but iterate over all subsets + spatial_regions = [self._get_spatial_region(dataset=self.dataset.selected[0], subset=subset) # noqa + for subset in self.selected if subset != self._manual_options] + else: + # use cached version + spatial_regions = [self.selected_spatial_region] + + x_coords, y_coords = np.array([]), np.array([]) + for spatial_region in spatial_regions: + if spatial_region is None: + continue + + if isinstance(spatial_region, PixelRegion): + pixel_region = spatial_region + else: + wcs = getattr(viewer.state.reference_data, 'coords', None) + if wcs is None: + return [], [] + pixel_region = spatial_region.to_pixel(wcs) + roi = regions2roi(pixel_region) + + # NOTE: this assumes that we'll apply the same radius factor to all subsets (all will + # be defined at the same slice for cones in cubes) + if hasattr(roi, 'radius'): + roi.radius *= self.scale_factor + elif hasattr(roi, 'radius_x'): + roi.radius_x *= self.scale_factor + roi.radius_y *= self.scale_factor + elif hasattr(roi, 'center'): + center = roi.center() + half_width = abs(roi.xmax - roi.xmin) * 0.5 * self.scale_factor + half_height = abs(roi.ymax - roi.ymin) * 0.5 * self.scale_factor + roi.xmin = center[0] - half_width + roi.xmax = center[0] + half_width + roi.ymin = center[1] - half_height + roi.ymax = center[1] + half_height + else: # pragma: no cover + raise NotImplementedError + + x, y = roi.to_polygon() + + # concatenate with nan between to avoid line connecting separate subsets + x_coords = np.concatenate((x_coords, np.array([np.nan]), x)) + y_coords = np.concatenate((y_coords, np.array([np.nan]), y)) + + return x_coords, y_coords + + def _update_mark_coords(self, *args): + for viewer in self.image_viewers: + x_coords, y_coords = self._get_mark_coords(viewer) + for mark in self.marks: + if mark.viewer != viewer: + continue + mark.x, mark.y = x_coords, y_coords + + +class ApertureSubsetSelectMixin(VuetifyTemplate, HubListener): + """ + Applies the ApertureSubsetSelect component as a mixin in the base plugin. This + automatically adds traitlets as well as new properties to the plugin with minimal + extra code. For multiple instances or custom traitlet names/defaults, use the + component instead. + + To use in a plugin: + + * add ``ApertureSubsetSelectMixin`` as a mixin to the class BEFORE ``DatasetSelectMixin`` + * use the traitlets available from the plugin or properties/methods available from + ``plugin.aperture``. + + Example template (label and hint are optional):: + + + + """ + aperture_items = List([]).tag(sync=True) + aperture_selected = Any('').tag(sync=True) + aperture_scale_factor = Float(1).tag(sync=True) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.aperture = ApertureSubsetSelect(self, + 'aperture_items', + 'aperture_selected', + 'aperture_scale_factor', + dataset='dataset' if hasattr(self, 'dataset') else None, # noqa + multiselect='multiselect' if hasattr(self, 'multiselect') else None) # noqa + + class DatasetSpectralSubsetValidMixin(VuetifyTemplate, HubListener): """ Adds a traitlet tracking whether self.dataset and self.spectral_subset