diff --git a/CHANGES.rst b/CHANGES.rst index 732515e20b..5138d5a990 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,6 +4,8 @@ New Features ------------ +* New design for viewer legend. [#3220] + Cubeviz ^^^^^^^ diff --git a/jdaviz/app.py b/jdaviz/app.py index bd1260a200..cc1bbe7b57 100644 --- a/jdaviz/app.py +++ b/jdaviz/app.py @@ -12,9 +12,8 @@ from echo import CallbackProperty, DictCallbackProperty, ListCallbackProperty from ipygoldenlayout import GoldenLayout from ipysplitpanes import SplitPanes -import matplotlib.cm as cm import numpy as np -from glue.config import colormaps, data_translator, settings as glue_settings +from glue.config import data_translator, settings as glue_settings from glue.core import HubListener from glue.core.link_helpers import LinkSame, LinkSameWithUnits from glue.core.message import (DataCollectionAddMessage, @@ -126,6 +125,7 @@ def to_unit(self, data, cid, values, original_units, target_units): 'j-viewer-data-select': 'components/viewer_data_select.vue', 'j-viewer-data-select-item': 'components/viewer_data_select_item.vue', 'j-layer-viewer-icon': 'components/layer_viewer_icon.vue', + 'j-layer-viewer-icon-stylized': 'components/layer_viewer_icon_stylized.vue', 'j-tray-plugin': 'components/tray_plugin.vue', 'j-play-pause-widget': 'components/play_pause_widget.vue', 'j-plugin-section-header': 'components/plugin_section_header.vue', @@ -297,21 +297,6 @@ def __init__(self, configuration=None, *args, **kwargs): # can reference their state easily since glue does not store viewers self._viewer_store = {} - # Add new and inverse colormaps to Glue global state. Also see ColormapRegistry in - # https://github.com/glue-viz/glue/blob/main/glue/config.py - new_cms = (['Rainbow', cm.rainbow], - ['Seismic', cm.seismic], - ['Reversed: Gray', cm.gray_r], - ['Reversed: Viridis', cm.viridis_r], - ['Reversed: Plasma', cm.plasma_r], - ['Reversed: Inferno', cm.inferno_r], - ['Reversed: Magma', cm.magma_r], - ['Reversed: Hot', cm.hot_r], - ['Reversed: Rainbow', cm.rainbow_r]) - for cur_cm in new_cms: - if cur_cm not in colormaps.members: - colormaps.add(*cur_cm) - from jdaviz.core.events import PluginTableAddedMessage, PluginPlotAddedMessage self._plugin_tables = {} self.hub.subscribe(self, PluginTableAddedMessage, @@ -603,7 +588,16 @@ def _on_layers_changed(self, msg): self.state.layer_icons = {**self.state.layer_icons, layer_name: orientation_icons.get(layer_name, wcs_only_refdata_icon)} - elif is_not_child: + elif not is_not_child: + parent_icon = self.state.layer_icons.get(self._get_assoc_data_parent(layer_name)) + index = len([ln for ln, ic in self.state.layer_icons.items() + if not ic[:4] == 'mdi-' and + self._get_assoc_data_parent(ln) == parent_icon]) + 1 + self.state.layer_icons = { + **self.state.layer_icons, + layer_name: f"{parent_icon}{index}" + } + else: self.state.layer_icons = { **self.state.layer_icons, layer_name: alpha_index(len([ln for ln, ic in self.state.layer_icons.items() diff --git a/jdaviz/components/layer_viewer_icon_stylized.vue b/jdaviz/components/layer_viewer_icon_stylized.vue new file mode 100644 index 0000000000..fddef4e149 --- /dev/null +++ b/jdaviz/components/layer_viewer_icon_stylized.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/jdaviz/components/plugin_layer_select_tabs.vue b/jdaviz/components/plugin_layer_select_tabs.vue index 8647a04d92..c979a4ade8 100644 --- a/jdaviz/components/plugin_layer_select_tabs.vue +++ b/jdaviz/components/plugin_layer_select_tabs.vue @@ -13,21 +13,17 @@ - - - - {{ item.icon }} - - - - + @@ -44,70 +40,6 @@ module.exports = { return [this.$props.selected] } }, - methods: { - tooltipContent(item) { - var tooltip = item.label - if (item.visible === 'mixed') { - tooltip += '
Visibility: mixed' - } else if (!item.visible) { - tooltip += '
Visibility: hidden' - } - if (this.$props.colormode === 'mixed' && !item.is_subset) { - tooltip += '
Color mode: mixed' - } - if (item.colors.length > 1) { - if (this.$props.colormode === 'Colormaps') { - tooltip += '
Colormap: mixed' - } else if (this.$props.colormode === 'mixed') { - tooltip += '
Color/colormap: mixed' - } else { - tooltip += '
Color: mixed' - } - } - return tooltip - }, - visibilityStyle(item) { - if (item.visible === 'mixed'){ - return 'repeating-linear-gradient(30deg, rgba(0,0,0,0.3), rgba(0,0,0,0.3) 3px, transparent 3px, transparent 3px, transparent 10px)' - } - else if (item.visible) { - return 'repeating-linear-gradient(30deg, transparent, transparent 10px)' - } else { - return 'repeating-linear-gradient(30deg, rgba(0,0,0,0.4), rgba(0,0,0,0.4) 8px, transparent 8px, transparent 8px, transparent 10px)' - } - }, - colorStyle(item) { - const strip_width = 42 / item.colors.length - var cmap_strip_width = strip_width - var colors = [] - var style = 'repeating-linear-gradient( 135deg, ' - - for ([mi, color_or_cmap] of item.colors.entries()) { - if (color_or_cmap.startsWith('#')) { - colors = [color_or_cmap] - } else { - colors = this.$props.cmap_samples[color_or_cmap] - } - - cmap_strip_width = strip_width / colors.length - for ([ci, color] of colors.entries()) { - var start = mi*strip_width + ci*cmap_strip_width - var end = mi*strip_width+(ci+1)*cmap_strip_width - style += color + ' '+start+'px, ' + color + ' '+end+'px' - if (ci !== colors.length-1) { - style += ', ' - } - } - if (mi !== item.colors.length-1) { - style += ', ' - } - } - - style += ')' - return style - - } - } }; diff --git a/jdaviz/configs/default/plugins/data_menu/data_menu.py b/jdaviz/configs/default/plugins/data_menu/data_menu.py index c733e6f866..287eb60aed 100644 --- a/jdaviz/configs/default/plugins/data_menu/data_menu.py +++ b/jdaviz/configs/default/plugins/data_menu/data_menu.py @@ -1,14 +1,21 @@ from traitlets import Dict, Unicode -from jdaviz.core.template_mixin import TemplateMixin +from jdaviz.core.template_mixin import TemplateMixin, LayerSelectMixin from jdaviz.core.user_api import UserApiWrapper -from jdaviz.core.events import IconsUpdatedMessage +from jdaviz.core.events import IconsUpdatedMessage, AddDataMessage +from jdaviz.utils import cmap_samples, is_not_wcs_only __all__ = ['DataMenu'] -class DataMenu(TemplateMixin): - """Viewer Data Menu""" +class DataMenu(TemplateMixin, LayerSelectMixin): + """Viewer Data Menu + + Only the following attributes and methods are available through the + :ref:`public API `: + + * ``layer`` (:class:`~jdaviz.core.template_mixin.LayerSelect`): + """ template_file = __file__, "data_menu.vue" viewer_id = Unicode().tag(sync=True) @@ -19,18 +26,26 @@ class DataMenu(TemplateMixin): visible_layers = Dict().tag(sync=True) # read-only, set by viewer + cmap_samples = Dict(cmap_samples).tag(sync=True) + def __init__(self, viewer, *args, **kwargs): super().__init__(*args, **kwargs) self._viewer = viewer + + # TODO: refactor how this is applied by default to go through filters directly + self.layer.remove_filter('filter_is_root') + self.layer.add_filter(is_not_wcs_only) + # 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) + self.hub.subscribe(self, AddDataMessage, handler=lambda _: self.set_viewer_id()) self.viewer_icons = dict(self.app.state.viewer_icons) self.layer_icons = dict(self.app.state.layer_icons) @property def user_api(self): - expose = [] + expose = ['layer'] return UserApiWrapper(self, expose=expose) def set_viewer_id(self): @@ -41,6 +56,7 @@ def set_viewer_id(self): try: self.viewer_id = getattr(self._viewer, '_reference_id', '') self.viewer_reference = self._viewer.reference + self.layer.viewer = self._viewer.reference except AttributeError: return diff --git a/jdaviz/configs/default/plugins/data_menu/data_menu.vue b/jdaviz/configs/default/plugins/data_menu/data_menu.vue index d516737ebf..3ccc0180da 100644 --- a/jdaviz/configs/default/plugins/data_menu/data_menu.vue +++ b/jdaviz/configs/default/plugins/data_menu/data_menu.vue @@ -1,23 +1,54 @@ @@ -25,19 +56,20 @@ .viewer-label { display: block; float: right; - background-color: #c3c3c3c3; - width: 24px; + background-color: #c3c3c32c; + width: 30px; overflow: hidden; white-space: nowrap; - /*cursor: pointer;*/ } .viewer-label:last-child { - padding-bottom: 2px; + padding-bottom: 0px; + border-bottom-left-radius: 4px; } .viewer-label:hover { background-color: #e5e5e5; width: auto; - border-bottom-left-radius: 4px; + border-top-left-radius: 4px; + border-bottom-left-radius: 4px; } \ No newline at end of file diff --git a/jdaviz/configs/default/plugins/plot_options/plot_options.py b/jdaviz/configs/default/plugins/plot_options/plot_options.py index 164457d7b1..de50d5e17b 100644 --- a/jdaviz/configs/default/plugins/plot_options/plot_options.py +++ b/jdaviz/configs/default/plugins/plot_options/plot_options.py @@ -10,7 +10,7 @@ from traitlets import Any, Dict, Float, Bool, Int, List, Unicode, observe from glue.core.subset_group import GroupedSubset -from glue.config import colormaps, stretches +from glue.config import stretches as glue_stretches from glue.viewers.scatter.state import ScatterViewerState from glue.viewers.profile.state import ProfileViewerState, ProfileLayerState from glue.viewers.image.state import ImageSubsetLayerState, ImageViewerState @@ -20,47 +20,24 @@ from glue_jupyter.common.toolbar_vuetify import read_icon from jdaviz.core.registries import tray_registry -from jdaviz.core.template_mixin import (PluginTemplateMixin, ViewerSelect, LayerSelect, +from jdaviz.core.template_mixin import (PluginTemplateMixin, ViewerSelectMixin, LayerSelect, PlotOptionsSyncState, Plot, skip_if_no_updates_since_last_active, with_spinner) from jdaviz.core.events import ChangeRefDataMessage from jdaviz.core.user_api import PluginUserApi from jdaviz.core.tools import ICON_DIR from jdaviz.core.custom_traitlets import IntHandleEmpty -from jdaviz.utils import is_not_wcs_only +# by importing from utils, glue_colormaps will include the custom Random colormap +from jdaviz.utils import is_not_wcs_only, cmap_samples, glue_colormaps from scipy.interpolate import PchipInterpolator -from photutils.utils import make_random_cmap __all__ = ['PlotOptions'] RANDOM_SUBSET_SIZE = 10_000 -def _register_random_cmap( - cmap_name, - bkg_color=[0, 0, 0], - bkg_alpha=1, - seed=42, - ncolors=10_000 -): - """ - Custom random colormap, useful for rendering image - segmentation maps. The default background for - `label==0` is *transparent*. If the segmentation map - contains more than 10,000 labels, adjust the `ncolors` - kwarg to ensure uniqueness. - """ - cmap = make_random_cmap(ncolors=ncolors, seed=seed) - cmap.colors[0] = bkg_color + [bkg_alpha] - cmap.name = cmap_name - colormaps.add(cmap_name, cmap) - - -_register_random_cmap('Random', bkg_alpha=1) - - class SplineStretch: """ A class to represent spline stretches. @@ -117,8 +94,8 @@ def update_knots(self, x, y): # Add the spline stretch to the glue stretch registry if not registered -if "spline" not in stretches: - stretches.add("spline", SplineStretch, display="Spline") +if "spline" not in glue_stretches: + glue_stretches.add("spline", SplineStretch, display="Spline") def _round_step(step): @@ -132,7 +109,7 @@ def _round_step(step): @tray_registry('g-plot-options', label="Plot Options") -class PlotOptions(PluginTemplateMixin): +class PlotOptions(PluginTemplateMixin, ViewerSelectMixin): """ The Plot Options Plugin gives access to per-viewer and per-layer options and enables setting across multiple viewers/layers simultaneously. @@ -215,9 +192,6 @@ class PlotOptions(PluginTemplateMixin): # read-only display units display_units = Dict().tag(sync=True) - viewer_multiselect = Bool(False).tag(sync=True) - viewer_items = List().tag(sync=True) - viewer_selected = Any().tag(sync=True) # Any needed for multiselect viewer_limits = Dict().tag(sync=True) layer_multiselect = Bool(False).tag(sync=True) @@ -399,14 +373,13 @@ class PlotOptions(PluginTemplateMixin): show_viewer_labels = Bool(True).tag(sync=True) - cmap_samples = Dict().tag(sync=True) + cmap_samples = Dict(cmap_samples).tag(sync=True) swatches_palette = List().tag(sync=True) apply_RGB_presets_spinner = Bool(False).tag(sync=True) stretch_hist_spinner = Bool(False).tag(sync=True) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.viewer = ViewerSelect(self, 'viewer_items', 'viewer_selected', 'viewer_multiselect') self.layer = LayerSelect(self, 'layer_items', 'layer_selected', 'viewer_selected', 'layer_multiselect') @@ -666,13 +639,6 @@ def state_attr_for_line_visible(state): self.hub.subscribe(self, ChangeRefDataMessage, handler=self._on_refdata_change) - # give UI access to sampled version of the available colormap choices - def hex_for_cmap(cmap): - N = 50 - cm_sampled = cmap.resampled(N) - return [matplotlib.colors.to_hex(cm_sampled(i)) for i in range(N)] - self.cmap_samples = {cmap[1].name: hex_for_cmap(cmap[1]) for cmap in colormaps.members} - @property def user_api(self): expose = ['multiselect', 'viewer', 'viewer_multiselect', 'layer', 'layer_multiselect', @@ -1085,7 +1051,7 @@ def _update_stretch_curve(self, msg=None): data = stretch(data, out=data) if color_mode == 'Colormaps': - cmap = colormaps[self.image_colormap.text] + cmap = glue_colormaps[self.image_colormap.text] if hasattr(cmap, "get_bad"): bad_color = cmap.get_bad().tolist()[:3] layer_cmap = cmap.with_extremes(bad=bad_color + [self.image_opacity_value]) diff --git a/jdaviz/configs/default/plugins/plot_options/plot_options.vue b/jdaviz/configs/default/plugins/plot_options/plot_options.vue index 0eddb38884..2078a28dae 100644 --- a/jdaviz/configs/default/plugins/plot_options/plot_options.vue +++ b/jdaviz/configs/default/plugins/plot_options/plot_options.vue @@ -900,7 +900,6 @@ module.exports = { } } style += ')' - console.log(style) return style } }, diff --git a/jdaviz/configs/imviz/tests/test_astrowidgets_api.py b/jdaviz/configs/imviz/tests/test_astrowidgets_api.py index d41c24f3b5..aea45c16e8 100644 --- a/jdaviz/configs/imviz/tests/test_astrowidgets_api.py +++ b/jdaviz/configs/imviz/tests/test_astrowidgets_api.py @@ -182,9 +182,10 @@ def test_colormap_options(self): 'Gray', 'Viridis', 'Plasma', 'Inferno', 'Magma', 'Purple-Blue', 'Yellow-Green-Blue', 'Yellow-Orange-Red', 'Red-Purple', 'Blue-Green', 'Hot', 'Red-Blue', 'Red-Yellow-Blue', 'Purple-Orange', 'Purple-Green', - 'Random', 'Rainbow', 'Seismic', + 'Rainbow', 'Seismic', 'Reversed: Gray', 'Reversed: Viridis', 'Reversed: Plasma', 'Reversed: Inferno', - 'Reversed: Magma', 'Reversed: Hot', 'Reversed: Rainbow'] + 'Reversed: Magma', 'Reversed: Hot', 'Reversed: Rainbow', + 'Random'] def test_invalid_colormap(self): with pytest.raises(ValueError, match='Invalid colormap'): diff --git a/jdaviz/core/template_mixin.py b/jdaviz/core/template_mixin.py index a3db290531..c97c3a8866 100644 --- a/jdaviz/core/template_mixin.py +++ b/jdaviz/core/template_mixin.py @@ -774,7 +774,7 @@ def __init__(self, *args, **kwargs): def __repr__(self): if hasattr(self, 'multiselect'): - return f"" # noqa + return f"" # noqa return f"" def __eq__(self, other): @@ -1531,14 +1531,18 @@ def not_spatial_subset_in_profile_viewer(lyr): def _layer_to_dict(self, layer_label): is_subset = None + subset_type = None colors = [] visibilities = [] + linewidths = [] for viewer in self.viewer_objs: for layer in viewer.layers: if layer.layer.label == layer_label and is_not_wcs_only(layer.layer): if is_subset is None: is_subset = ((hasattr(layer, 'state') and hasattr(layer.state, 'subset_state')) or # noqa (hasattr(layer, 'layer') and hasattr(layer.layer, 'subset_state'))) # noqa + if is_subset: + subset_type = get_subset_type(layer.layer) if (getattr(viewer.state, 'color_mode', None) == 'Colormaps' and hasattr(layer.state, 'cmap')): @@ -1548,11 +1552,14 @@ def _layer_to_dict(self, layer_label): visibilities.append(getattr(layer.state, 'bitmap_visible', True) and layer.visible) + linewidths.append(getattr(layer.state, 'linewidth', 0)) return {"label": layer_label, "is_subset": is_subset, + "subset_type": subset_type, "icon": self.app.state.layer_icons.get(layer_label), "visible": visibilities[0] if len(list(set(visibilities))) == 1 else 'mixed', + "linewidth": linewidths[0] if len(list(set(linewidths))) == 1 else 'mixed', "colors": np.unique(colors).tolist()} def _on_viewer_selected_changed(self, msg=None): @@ -1619,8 +1626,10 @@ def _on_data_added(self, msg=None): if msg is None or not hasattr(msg, 'data') or msg.data is None: return new_data_label = msg.data.label - viewer = self.viewer if isinstance(self.viewer, list) else [self.viewer] - for current_viewer in viewer: + viewers = self.viewer if isinstance(self.viewer, list) else [self.viewer] + for current_viewer in viewers: + if not len(current_viewer): + continue for layer in self._get_viewer(current_viewer).state.layers: if layer.layer.label == new_data_label and not hasattr(layer.layer, 'subset_state'): if is_wcs_only(layer.layer): @@ -3304,7 +3313,7 @@ class ViewerSelectMixin(VuetifyTemplate, HubListener): """ viewer_items = List().tag(sync=True) - viewer_selected = Any().tag(sync=True) + viewer_selected = Any().tag(sync=True) # Any needed for multiselect viewer_multiselect = Bool(False).tag(sync=True) def __init__(self, *args, **kwargs): @@ -4362,7 +4371,7 @@ def _on_glue_layer_visible_changed(self, value): self._update_mixed_state() def _on_glue_value_changed(self, value): - if self._glue_name == 'color_mode': + if self._glue_name in ('color_mode', 'linewidth'): # then we need to force updates to the layer-icon colors # NOTE: this will only trigger when the change to color_mode was handled # through this plugin. Manual changes to the glue state for viewers not diff --git a/jdaviz/utils.py b/jdaviz/utils.py index 9d04a9a752..d15a113674 100644 --- a/jdaviz/utils.py +++ b/jdaviz/utils.py @@ -14,8 +14,12 @@ from astropy.units import Quantity from astropy import units as u from astroquery.mast import Observations, conf +from matplotlib import colors as mpl_colors +import matplotlib.cm as cm +from photutils.utils import make_random_cmap from glue.config import settings +from glue.config import colormaps as glue_colormaps from glue.core import BaseData from glue.core.exceptions import IncompatibleAttribute from glue.core.subset import SubsetState, RangeSubsetState, RoiSubsetState @@ -30,7 +34,7 @@ 'download_uri_to_path', 'flux_conversion', 'spectral_axis_conversion', 'layer_is_2d', 'layer_is_2d_or_3d', 'layer_is_image_data', 'layer_is_wcs_only', 'get_wcs_only_layer_labels', 'get_top_layer_index', 'get_reference_image_data', - 'standardize_roman_metadata'] + 'standardize_roman_metadata', 'cmap_samples', 'glue_colormaps'] NUMPY_LT_2_0 = not minversion("numpy", "2.0.dev") @@ -929,3 +933,52 @@ def get_reference_image_data(app, viewer_id=None): return refdata, iref return None, -1 + + +# Add new and inverse colormaps to Glue global state. Also see ColormapRegistry in +# https://github.com/glue-viz/glue/blob/main/glue/config.py +new_cms = (['Rainbow', cm.rainbow], + ['Seismic', cm.seismic], + ['Reversed: Gray', cm.gray_r], + ['Reversed: Viridis', cm.viridis_r], + ['Reversed: Plasma', cm.plasma_r], + ['Reversed: Inferno', cm.inferno_r], + ['Reversed: Magma', cm.magma_r], + ['Reversed: Hot', cm.hot_r], + ['Reversed: Rainbow', cm.rainbow_r]) +for cur_cm in new_cms: + if cur_cm not in glue_colormaps.members: + glue_colormaps.add(*cur_cm) + + +def _register_random_cmap( + cmap_name, + bkg_color=[0, 0, 0], + bkg_alpha=1, + seed=42, + ncolors=10_000 +): + """ + Custom random colormap, useful for rendering image + segmentation maps. The default background for + `label==0` is *transparent*. If the segmentation map + contains more than 10,000 labels, adjust the `ncolors` + kwarg to ensure uniqueness. + """ + cmap = make_random_cmap(ncolors=ncolors, seed=seed) + cmap.colors[0] = bkg_color + [bkg_alpha] + cmap.name = cmap_name + glue_colormaps.add(cmap_name, cmap) + + +_register_random_cmap('Random', bkg_alpha=1) + + +# give UI access to sampled version of the available colormap choices +def _hex_for_cmap(cmap): + N = 50 + cm_sampled = cmap.resampled(N) + return [mpl_colors.to_hex(cm_sampled(i)) for i in range(N)] + + +cmap_samples = {cmap[1].name: _hex_for_cmap(cmap[1]) for cmap in glue_colormaps.members}