diff --git a/CHANGES.rst b/CHANGES.rst index fe5aa7216f..131aafbd33 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,7 +4,7 @@ New Features ------------ -* New design for viewer legend. [#3220, #3254] +* New design for viewer legend. [#3220, #3254, #3263] Cubeviz ^^^^^^^ diff --git a/jdaviz/app.py b/jdaviz/app.py index 5576d84202..bcc0c405b2 100644 --- a/jdaviz/app.py +++ b/jdaviz/app.py @@ -151,7 +151,8 @@ def to_unit(self, data, cid, values, original_units, target_units): 'plugin-slider': 'components/plugin_slider.vue', 'plugin-color-picker': 'components/plugin_color_picker.vue', 'plugin-input-header': 'components/plugin_input_header.vue', - 'glue-state-sync-wrapper': 'components/glue_state_sync_wrapper.vue'} + 'glue-state-sync-wrapper': 'components/glue_state_sync_wrapper.vue', + 'data-menu-add-data': 'components/data_menu_add_data.vue'} _verbosity_levels = ('debug', 'info', 'warning', 'error') diff --git a/jdaviz/components/data_menu_add_data.vue b/jdaviz/components/data_menu_add_data.vue new file mode 100644 index 0000000000..05887060a7 --- /dev/null +++ b/jdaviz/components/data_menu_add_data.vue @@ -0,0 +1,66 @@ + + + \ No newline at end of file diff --git a/jdaviz/components/toolbar_nested.py b/jdaviz/components/toolbar_nested.py index 56f7843777..7f15932602 100644 --- a/jdaviz/components/toolbar_nested.py +++ b/jdaviz/components/toolbar_nested.py @@ -156,6 +156,39 @@ def _on_change_v_model(self, event): if event['old'] is not None: self.active_tool_id = event['old'] + def _select_tool(self, tool_id, menu_ind): + for search_tool_id, info in self.tools_data.items(): + if info['menu_ind'] == menu_ind and info['primary']: + prev_id = search_tool_id + prev_info = info + break + else: + raise ValueError("could not find previous selection") + + if isinstance(self.tools[tool_id], CheckableTool): + # only switch to primary if its actually checkable, otherwise + # just activate once + self.tools_data = { + **self.tools_data, + prev_id: { + **prev_info, + 'primary': False + }, + tool_id: { + **self.tools_data[tool_id], + 'primary': True + } + } + + # and finally, set to be the active tool (this triggers _on_change_v_model which in turn + # triggers BasicJupyterToolbar._on_change_active_tool) + self.active_tool_id = tool_id + + def select_tool(self, tool_id): + # find the previous primary tool in the same menu_ind + menu_ind = self.tools_data[tool_id]['menu_ind'] + self._select_tool(tool_id, menu_ind) + def add_tool(self, tool, menu_ind, has_suboptions=True, primary=False, visible=True): # NOTE: this method is essentially copied from glue-jupyter's BasicJupyterToolbar, # but we need extra values in the tools_data dictionary. We could call super(), @@ -183,29 +216,4 @@ def vue_select_primary(self, args): Activate the primary tool from a given menu index """ menu_ind, tool_id = args - for search_tool_id, info in self.tools_data.items(): - if info['menu_ind'] == menu_ind and info['primary']: - prev_id = search_tool_id - prev_info = info - break - else: - raise ValueError("could not find previous selection") - - if isinstance(self.tools[tool_id], CheckableTool): - # only switch to primary if its actually checkable, otherwise - # just activate once - self.tools_data = { - **self.tools_data, - prev_id: { - **prev_info, - 'primary': False - }, - tool_id: { - **self.tools_data[tool_id], - 'primary': True - } - } - - # and finally, set to be the active tool (this triggers _on_change_v_model which in turn - # triggers BasicJupyterToolbar._on_change_active_tool) - self.active_tool_id = tool_id + self._select_tool(tool_id, menu_ind) diff --git a/jdaviz/configs/cubeviz/plugins/viewers.py b/jdaviz/configs/cubeviz/plugins/viewers.py index 0bc60c5903..96238b96ad 100644 --- a/jdaviz/configs/cubeviz/plugins/viewers.py +++ b/jdaviz/configs/cubeviz/plugins/viewers.py @@ -39,6 +39,8 @@ def __init__(self, *args, **kwargs): # Hide axes by default self.state.show_axes = False + self.data_menu._obj.dataset.add_filter('is_cube_or_image') + @property def _default_spectrum_viewer_reference_name(self): return self.jdaviz_helper._default_spectrum_viewer_reference_name diff --git a/jdaviz/configs/default/plugins/data_menu/data_menu.py b/jdaviz/configs/default/plugins/data_menu/data_menu.py index 1d3f0fe8a0..5c4acb930b 100644 --- a/jdaviz/configs/default/plugins/data_menu/data_menu.py +++ b/jdaviz/configs/default/plugins/data_menu/data_menu.py @@ -1,7 +1,7 @@ from contextlib import contextmanager from traitlets import Bool, Dict, Unicode, List, observe -from jdaviz.core.template_mixin import TemplateMixin, LayerSelectMixin +from jdaviz.core.template_mixin import (TemplateMixin, LayerSelectMixin, DatasetSelectMixin) from jdaviz.core.user_api import UserApiWrapper from jdaviz.core.events import IconsUpdatedMessage, AddDataMessage from jdaviz.utils import cmap_samples, is_not_wcs_only @@ -9,7 +9,17 @@ __all__ = ['DataMenu'] -class DataMenu(TemplateMixin, LayerSelectMixin): +SUBSET_TOOL_IDS = {'circle': 'bqplot:truecircle', + 'rectangle': 'bqplot:rectangle', + 'ellipse': 'bqplot:ellipse', + 'annulus': 'bqplot:circannulus', + 'xrange': 'bqplot:xrange', + 'yrange': 'bqplot:yrange'} + +SUBSET_NAMES = {v: k for k, v in SUBSET_TOOL_IDS.items()} + + +class DataMenu(TemplateMixin, LayerSelectMixin, DatasetSelectMixin): """Viewer Data Menu Only the following attributes and methods are available through the @@ -19,6 +29,8 @@ class DataMenu(TemplateMixin, LayerSelectMixin): actively selected layer(s) * :meth:`set_layer_visibility` * :meth:`toggle_layer_visibility` + * :meth:`create_subset` + * :meth:`add_data` """ template_file = __file__, "data_menu.vue" @@ -31,6 +43,7 @@ class DataMenu(TemplateMixin, LayerSelectMixin): visible_layers = Dict().tag(sync=True) # read-only, set by viewer cmap_samples = Dict(cmap_samples).tag(sync=True) + subset_tools = List().tag(sync=True) dm_layer_selected = List().tag(sync=True) @@ -47,6 +60,13 @@ def __init__(self, viewer, *args, **kwargs): self.layer.multiselect = True self.layer._default_mode = 'empty' + # we'll use a modified version of the dataset mixin to have a filtered + # list of data entries in the app that are not in the current viewer. + # changing the selection has no consequence. + def data_not_in_viewer(data): + return data.label not in self.layer.choices + self.dataset.filters = ['is_not_wcs_only', 'not_child_layer', data_not_in_viewer] + # first attach callback to catch any updates to viewer/layer icons and then # set their initial state self.hub.subscribe(self, IconsUpdatedMessage, self._on_app_icons_updated) @@ -54,11 +74,24 @@ def __init__(self, viewer, *args, **kwargs): self.viewer_icons = dict(self.app.state.viewer_icons) self.layer_icons = dict(self.app.state.layer_icons) + # this currently assumes that toolbar.tools_data is set at init and does not change + # if we ever support dynamic tool registration, this will need to be updated + self.subset_tools = [{'id': k, 'img': v['img'], 'name': SUBSET_NAMES.get(k, k)} + for k, v in self._viewer.toolbar.tools_data.items() + if k in SUBSET_TOOL_IDS.values()] + @property def user_api(self): - expose = ['layer', 'set_layer_visibility', 'toggle_layer_visibility'] + expose = ['layer', 'set_layer_visibility', 'toggle_layer_visibility', + 'create_subset', 'add_data'] return UserApiWrapper(self, expose=expose) + @observe('layer_items') + def _update_data_not_in_viewer(self, msg): + # changing the layers in the viewer needs to trigger an update to dataset_items + # through the set filters + self.dataset._on_data_changed() + def _set_viewer_id(self): # viewer_ids are not populated on the viewer at init, so we'll keep checking and set # these the first time that they are available @@ -162,3 +195,39 @@ def toggle_layer_visibility(self, layer_label): def vue_set_layer_visibility(self, info, *args): return self.set_layer_visibility(info.get('layer'), info.get('value')) # pragma: no cover + + def add_data(self, data_label): + """ + Add a dataset to the viewer. + + Parameters + ---------- + data_label : str + The label of the dataset to add to the viewer. + """ + if data_label not in self.dataset.choices: + raise ValueError(f"Data label '{data_label}' not able to be loaded into '{self.viewer_id}'. Must be one of: {self.dataset.choices}") # noqa + return self.app.add_data_to_viewer(self.viewer_id, data_label) + + def vue_add_data_to_viewer(self, info, *args): + self.add_data(info.get('data_label')) # pragma: no cover + + def create_subset(self, subset_type): + """ + Create a new subset in the viewer. This sets the app-wide subset selection to 'Create New' + and selects the appropriate tool in this viewer's toolbar. + + Parameters + ---------- + subset_type : str + The type of subset to create. Must be one of 'circle', 'rectangle', 'ellipse', + 'annulus', 'xrange', or 'yrange'. + """ + # clear previous selection, finalize subsets, temporarily sets default tool + self._viewer.toolbar.active_tool_id = None + # set toolbar to the selection, will also set app-wide subset selection to "Create New" + # NOTE: supports passing either the user-friendly name or the actual ID + self._viewer.toolbar.select_tool(SUBSET_TOOL_IDS.get(subset_type, subset_type)) + + def vue_create_subset(self, info, *args): + self.create_subset(info.get('subset_type')) # pragma: no cover diff --git a/jdaviz/configs/default/plugins/data_menu/data_menu.vue b/jdaviz/configs/default/plugins/data_menu/data_menu.vue index be50687122..8de84bc32a 100644 --- a/jdaviz/configs/default/plugins/data_menu/data_menu.vue +++ b/jdaviz/configs/default/plugins/data_menu/data_menu.vue @@ -63,7 +63,7 @@ icon disabled > - mdi-format-vertical-align-center + mdi-format-vertical-align-center @@ -75,14 +75,13 @@ - - - mdi-plus - - + + - mdi-delete + mdi-delete - mdi-label + mdi-label Edit Subset @@ -271,6 +271,7 @@ .dm-footer > .v-list-item__icon, .dm-footer > .v-list-item__content, .dm-footer > .v-list-item__action { filter: invert(1); } + .dm-header.v-btn--disabled .v-icon { color: green !important; } diff --git a/jdaviz/configs/default/tests/test_data_menu.py b/jdaviz/configs/default/tests/test_data_menu.py index 143a7fbffb..af3105b42a 100644 --- a/jdaviz/configs/default/tests/test_data_menu.py +++ b/jdaviz/configs/default/tests/test_data_menu.py @@ -66,3 +66,28 @@ def test_data_menu_selection(specviz_helper, spectrum1d): assert len(dm._obj.layer_items) == 1 assert dm._obj.dm_layer_selected == [0] assert dm.layer.selected == ['test'] + + +def test_data_menu_add_data(imviz_helper): + for i in range(3): + imviz_helper.load_data(np.zeros((2, 2)) + i, data_label=f'image_{i}', show_in_viewer=False) + + dm = imviz_helper.viewers['imviz-0']._obj.data_menu + assert len(dm._obj.layer_items) == 0 + assert len(dm.layer.choices) == 0 + assert len(dm._obj.dataset.choices) == 3 + + dm.add_data('image_0') + assert dm.layer.choices == ['image_0'] + assert len(dm._obj.dataset.choices) == 2 + + +def test_data_menu_create_subset(imviz_helper): + imviz_helper.load_data(np.zeros((2, 2)), data_label='image', show_in_viewer=True) + + dm = imviz_helper.viewers['imviz-0']._obj.data_menu + assert imviz_helper.app.session.edit_subset_mode.edit_subset == [] + + dm.create_subset('circle') + assert imviz_helper.app.session.edit_subset_mode.edit_subset == [] + assert imviz_helper.viewers['imviz-0']._obj.toolbar.active_tool_id == 'bqplot:truecircle' diff --git a/jdaviz/configs/imviz/plugins/viewers.py b/jdaviz/configs/imviz/plugins/viewers.py index e7175e41b1..0486582e81 100644 --- a/jdaviz/configs/imviz/plugins/viewers.py +++ b/jdaviz/configs/imviz/plugins/viewers.py @@ -63,6 +63,8 @@ def __init__(self, *args, **kwargs): # intensive. self.state.image_external_padding = 0.5 + self.data_menu._obj.dataset.add_filter('is_image') + def on_mouse_or_key_event(self, data): active_image_layer = self.active_image_layer if active_image_layer is None: diff --git a/jdaviz/configs/mosviz/helper.py b/jdaviz/configs/mosviz/helper.py index 514cdef410..2ded2d0624 100644 --- a/jdaviz/configs/mosviz/helper.py +++ b/jdaviz/configs/mosviz/helper.py @@ -160,6 +160,10 @@ def _row_click_message_handler(self, msg): self._handle_image_zoom(msg) # expose the row to vue for each of the viewers self.app.state.settings = {**self.app.state.settings, 'mosviz_row': msg.selected_index} + # update data filters in each viewer's data_menu + for viewer in self.viewers.values(): + if data_menu := getattr(viewer._obj, '_data_menu', None): + data_menu.dataset._on_data_changed() def _handle_image_zoom(self, msg): mos_data = self.app.data_collection['MOS Table'] diff --git a/jdaviz/configs/mosviz/plugins/viewers.py b/jdaviz/configs/mosviz/plugins/viewers.py index 91fee2ee5b..a84d772998 100644 --- a/jdaviz/configs/mosviz/plugins/viewers.py +++ b/jdaviz/configs/mosviz/plugins/viewers.py @@ -43,6 +43,9 @@ def __init__(self, *args, **kwargs): self.state.show_axes = False # Axes are wrong anyway self.figure.fig_margin = {'left': 0, 'bottom': 0, 'top': 0, 'right': 0} + self.data_menu._obj.dataset.add_filter('is_image_not_spectrum') + self.data_menu._obj.dataset.add_filter('same_mosviz_row') + def data(self, cls=None): return [layer_state.layer.get_object(cls=cls or self.default_class) for layer_state in self.state.layers @@ -95,6 +98,9 @@ def __init__(self, *args, **kwargs): for k in ('x_min', 'x_max'): self.state.add_callback(k, self._handle_x_axis_orientation) + self.data_menu._obj.dataset.add_filter('is_2d_spectrum_or_trace') + self.data_menu._obj.dataset.add_filter('same_mosviz_row') + @cached_property def reference_spectral_axis(self): return self.state.reference_data.get_object().spectral_axis.value @@ -243,6 +249,13 @@ class MosvizProfileView(SpecvizProfileView): ['jdaviz:sidebar_plot', 'jdaviz:sidebar_export'] ] + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # NOTE: is_spectrum filter already applied by SpecvizProfileView + self.data_menu.layer.add_filter('not_trace') + self.data_menu._obj.dataset.add_filter('same_mosviz_row') + def set_plot_axes(self): super().set_plot_axes() self.figure.axes[1].num_ticks = 5 diff --git a/jdaviz/configs/specviz/plugins/viewers.py b/jdaviz/configs/specviz/plugins/viewers.py index bde414f458..baf6372f06 100644 --- a/jdaviz/configs/specviz/plugins/viewers.py +++ b/jdaviz/configs/specviz/plugins/viewers.py @@ -32,6 +32,10 @@ class SpecvizProfileView(JdavizProfileView): _state_cls = FreezableProfileViewerState _default_profile_subset_type = 'spectral' + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.data_menu._obj.dataset.add_filter('is_spectrum') + @property def redshift(self): return self.jdaviz_helper._redshift diff --git a/jdaviz/core/template_mixin.py b/jdaviz/core/template_mixin.py index c97c3a8866..6e24465e06 100644 --- a/jdaviz/core/template_mixin.py +++ b/jdaviz/core/template_mixin.py @@ -1527,6 +1527,12 @@ def not_spatial_subset_in_profile_viewer(lyr): # so we want to exclude spatial subsets return get_subset_type(lyr) != 'spatial' + def is_trace(lyr): + return 'Trace' in getattr(getattr(lyr, 'data', None), 'meta', []) + + def not_trace(lyr): + return not is_trace(lyr) + return super()._is_valid_item(lyr, locals()) def _layer_to_dict(self, layer_label): @@ -3525,7 +3531,7 @@ def layer_in_flux_viewer(data): return data.label in [lyr.layer.label for lyr in self.flux_viewer.layers] # noqa E741 def is_trace(data): - return hasattr(data, 'meta') and 'Trace' in data.meta + return 'Trace' in getattr(data, 'meta', []) def not_trace(data): return not is_trace(data) @@ -3533,9 +3539,26 @@ def not_trace(data): def is_image(data): return len(data.shape) == 2 + def is_image_not_spectrum(data): + return (is_image(data) + and not getattr(data.coords, 'is_spectral', True)) + def is_cube(data): return len(data.shape) == 3 + def is_cube_or_image(data): + return len(data.shape) >= 2 + + def is_spectrum(data): + return (len(data.shape) == 1 + and data.coords is not None + and getattr(data.coords, 'is_spectral', True)) + + def is_2d_spectrum_or_trace(data): + return (data.ndim == 2 + and data.coords is not None + and getattr(data.coords, 'is_spectral', True)) or 'Trace' in data.meta + def is_flux_cube(data): if hasattr(self.app._jdaviz_helper, '_loaded_uncert_cube'): uncert_label = getattr(self.app._jdaviz_helper._loaded_uncert_cube, 'label', None) @@ -3550,6 +3573,19 @@ def not_child_layer(data): # ignore layers that are children in associations: return self.app._get_assoc_data_parent(data.label) is None + def same_mosviz_row(data): + # NOTE: requires calling _on_data_changed on a change to row + # currently handled by mosviz helper _row_click_message_handler + meta = getattr(data, 'meta', None) + if meta is None: + return True + data_row = meta.get('mosviz_row', None) + app_row = self.app.state.settings.get('mosviz_row', None) + + if data_row is None or app_row is None: + return True + return data_row == app_row + layer_is_not_dq = layer_is_not_dq_global return super()._is_valid_item(data, locals())