import math
import os
import warnings

import matplotlib
import numpy as np
from functools import cached_property
from echo import delay_callback
from astropy.visualization import ManualInterval, ContrastBiasStretch
from glue.core.subset_group import GroupedSubset
from glue.config import stretches as glue_stretches
from glue.viewers.histogram.state import HistogramViewerState
from glue.viewers.histogram.state import HistogramLayerState as BqplotHistogramLayerState
from glue.viewers.scatter.state import ScatterViewerState
from glue.viewers.profile.state import ProfileViewerState, ProfileLayerState
from glue.viewers.image.state import ImageSubsetLayerState, ImageViewerState
from glue.viewers.scatter.state import ScatterLayerState as BqplotScatterLayerState
from glue.viewers.image.composite_array import COLOR_CONVERTER
from glue_jupyter.bqplot.image.state import BqplotImageLayerState
from glue_jupyter.common.toolbar_vuetify import read_icon
from scipy.interpolate import PchipInterpolator
from traitlets import Any, Dict, Float, Bool, Int, List, Unicode, observe

from jdaviz.core.registries import tray_registry
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, ViewerAddedMessage
from jdaviz.core.user_api import PluginUserApi
from jdaviz.core.tools import ICON_DIR
from jdaviz.core.custom_traitlets import IntHandleEmpty
from jdaviz.core.sonified_layers import SonifiedLayerState
# by importing from utils, glue_colormaps will include the custom Random colormap
from jdaviz.utils import is_not_wcs_only, cmap_samples, glue_colormaps

__all__ = ['PlotOptions']

RANDOM_SUBSET_SIZE = 10_000


class SplineStretch:
    """
    A class to represent spline stretches.

    Attributes
    ----------
    k : int
        Degree of the smoothing spline. Default is 3.
    bc_type : str or None
        Boundary condition type. Default is None.
    t : array-like or None
        Array of knot positions. Default is None.
    x : array-like
        The x-coordinates of the data points.
    y : array-like
        The y-coordinates of the data points.
    spline : object
        Interpolating spline.

    Raises
    ------
    ValueError
        If `x` and `y` have different lengths.
    """

    def __init__(self):
        # Default x, y values(0-1) range chosen for a typical initial spline shape.
        # Can be modified if required.
        self._x = np.array([0, 0.1, 0.2, 0.7, 1])
        self._y = np.array([0, 0.05, 0.3, 0.9, 1])
        self.update_knots(self._x, self._y)

    @property
    def knots(self):
        return (self._x, self._y)

    @knots.setter
    def knots(self, value):
        x, y = value
        if len(x) != len(y):
            # Silently return
            return
        self.update_knots(x, y)

    def __call__(self, values, out=None, clip=False):
        # For our uses, we can ignore `out` and `clip`, but those would need
        # to be implemented before contributing this class upstream.
        return self.spline(values)

    def update_knots(self, x, y):
        self._x = x
        self._y = y
        self.spline = PchipInterpolator(self._x, self._y)


# Add the spline stretch to the glue stretch registry if not registered
if "spline" not in glue_stretches:
    glue_stretches.add("spline", SplineStretch, display="Spline")


def _round_step(step):
    # round the step for a float input
    if step <= 0:
        return 1e-6, 6
    decimals = -int(np.log10(abs(step))) + 1 if step != 0 else 6
    if decimals < 0:
        decimals = 0
    return float(np.round(step, decimals)), decimals


@tray_registry('g-plot-options', label="Plot Options",
               category='core', sidebar='settings', subtab=0)
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.

    Only the following attributes and methods are available through the
    :ref:`public plugin API <plugin-apis>`:

    * :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`
    * ``viewer`` (:class:`~jdaviz.core.template_mixin.ViewerSelect`):
    * ``viewer_multiselect``
    * ``layer`` (:class:`~jdaviz.core.template_mixin.LayerSelect`):
    * ``layer_multiselect``
    * :meth:`select_all`
    * ``subset_visible`` (:class:`~jdaviz.core.template_mixin.PlotOptionsSyncState`):
      whether a subset should be visible.
    * ``subset_color`` (:class:`~jdaviz.core.template_mixin.PlotOptionsSyncState`):
      not exposed for Specviz
    * ``subset_opacity`` (:class:`~jdaviz.core.template_mixin.PlotOptionsSyncState`):
      not exposed for Specviz
    * ``axes_visible`` (:class:`~jdaviz.core.template_mixin.PlotOptionsSyncState`):
      not exposed for Imviz
    * ``line_visible`` (:class:`~jdaviz.core.template_mixin.PlotOptionsSyncState`):
      not exposed for Imviz
    * ``line_color`` (:class:`~jdaviz.core.template_mixin.PlotOptionsSyncState`):
      not exposed for Imviz
    * ``line_width`` (:class:`~jdaviz.core.template_mixin.PlotOptionsSyncState`):
      not exposed for Imviz
    * ``line_opacity`` (:class:`~jdaviz.core.template_mixin.PlotOptionsSyncState`):
      not exposed for Imviz
    * ``line_as_steps`` (:class:`~jdaviz.core.template_mixin.PlotOptionsSyncState`):
      not exposed for Imviz
    * ``uncertainty_visible`` (:class:`~jdaviz.core.template_mixin.PlotOptionsSyncState`):
      not exposed for Imviz
    * ``stretch_function`` (:class:`~jdaviz.core.template_mixin.PlotOptionsSyncState`):
      not exposed for Specviz
    * ``stretch_preset`` (:class:`~jdaviz.core.template_mixin.PlotOptionsSyncState`):
      not exposed for Specviz
    * ``stretch_vmin`` (:class:`~jdaviz.core.template_mixin.PlotOptionsSyncState`):
      not exposed for Specviz
    * ``stretch_vmax`` (:class:`~jdaviz.core.template_mixin.PlotOptionsSyncState`):
      not exposed for Specviz
    * ``stretch_hist_zoom_limits`` : whether to show the histogram for the current zoom
      limits instead of all data within the layer; not exposed for Specviz.
    * ``stretch_hist_nbins`` : number of bins to use in creating the histogram; not exposed
      for Specviz.
    * ``stretch_curve_visible`` : bool
      whether the stretch histogram's colormap "curve" is visible; not exposed for Specviz.
    * ``image_visible`` (:class:`~jdaviz.core.template_mixin.PlotOptionsSyncState`):
      whether the image bitmap is visible; not exposed for Specviz.
    * ``image_color_mode`` (:class:`~jdaviz.core.template_mixin.PlotOptionsSyncState`):
      not exposed for Specviz
    * ``image_color`` (:class:`~jdaviz.core.template_mixin.PlotOptionsSyncState`):
      not exposed for Specviz.  This only applies when ``image_color_mode`` is "Color".
    * ``image_colormap`` (:class:`~jdaviz.core.template_mixin.PlotOptionsSyncState`):
      not exposed for Specviz. This only applies when ``image_color_mode`` is "Colormap".
    * ``image_opacity`` (:class:`~jdaviz.core.template_mixin.PlotOptionsSyncState`):
      not exposed for Specviz. Valid values are between 0 and 1, inclusive. Default is 1.
    * ``image_contrast`` (:class:`~jdaviz.core.template_mixin.PlotOptionsSyncState`):
      not exposed for Specviz. Valid values are between 0 and 4, inclusive. Default is 1.
    * ``image_bias`` (:class:`~jdaviz.core.template_mixin.PlotOptionsSyncState`):
      not exposed for Specviz. Valid values are between 0 and 1, inclusive. Default is 0.5.
    * ``contour_visible`` (:class:`~jdaviz.core.template_mixin.PlotOptionsSyncState`):
      whether the contour is visible; not exposed for Specviz
    * ``contour_mode`` (:class:`~jdaviz.core.template_mixin.PlotOptionsSyncState`):
      not exposed for Specviz
    * ``contour_min`` (:class:`~jdaviz.core.template_mixin.PlotOptionsSyncState`):
      not exposed for Specviz. This only applies when ``contour_mode`` is "Linear".
    * ``contour_max`` (:class:`~jdaviz.core.template_mixin.PlotOptionsSyncState`):
      not exposed for Specviz. This only applies when ``contour_mode`` is "Linear".
    * ``contour_nlevels`` (:class:`~jdaviz.core.template_mixin.PlotOptionsSyncState`):
      not exposed for Specviz. This only applies when ``contour_mode`` is "Linear".
    * ``contour_custom_levels`` (:class:`~jdaviz.core.template_mixin.PlotOptionsSyncState`):
      not exposed for Specviz. This only applies when ``contour_mode`` is "Custom".
    * ``volume_level`` (:class:`~jdaviz.core.template_mixin.PlotOptionsSyncState`):
      not exposed for Specviz. Set the volume for the selected sonified layer.
    * ``sonified_audible`` (:class:`~jdaviz.core.template_mixin.PlotOptionsSyncState`):
      not exposed for Specviz. Set if the selected sonified layer will output audio.
    * ``xatt`` (:class:`~jdaviz.core.template_mixin.PlotOptionsSyncState`):
      only exposed for deconfigged. Controls the x-axis attribute for scatter
      and histogram plots.
    * ``yatt`` (:class:`~jdaviz.core.template_mixin.PlotOptionsSyncState`):
      only exposed for deconfigged. Controls the y-axis attribute for scatter plots.
    * ``hist_visible`` (:class:`~jdaviz.core.template_mixin.PlotOptionsSyncState`):
      only exposed for deconfigged. Whether histogram is visible.
    * ``hist_color`` (:class:`~jdaviz.core.template_mixin.PlotOptionsSyncState`):
      only exposed for deconfigged. Controls histogram color.
    * ``hist_opacity`` (:class:`~jdaviz.core.template_mixin.PlotOptionsSyncState`):
      only exposed for deconfigged. Controls histogram opacity.
    * ``hist_xlog`` (:class:`~jdaviz.core.template_mixin.PlotOptionsSyncState`):
      only exposed for deconfigged. Controls x-axis log scaling for histograms.
    * ``hist_ylog`` (:class:`~jdaviz.core.template_mixin.PlotOptionsSyncState`):
      only exposed for deconfigged. Controls y-axis log scaling for histograms.
    * ``hist_n_bin`` (:class:`~jdaviz.core.template_mixin.PlotOptionsSyncState`):
      only exposed for deconfigged. Controls number of bins for histograms.
    * ``hist_x_min`` (:class:`~jdaviz.core.template_mixin.PlotOptionsSyncState`):
      only exposed for deconfigged. Controls x-axis minimum value for histograms.
    * ``hist_x_max`` (:class:`~jdaviz.core.template_mixin.PlotOptionsSyncState`):
      only exposed for deconfigged. Controls x-axis maximum value for histograms.
    * ``hist_cumulative`` (:class:`~jdaviz.core.template_mixin.PlotOptionsSyncState`):
      only exposed for deconfigged. Controls cumulative histogram display.
    * ``hist_normalize`` (:class:`~jdaviz.core.template_mixin.PlotOptionsSyncState`):
      only exposed for deconfigged. Controls histogram normalization.
    * ``hist_update_bins_on_reset_limits``
      (:class:`~jdaviz.core.template_mixin.PlotOptionsSyncState`):
      only exposed for deconfigged. Controls automatic bin updates when limits are reset.
    * ``marker_visible`` (:class:`~jdaviz.core.template_mixin.PlotOptionsSyncState`):
      only exposed for Imviz and deconfigged. Whether markers are visible.
    * ``marker_fill`` (:class:`~jdaviz.core.template_mixin.PlotOptionsSyncState`):
      only exposed for Imviz and deconfigged. Controls marker fill.
    * ``marker_opacity`` (:class:`~jdaviz.core.template_mixin.PlotOptionsSyncState`):
      only exposed for Imviz and deconfigged. Controls marker opacity.
    * ``marker_size`` (:class:`~jdaviz.core.template_mixin.PlotOptionsSyncState`):
      only exposed for Imviz and deconfigged. Controls marker size.
    * ``marker_size_scale`` (:class:`~jdaviz.core.template_mixin.PlotOptionsSyncState`):
      only exposed for Imviz and deconfigged. Controls marker size scaling.
    * ``marker_color_mode`` (:class:`~jdaviz.core.template_mixin.PlotOptionsSyncState`):
      only exposed for Imviz and deconfigged. Controls marker color mode.
    * ``marker_color`` (:class:`~jdaviz.core.template_mixin.PlotOptionsSyncState`):
      only exposed for Imviz and deconfigged. Controls marker color.
    * ``marker_color_col`` (:class:`~jdaviz.core.template_mixin.PlotOptionsSyncState`):
      only exposed for Imviz and deconfigged. Controls marker color column.
    * ``marker_colormap`` (:class:`~jdaviz.core.template_mixin.PlotOptionsSyncState`):
      only exposed for Imviz and deconfigged. Controls marker colormap.
    * ``marker_colormap_vmin`` (:class:`~jdaviz.core.template_mixin.PlotOptionsSyncState`):
      only exposed for Imviz and deconfigged. Controls marker colormap minimum value.
    * ``marker_colormap_vmax`` (:class:`~jdaviz.core.template_mixin.PlotOptionsSyncState`):
      only exposed for Imviz and deconfigged. Controls marker colormap maximum value.
    """
    template_file = __file__, "plot_options.vue"
    uses_active_status = Bool(True).tag(sync=True)

    # read-only display units
    display_units = Dict().tag(sync=True)

    viewer_limits = Dict().tag(sync=True)

    layer_multiselect = Bool(False).tag(sync=True)
    layer_items = List().tag(sync=True)
    layer_selected = Any().tag(sync=True)  # Any needed for multiselect

    xatt_value = Unicode().tag(sync=True)
    xatt_sync = Dict().tag(sync=True)

    yatt_value = Unicode().tag(sync=True)
    yatt_sync = Dict().tag(sync=True)

    # profile/line viewer/layer options:
    line_visible_value = Bool().tag(sync=True)
    line_visible_sync = Dict().tag(sync=True)

    line_color_value = Any().tag(sync=True)
    line_color_sync = Dict().tag(sync=True)

    line_width_value = Int().tag(sync=True)
    line_width_sync = Dict().tag(sync=True)

    line_opacity_value = Float().tag(sync=True)
    line_opacity_sync = Dict().tag(sync=True)

    line_as_steps_value = Bool().tag(sync=True)
    line_as_steps_sync = Dict().tag(sync=True)

    uncertainty_visible_value = Int().tag(sync=True)
    uncertainty_visible_sync = Dict().tag(sync=True)

    x_min_value = Float().tag(sync=True)
    x_min_sync = Dict().tag(sync=True)

    x_max_value = Float().tag(sync=True)
    x_max_sync = Dict().tag(sync=True)

    y_min_value = Float().tag(sync=True)
    y_min_sync = Dict().tag(sync=True)

    y_max_value = Float().tag(sync=True)
    y_max_sync = Dict().tag(sync=True)

    x_bound_step = Float(0.1).tag(sync=True)  # dynamic based on maximum value
    y_bound_step = Float(0.1).tag(sync=True)  # dynamic based on maximum value

    zoom_center_x_value = Float().tag(sync=True)
    zoom_center_x_sync = Dict().tag(sync=True)

    zoom_center_y_value = Float().tag(sync=True)
    zoom_center_y_sync = Dict().tag(sync=True)

    zoom_radius_value = Float().tag(sync=True)
    zoom_radius_sync = Dict().tag(sync=True)

    zoom_step = Float(1).tag(sync=True)

    # scatter/marker options
    marker_visible_value = Bool().tag(sync=True)
    marker_visible_sync = Dict().tag(sync=True)

    marker_fill_value = Bool().tag(sync=True)
    marker_fill_sync = Dict().tag(sync=True)

    marker_opacity_value = Float().tag(sync=True)
    marker_opacity_sync = Dict().tag(sync=True)

    marker_size_mode_value = Unicode().tag(sync=True)
    marker_size_mode_sync = Dict().tag(sync=True)

    marker_size_value = Float().tag(sync=True)
    marker_size_sync = Dict().tag(sync=True)

    marker_size_scale_value = Float().tag(sync=True)
    marker_size_scale_sync = Dict().tag(sync=True)

    marker_size_col_value = Unicode().tag(sync=True)
    marker_size_col_sync = Dict().tag(sync=True)

    marker_size_vmin_value = Float().tag(sync=True)
    marker_size_vmin_sync = Dict().tag(sync=True)

    marker_size_vmax_value = Float().tag(sync=True)
    marker_size_vmax_sync = Dict().tag(sync=True)

    marker_color_mode_value = Unicode().tag(sync=True)
    marker_color_mode_sync = Dict().tag(sync=True)

    marker_color_value = Any().tag(sync=True)
    marker_color_sync = Dict().tag(sync=True)

    marker_color_col_value = Unicode().tag(sync=True)
    marker_color_col_sync = Dict().tag(sync=True)

    marker_colormap_value = Unicode().tag(sync=True)
    marker_colormap_sync = Dict().tag(sync=True)

    marker_colormap_vmin_value = Float().tag(sync=True)
    marker_colormap_vmin_sync = Dict().tag(sync=True)

    marker_colormap_vmax_value = Float().tag(sync=True)
    marker_colormap_vmax_sync = Dict().tag(sync=True)

    # image viewer/layer options
    active_layer = Unicode().tag(sync=True)
    stretch_function_value = Unicode().tag(sync=True)
    stretch_function_sync = Dict().tag(sync=True)

    stretch_preset_value = Any().tag(sync=True)  # glue will pass either a float or string
    stretch_preset_sync = Dict().tag(sync=True)

    stretch_vstep = Float(0.1).tag(sync=True)  # dynamic based on full range from image

    stretch_vmin_value = Float().tag(sync=True)
    stretch_vmin_sync = Dict().tag(sync=True)

    stretch_vmax_value = Float().tag(sync=True)
    stretch_vmax_sync = Dict().tag(sync=True)

    stretch_params_value = Dict().tag(sync=True)
    stretch_params_sync = Dict().tag(sync=True)

    stretch_hist_sync = Dict().tag(sync=True)
    stretch_hist_zoom_limits = Bool().tag(sync=True)
    stretch_hist_nbins = IntHandleEmpty(25).tag(sync=True)
    stretch_histogram_widget = Unicode().tag(sync=True)

    stretch_curve_visible = Bool(True).tag(sync=True)

    subset_visible_value = Bool().tag(sync=True)
    subset_visible_sync = Dict().tag(sync=True)

    subset_color_value = Unicode().tag(sync=True)
    subset_color_sync = Dict().tag(sync=True)

    subset_opacity_value = Float().tag(sync=True)
    subset_opacity_sync = Dict().tag(sync=True)

    image_visible_value = Bool().tag(sync=True)
    image_visible_sync = Dict().tag(sync=True)

    image_color_mode_value = Unicode().tag(sync=True)
    image_color_mode_sync = Dict().tag(sync=True)

    image_color_value = Any().tag(sync=True)
    image_color_sync = Dict().tag(sync=True)

    image_colormap_value = Unicode().tag(sync=True)
    image_colormap_sync = Dict().tag(sync=True)

    image_opacity_value = Float().tag(sync=True)
    image_opacity_sync = Dict().tag(sync=True)

    image_contrast_value = Float().tag(sync=True)
    image_contrast_sync = Dict().tag(sync=True)

    image_bias_value = Float().tag(sync=True)
    image_bias_sync = Dict().tag(sync=True)

    contour_spinner = Bool().tag(sync=True)

    contour_visible_value = Bool().tag(sync=True)
    contour_visible_sync = Dict().tag(sync=True)

    contour_mode_value = Unicode().tag(sync=True)
    contour_mode_sync = Dict().tag(sync=True)

    contour_min_value = Float().tag(sync=True)
    contour_min_sync = Dict().tag(sync=True)

    contour_max_value = Float().tag(sync=True)
    contour_max_sync = Dict().tag(sync=True)

    contour_nlevels_value = Int().tag(sync=True)
    contour_nlevels_sync = Dict().tag(sync=True)

    contour_custom_levels_value = List().tag(sync=True)
    contour_custom_levels_txt = Unicode().tag(sync=True)   # controlled by vue
    contour_custom_levels_sync = Dict().tag(sync=True)

    axes_visible_value = Bool().tag(sync=True)
    axes_visible_sync = Dict().tag(sync=True)

    icon_radialtocheck = Unicode(read_icon(os.path.join(ICON_DIR, 'radialtocheck.svg'), 'svg+xml')).tag(sync=True)  # noqa
    icon_checktoradial = Unicode(read_icon(os.path.join(ICON_DIR, 'checktoradial.svg'), 'svg+xml')).tag(sync=True)  # noqa

    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)

    volume_value = IntHandleEmpty(50).tag(sync=True)
    volume_sync = Dict().tag(sync=True)

    sonified_audible_value = Bool(False).tag(sync=True)
    sonified_audible_sync = Dict().tag(sync=True)

    hist_visible_value = Bool().tag(sync=True)
    hist_visible_sync = Dict().tag(sync=True)

    hist_opacity_value = Float().tag(sync=True)
    hist_opacity_sync = Dict().tag(sync=True)

    hist_color_value = Any().tag(sync=True)
    hist_color_sync = Dict().tag(sync=True)

    hist_xlog_value = Bool().tag(sync=True)
    hist_xlog_sync = Dict().tag(sync=True)

    hist_ylog_value = Bool().tag(sync=True)
    hist_ylog_sync = Dict().tag(sync=True)

    hist_n_bin_value = Int().tag(sync=True)
    hist_n_bin_sync = Dict().tag(sync=True)

    hist_x_min_value = Float().tag(sync=True)
    hist_x_min_sync = Dict().tag(sync=True)

    hist_x_max_value = Float().tag(sync=True)
    hist_x_max_sync = Dict().tag(sync=True)

    hist_cumulative_value = Bool().tag(sync=True)
    hist_cumulative_sync = Dict().tag(sync=True)

    hist_normalize_value = Bool().tag(sync=True)
    hist_normalize_sync = Dict().tag(sync=True)

    hist_update_bins_on_reset_limits_value = Bool().tag(sync=True)
    hist_update_bins_on_reset_limits_sync = Dict().tag(sync=True)

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        # description displayed under plugin title in tray
        self._plugin_description = 'Set viewer and layer display options.'

        self.layer = LayerSelect(self, 'layer_items', 'layer_selected',
                                 'viewer_selected', 'layer_multiselect')

        self.layer.filters += [is_not_wcs_only, 'has_wcs_if_image_viewer_pixel_linked']

        self.swatches_palette = [
            ['#FF0000', '#AA0000', '#550000'],
            ['#FFD300', '#AAAA00', '#555500'],
            ['#4CFF00', '#00AA00', '#005500'],
            ['#00FF8E', '#00AAAA', '#005555'],
            ['#0089FF', '#5200FF', '#000055']
        ]

        def is_profile(state):
            return isinstance(state, (ProfileViewerState, ProfileLayerState))

        def not_profile(state):
            return not is_profile(state)

        def is_scatter(state):
            return isinstance(state, (ScatterViewerState, BqplotScatterLayerState))

        def is_histogram(state):
            return isinstance(state, (HistogramViewerState, BqplotHistogramLayerState))

        def is_scatter_or_histogram(state):
            return is_scatter(state) or is_histogram(state)

        def supports_line(state):
            return is_profile(state) or is_scatter(state)

        def is_image(state):
            return isinstance(state, BqplotImageLayerState)

        def not_image(state):
            return not is_image(state)

        def not_image_viewer(state):
            return not isinstance(state, ImageViewerState)

        def not_image_or_spatial_subset(state):
            return not is_image(state) and not is_spatial_subset(state)

        def is_spatial_subset(state):
            return isinstance(state, ImageSubsetLayerState) and is_not_wcs_only(state.layer)

        def is_not_subset(state):
            return not is_spatial_subset(state)

        def is_sonified(state):
            return isinstance(state, SonifiedLayerState)

        def line_visible(state):
            # exclude for scatter layers where the marker is shown instead of the line
            return getattr(state, 'line_visible', True)

        def state_attr_for_line_visible(state):
            if is_scatter(state):
                return 'line_visible'
            return 'visible'

        # Profile/line viewer/layer options:
        self.line_visible = PlotOptionsSyncState(self, self.viewer, self.layer, state_attr_for_line_visible,  # noqa
                                                 'line_visible_value', 'line_visible_sync',
                                                 state_filter=supports_line)
        self.line_color = PlotOptionsSyncState(self, self.viewer, self.layer, 'color',
                                               'line_color_value', 'line_color_sync',
                                               state_filter=not_image_or_spatial_subset)
        self.line_width = PlotOptionsSyncState(self, self.viewer, self.layer, 'linewidth',
                                               'line_width_value', 'line_width_sync',
                                               state_filter=supports_line)
        self.line_opacity = PlotOptionsSyncState(self, self.viewer, self.layer, 'alpha',
                                                 'line_opacity_value', 'line_opacity_sync',
                                                 state_filter=supports_line)
        self.line_as_steps = PlotOptionsSyncState(self, self.viewer, self.layer, 'as_steps',
                                                  'line_as_steps_value', 'line_as_steps_sync')
        self.uncertainty_visible = PlotOptionsSyncState(self, self.viewer, self.layer, 'show_uncertainty',  # noqa
                                                        'uncertainty_visible_value', 'uncertainty_visible_sync')  # noqa

        self.xatt = PlotOptionsSyncState(self, self.viewer, self.layer, 'x_att',
                                         'xatt_value', 'xatt_sync',
                                         state_filter=is_scatter_or_histogram)

        self.yatt = PlotOptionsSyncState(self, self.viewer, self.layer, 'y_att',
                                         'yatt_value', 'yatt_sync',
                                         state_filter=is_scatter)

        # Viewer bounds
        self.x_min = PlotOptionsSyncState(self, self.viewer, self.layer, 'x_min',
                                          'x_min_value', 'x_min_sync',
                                          state_filter=not_image_viewer)
        self.x_max = PlotOptionsSyncState(self, self.viewer, self.layer, 'x_max',
                                          'x_max_value', 'x_max_sync',
                                          state_filter=not_image_viewer)
        self.y_min = PlotOptionsSyncState(self, self.viewer, self.layer, 'y_min',
                                          'y_min_value', 'y_min_sync',
                                          state_filter=not_image_viewer)
        self.y_max = PlotOptionsSyncState(self, self.viewer, self.layer, 'y_max',
                                          'y_max_value', 'y_max_sync',
                                          state_filter=not_image_viewer)
        self.zoom_center_x = PlotOptionsSyncState(self, self.viewer, self.layer, 'zoom_center_x',
                                                  'zoom_center_x_value', 'zoom_center_x_sync')
        self.zoom_center_y = PlotOptionsSyncState(self, self.viewer, self.layer, 'zoom_center_y',
                                                  'zoom_center_y_value', 'zoom_center_y_sync')
        self.zoom_radius = PlotOptionsSyncState(self, self.viewer, self.layer, 'zoom_radius',
                                                'zoom_radius_value', 'zoom_radius_sync')

        # Scatter/marker options:
        # NOTE: marker_visible hides the entire layer (including the line)
        self.marker_visible = PlotOptionsSyncState(self, self.viewer, self.layer, 'visible',
                                                   'marker_visible_value', 'marker_visible_sync',
                                                   state_filter=is_scatter)
        self.marker_fill = PlotOptionsSyncState(self, self.viewer, self.layer, 'fill',
                                                'marker_fill_value', 'marker_fill_sync',
                                                state_filter=is_scatter)
        self.marker_opacity = PlotOptionsSyncState(self, self.viewer, self.layer, 'alpha',
                                                   'marker_opacity_value', 'marker_opacity_sync',
                                                   state_filter=is_scatter)
        self.marker_size_mode = PlotOptionsSyncState(self, self.viewer, self.layer, 'size_mode',
                                                     'marker_size_mode_value', 'marker_size_mode_sync',  # noqa
                                                     state_filter=is_scatter)
        self.marker_size = PlotOptionsSyncState(self, self.viewer, self.layer, 'size',
                                                'marker_size_value', 'marker_size_sync',
                                                state_filter=is_scatter)
        self.marker_size_scale = PlotOptionsSyncState(self, self.viewer, self.layer, 'size_scaling',
                                                      'marker_size_scale_value', 'marker_size_scale_sync',  # noqa
                                                      state_filter=is_scatter)
        self.marker_size_col = PlotOptionsSyncState(self, self.viewer, self.layer, 'size_att',
                                                    'marker_size_col_value', 'marker_size_col_sync',
                                                    state_filter=is_scatter)
        self.marker_size_vmin = PlotOptionsSyncState(self, self.viewer, self.layer, 'size_vmin',
                                                     'marker_size_vmin_value', 'marker_size_vmin_sync',  # noqa
                                                     state_filter=is_scatter)
        self.marker_size_vmax = PlotOptionsSyncState(self, self.viewer, self.layer, 'size_vmax',
                                                     'marker_size_vmax_value', 'marker_size_vmax_sync',  # noqa
                                                     state_filter=is_scatter)

        # TODO: remove marker_ prefix if these also apply to the lines?
        self.marker_color_mode = PlotOptionsSyncState(self, self.viewer, self.layer, 'cmap_mode',
                                                      'marker_color_mode_value', 'marker_color_mode_sync',  # noqa
                                                      state_filter=is_scatter)
        self.marker_color = PlotOptionsSyncState(self, self.viewer, self.layer, 'color',
                                                 'marker_color_value', 'marker_color_sync',
                                                 state_filter=is_scatter)
        self.marker_color_col = PlotOptionsSyncState(self, self.viewer, self.layer, 'cmap_att',
                                                     'marker_color_col_value', 'marker_color_col_sync',  # noqa
                                                     state_filter=is_scatter)
        self.marker_colormap = PlotOptionsSyncState(self, self.viewer, self.layer, 'cmap',
                                                    'marker_colormap_value', 'marker_colormap_sync',
                                                    state_filter=is_scatter)
        self.marker_colormap_vmin = PlotOptionsSyncState(self, self.viewer, self.layer, 'cmap_vmin',
                                                         'marker_colormap_vmin_value', 'marker_colormap_vmin_sync',  # noqa
                                                         state_filter=is_scatter)
        self.marker_colormap_vmax = PlotOptionsSyncState(self, self.viewer, self.layer, 'cmap_vmax',
                                                         'marker_colormap_vmax_value', 'marker_colormap_vmax_sync',  # noqa
                                                         state_filter=is_scatter)

        # Image viewer/layer options:
        self.stretch_function = PlotOptionsSyncState(self, self.viewer, self.layer, 'stretch',
                                                     'stretch_function_value', 'stretch_function_sync',  # noqa
                                                     state_filter=is_image)
        # use add_observe to ensure that the glue state syncs with the traitlet choice:
        self.stretch_function.add_observe('stretch_function_value', self._update_stretch_curve)

        self.stretch_preset = PlotOptionsSyncState(self, self.viewer, self.layer, 'percentile',
                                                   'stretch_preset_value', 'stretch_preset_sync',
                                                   state_filter=is_image)
        self.stretch_vmin = PlotOptionsSyncState(self, self.viewer, self.layer, 'v_min',
                                                 'stretch_vmin_value', 'stretch_vmin_sync',
                                                 state_filter=is_image)
        self.stretch_vmax = PlotOptionsSyncState(self, self.viewer, self.layer, 'v_max',
                                                 'stretch_vmax_value', 'stretch_vmax_sync',
                                                 state_filter=is_image)
        self.stretch_params = PlotOptionsSyncState(self, self.viewer, self.layer, 'stretch_parameters',  # noqa
                                                   'stretch_params_value', 'stretch_params_sync',
                                                   state_filter=is_image)

        self.subset_visible = PlotOptionsSyncState(self, self.viewer, self.layer, 'visible',
                                                   'subset_visible_value', 'subset_visible_sync',
                                                   state_filter=is_spatial_subset)
        self.subset_color = PlotOptionsSyncState(self, self.viewer, self.layer, 'color',
                                                 'subset_color_value', 'subset_color_sync',
                                                 state_filter=is_spatial_subset)
        self.subset_opacity = PlotOptionsSyncState(self, self.viewer, self.layer, 'alpha',
                                                   'subset_opacity_value', 'subset_opacity_sync',
                                                   state_filter=is_spatial_subset)
        self.image_visible = PlotOptionsSyncState(self, self.viewer, self.layer, 'bitmap_visible',
                                                  'image_visible_value', 'image_visible_sync',
                                                  state_filter=is_image)
        self.image_color_mode = PlotOptionsSyncState(self, self.viewer, self.layer, 'color_mode',  # noqa
                                                     'image_color_mode_value', 'image_color_mode_sync')  # noqa
        self.image_color = PlotOptionsSyncState(self, self.viewer, self.layer, 'color',
                                                'image_color_value', 'image_color_sync',
                                                state_filter=is_image)
        self.image_colormap = PlotOptionsSyncState(self, self.viewer, self.layer, 'cmap',
                                                   'image_colormap_value', 'image_colormap_sync')
        self.image_opacity = PlotOptionsSyncState(self, self.viewer, self.layer, 'alpha',
                                                  'image_opacity_value', 'image_opacity_sync',
                                                  state_filter=is_image)
        self.image_contrast = PlotOptionsSyncState(self, self.viewer, self.layer, 'contrast',
                                                   'image_contrast_value', 'image_contrast_sync')
        self.image_bias = PlotOptionsSyncState(self, self.viewer, self.layer, 'bias',
                                               'image_bias_value', 'image_bias_sync')

        self.contour_visible = PlotOptionsSyncState(self, self.viewer, self.layer, 'contour_visible',  # noqa
                                                    'contour_visible_value', 'contour_visible_sync',
                                                    spinner='contour_spinner')
        self.contour_mode = PlotOptionsSyncState(self, self.viewer, self.layer, 'level_mode',
                                                 'contour_mode_value', 'contour_mode_sync',
                                                 spinner='contour_spinner')
        self.contour_min = PlotOptionsSyncState(self, self.viewer, self.layer, 'c_min',
                                                'contour_min_value', 'contour_min_sync',
                                                spinner='contour_spinner')
        self.contour_max = PlotOptionsSyncState(self, self.viewer, self.layer, 'c_max',
                                                'contour_max_value', 'contour_max_sync',
                                                spinner='contour_spinner')
        self.contour_nlevels = PlotOptionsSyncState(self, self.viewer, self.layer, 'n_levels',
                                                    'contour_nlevels_value', 'contour_nlevels_sync',
                                                    spinner='contour_spinner')
        self.contour_custom_levels = PlotOptionsSyncState(self, self.viewer, self.layer, 'levels',
                                                          'contour_custom_levels_value', 'contour_custom_levels_sync',   # noqa
                                                          spinner='contour_spinner')

        self.volume_level = PlotOptionsSyncState(self, self.viewer, self.layer, 'volume',
                                                 'volume_value', 'volume_sync',
                                                 state_filter=is_sonified)

        self.sonified_audible = PlotOptionsSyncState(self, self.viewer, self.layer, 'audible',
                                                     'sonified_audible_value',
                                                     'sonified_audible_sync',
                                                     state_filter=is_sonified)

        # Histogram layer options:
        self.hist_visible = PlotOptionsSyncState(self, self.viewer, self.layer, 'visible',
                                                 'hist_visible_value', 'hist_visible_sync',
                                                 state_filter=is_histogram)

        self.hist_opacity = PlotOptionsSyncState(self, self.viewer, self.layer, 'alpha',
                                                 'hist_opacity_value', 'hist_opacity_sync',
                                                 state_filter=is_histogram)

        self.hist_color = PlotOptionsSyncState(self, self.viewer, self.layer, 'color',
                                               'hist_color_value', 'hist_color_sync',
                                               state_filter=is_histogram)

        # Axes options:
        # axes_visible hidden for imviz in plot_options.vue
        self.axes_visible = PlotOptionsSyncState(self, self.viewer, self.layer, 'show_axes',
                                                 'axes_visible_value', 'axes_visible_sync',
                                                 state_filter=not_profile)
        sv = self.spectrum_viewer
        if sv is not None:
            sv.state.add_callback('x_display_unit',
                                  self._on_global_display_unit_changed)
            sv.state.add_callback('y_display_unit',
                                  self._on_global_display_unit_changed)

        # Histogram axes options:
        self.hist_xlog = PlotOptionsSyncState(self, self.viewer, self.layer, 'x_log',
                                              'hist_xlog_value', 'hist_xlog_sync',
                                              state_filter=is_histogram)
        self.hist_ylog = PlotOptionsSyncState(self, self.viewer, self.layer, 'y_log',
                                              'hist_ylog_value', 'hist_ylog_sync',
                                              state_filter=is_histogram)
        self.hist_n_bin = PlotOptionsSyncState(self, self.viewer, self.layer, 'hist_n_bin',
                                               'hist_n_bin_value', 'hist_n_bin_sync',
                                               state_filter=is_histogram)
        self.hist_x_min = PlotOptionsSyncState(self, self.viewer, self.layer, 'hist_x_min',
                                               'hist_x_min_value', 'hist_x_min_sync',
                                               state_filter=is_histogram)
        self.hist_x_max = PlotOptionsSyncState(self, self.viewer, self.layer, 'hist_x_max',
                                               'hist_x_max_value', 'hist_x_max_sync',
                                               state_filter=is_histogram)
        self.hist_cumulative = PlotOptionsSyncState(self, self.viewer, self.layer, 'cumulative',
                                                    'hist_cumulative_value', 'hist_cumulative_sync',
                                                    state_filter=is_histogram)
        self.hist_normalize = PlotOptionsSyncState(self, self.viewer, self.layer, 'normalize',
                                                   'hist_normalize_value', 'hist_normalize_sync',
                                                   state_filter=is_histogram)
        self.hist_update_bins_on_reset_limits = PlotOptionsSyncState(self, self.viewer, self.layer, 'update_bins_on_reset_limits',  # noqa
                                                                     'hist_update_bins_on_reset_limits_value',  # noqa
                                                                     'hist_update_bins_on_reset_limits_sync',  # noqa
                                                                     state_filter=is_histogram)

        # Add layer callback to image viewers to track active layer
        for viewer in self.app._viewer_store.values():
            viewer.state.add_callback('layers', lambda msg: self._layers_changed(viewer=viewer))

        self.hub.subscribe(self, ViewerAddedMessage, handler=self._on_viewer_added)

        self.hub.subscribe(self, ChangeRefDataMessage,
                           handler=self._on_refdata_change)

        if self.config == 'deconfigged':
            self.observe_traitlets_for_relevancy(traitlets_to_observe=['viewer_items'])

    @property
    def user_api(self):
        expose = ['multiselect', 'viewer', 'viewer_multiselect', 'layer', 'layer_multiselect',
                  'select_all', 'subset_visible', 'reset_viewer_bounds']
        if self.config == "cubeviz":
            expose += ['uncertainty_visible', 'volume_level', 'sonified_audible']
        if self.config != "imviz":
            expose += ['x_min', 'x_max', 'y_min', 'y_max',
                       'axes_visible', 'line_visible', 'line_color', 'line_width', 'line_opacity',
                       'line_as_steps', 'uncertainty_visible']
        if self.config != "specviz":
            expose += ['zoom_center_x', 'zoom_center_y', 'zoom_radius',
                       'subset_color', 'subset_opacity',
                       'stretch_function', 'stretch_preset', 'stretch_vmin', 'stretch_vmax',
                       'stretch_hist_zoom_limits', 'stretch_hist_nbins',
                       'image_visible', 'image_color_mode',
                       'image_color', 'image_colormap', 'image_opacity',
                       'image_contrast', 'image_bias',
                       'contour_visible', 'contour_mode',
                       'contour_min', 'contour_max', 'contour_nlevels', 'contour_custom_levels',
                       'stretch_curve_visible', 'apply_RGB_presets']
        if self.config == 'deconfigged':
            expose += ['xatt', 'yatt', 'hist_visible', 'hist_color', 'hist_opacity',
                       'hist_xlog', 'hist_ylog', 'hist_n_bin', 'hist_x_min', 'hist_x_max',
                       'hist_cumulative', 'hist_normalize', 'hist_update_bins_on_reset_limits']
        if self.config in ('imviz', 'deconfigged'):
            expose += ['marker_visible', 'marker_fill', 'marker_opacity',
                       'marker_size', 'marker_size_scale',
                       'marker_color_mode', 'marker_color',
                       'marker_color_col', 'marker_colormap',
                       'marker_colormap_vmin', 'marker_colormap_vmax']

        return PluginUserApi(self, expose)

    @property
    def multiselect(self):
        warnings.warn(
            "multiselect has been replaced by separate viewer_multiselect and "
            "layer_multiselect and will be removed in the future. "
            "This currently evaluates viewer_multiselect or layer_multiselect", DeprecationWarning)
        return self.viewer_multiselect or self.layer_multiselect

    @multiselect.setter
    def multiselect(self, value):
        warnings.warn(
            "multiselect has been replaced by separate viewer_multiselect and "
            "layer_multiselect and will be removed in the future. "
            "This currently sets viewer_multiselect and layer_multiselect", DeprecationWarning)
        self.viewer_multiselect = value
        self.layer_multiselect = value

    @cached_property
    def stretch_histogram(self):
        stretch_histogram = Plot(self, name='stretch_hist', viewer_type='histogram',
                                 update_callback=self._update_stretch_histogram)
        # Add the stretch bounds tool to the default Plot viewer.
        stretch_histogram.tools_nested.append(["jdaviz:stretch_bounds"])
        stretch_histogram._initialize_toolbar(["jdaviz:stretch_bounds"])

        stretch_histogram._add_data('histogram', x=[0, 1])

        stretch_histogram.add_line('vmin', x=[0, 0], y=[0, 1], ynorm=True, color='#c75d2c')
        stretch_histogram.add_line('vmax', x=[0, 0], y=[0, 1], ynorm='vmin', color='#c75d2c')
        stretch_histogram.add_line(
            label='stretch_curve',
            x=[], y=[],
            ynorm='vmin',
            color="#007BA1",  # "inactive" blue
            opacities=[0.5],
        )
        stretch_histogram.add_scatter(
            label='stretch_knots',
            x=[], y=[],
            ynorm='vmin',
            color="#c75d2c",  # "active" orange (tool enabled by default)
        )
        stretch_histogram.add_scatter('colorbar', x=[], y=[], ynorm='vmin', marker='square', stroke_width=33)  # noqa: E501
        stretch_histogram.viewer.state.update_bins_on_reset_limits = False
        stretch_histogram.viewer.state.x_limits_percentile = 95
        with stretch_histogram.figure.hold_sync():
            stretch_histogram.figure.axes[0].label = 'pixel value'
            stretch_histogram.figure.axes[0].num_ticks = 3
            stretch_histogram.figure.axes[0].tick_format = '0.1e'
            stretch_histogram.figure.axes[1].label = 'density'
            stretch_histogram.figure.axes[1].num_ticks = 2
        self.stretch_histogram_widget = f'IPY_MODEL_{stretch_histogram.model_id}'
        self.send_state('stretch_histogram_widget')
        return stretch_histogram

    def select_all(self, viewers=True, layers=True):
        """
        Enable multiselect mode and select all viewers and/or layers.

        Parameters
        ----------
        viewers : bool
            Whether to set ``viewer_multiselect`` and select all viewers (default: True)

        layers: bool
            Whether to set ``layer_multiselect`` and select all layers (default: True)
        """
        if viewers:
            self.viewer_multiselect = True
            self.viewer.select_all()
        if layers:
            self.layer_multiselect = True
            self.layer.select_all()

    def _on_global_display_unit_changed(self, *args):
        sv = self.spectrum_viewer
        self.display_units['spectral'] = sv.state.x_display_unit
        self.display_units['flux'] = sv.state.y_display_unit
        self.send_state('display_units')

    def _on_refdata_change(self, *args):
        if self.app._align_by.lower() == 'wcs':
            self.display_units['image'] = 'deg'
        else:
            self.display_units['image'] = 'pix'
        self.send_state('display_units')
        self._update_viewer_zoom_steps()

    def _on_viewer_added(self, msg):
        viewer = self.app.get_viewer_by_id(msg.viewer_id)
        viewer.state.add_callback('layers', lambda msg: self._layers_changed(viewer=viewer))

    @observe('viewer_selected')
    def _layers_changed(self, msg=None, viewer=None):
        # We need msg first in the keyword arguments to catch the msg value from the observe,
        # even though we don't end up using it
        if self.viewer_multiselect or not hasattr(self, 'viewer'):
            self.active_layer = ""
            return

        if viewer is None:
            viewer = self.viewer.selected_obj

        if viewer is self.viewer.selected_obj and self._viewer_is_image_viewer():  # noqa
            if viewer.active_image_layer is None:
                self.active_layer = ""
                return
            self.active_layer = viewer.active_image_layer.layer.label

    def vue_unmix_state(self, names):
        if isinstance(names, str):
            names = [names]
        for name in names:
            sync_state = getattr(self, name)
            sync_state.unmix_state()
        if 'stretch_params' in names:
            # there is no way to call send_state to force the update to the layers,
            # so we'll force an update by clearing first
            stretch_params = dict(self.stretch_params_value)
            self.stretch_params_value = {}
            self.stretch_params_value = stretch_params

    def vue_set_value(self, data):
        attr_name = data.get('name')
        value = data.get('value')
        setattr(self, attr_name, value)

    @with_spinner('apply_RGB_presets_spinner')
    def apply_RGB_presets(self):
        """
        Applies preset colors, opacities, and stretch settings to all visible layers
        (in all viewers) when in Color (Monochromatic) mode.
        """

        if (self.image_color_mode_value != "One color per layer" or
                self.image_color_mode_sync['mixed']):
            raise ValueError("RGB presets can only be applied if color mode is Color.")
        # Preselected colors we want to use for 5 or less layers
        preset_colors = [self.swatches_palette[4][1],
                         "#0000FF",
                         "#00FF00",
                         self.swatches_palette[1][0],
                         self.swatches_palette[0][0],
                         ]

        preset_inds = {2: [1, 4], 3: [1, 2, 4], 4: [1, 2, 3, 4]}

        # Switch back to this at the end
        initial_layer = self.layer_selected

        # Determine layers visible in selected viewer(s) - consider mixed to be visible
        visible_layers = [layer['label'] for layer in self.layer.items if not layer['is_subset'] and (layer['visible'] in (True, 'mixed'))]  # noqa

        # Set opacity to something that seems sensible
        n_visible = len(visible_layers)
        default_opacity = 1
        if n_visible > 2:
            default_opacity = 1 / math.log2(n_visible)

        # Sample along a colormap if we have too many layers
        if n_visible > len(preset_colors):
            cmap = matplotlib.colormaps['gist_rainbow'].resampled(n_visible)
            # Have to reverse the order of the cmap to make physical sense with
            # assumed wavelength order of layers.
            preset_colors = [matplotlib.colors.to_hex(cmap(i), keep_alpha=True) for
                             i in range(n_visible - 1, -1, -1)]
        elif n_visible >= 2 and n_visible < len(preset_colors):
            preset_colors = [preset_colors[i] for i in preset_inds[n_visible]]

        for i in range(n_visible):
            self.layer_selected = visible_layers[i]
            self.image_opacity.unmix_state(default_opacity)
            self.image_color.unmix_state(preset_colors[i])
            self.stretch_function.unmix_state("arcsinh")
            self.stretch_preset.unmix_state(99)

        self.layer_selected = initial_layer

    def vue_apply_RGB_presets(self, data):
        self.apply_RGB_presets()

    @observe('viewer_selected',
             'x_min_value', 'x_max_value',
             'y_min_value', 'y_max_value')
    def _update_viewer_bound_steps(self, msg={}):
        if not hasattr(self, 'viewer'):  # pragma: no cover
            # plugin hasn't been fully initialized yet
            return

        if not self.viewer.selected or not self.x_min_sync['in_subscribed_states']:
            # nothing selected yet
            return

        for ax in ('x', 'y'):
            ax_min = getattr(self, f'{ax}_min_value')
            ax_max = getattr(self, f'{ax}_max_value')
            bound_step, decimals = _round_step((ax_max - ax_min) / 100.)
            decimals = -int(np.log10(abs(bound_step))) + 1 if bound_step != 0 else 6
            setattr(self, f'{ax}_bound_step', bound_step)
            setattr(self, f'{ax}_min_value', np.round(ax_min, decimals=decimals))
            setattr(self, f'{ax}_max_value', np.round(ax_max, decimals=decimals))

    @observe('viewer_selected',
             'zoom_center_x_value', 'zoom_center_y_value',
             'zoom_radius_value')
    def _update_viewer_zoom_steps(self, msg={}):
        if not hasattr(self, 'viewer'):  # pragma: no cover
            # plugin hasn't been fully initialized yet
            return

        if not self.viewer.selected or not self.zoom_radius_sync['in_subscribed_states']:
            # nothing selected yet
            return

        # in the case of multiple viewers, calculate based on the first
        # alternatively, we could find the most extreme by looping over all selected viewers
        viewers = self.viewer.selected_obj if self.viewer_multiselect else [self.viewer.selected_obj]  # noqa
        for viewer in viewers:
            if hasattr(viewer.state, '_get_reset_limits'):
                break
        else:
            # no image viewer
            return
        x_min, x_max, y_min, y_max = viewer.state._get_reset_limits(return_as_world=True)
        self.zoom_step, _ = _round_step(max(x_max-x_min, y_max-y_min) / 100.)

    def vue_reset_viewer_bounds(self, _):
        self.reset_viewer_bounds()

    def reset_viewer_bounds(self):
        # This button is currently only exposed if only the spectrum viewer is selected
        viewers = [self.viewer.selected_obj] if not self.viewer_multiselect else self.viewer.selected_obj # noqa
        for viewer in viewers:
            viewer.toolbar.tools['jdaviz:homezoom'].activate()

    @observe('stretch_function_sync', 'stretch_params_sync',
             'stretch_vmin_sync', 'stretch_vmax_sync',
             'image_color_mode_sync', 'image_color_sync', 'image_colormap_sync')
    def _update_stretch_hist_sync(self, msg={}):
        # the histogram should show as mixed if ANY of the input parameters are mixed
        # these should match in the @observe above, all_syncs here, as well as the strings
        # passed to unmix_state in the <glue-state-sync-wrapper> in plot_options.vue
        all_syncs = [self.stretch_function_sync, self.stretch_params_sync,
                     self.stretch_vmin_sync, self.stretch_vmax_sync,
                     self.image_color_mode_sync, self.image_color_sync, self.image_colormap_sync]
        self.stretch_hist_sync = {'in_subscribed_states': bool(np.any([sync.get('in_subscribed_states', False) for sync in all_syncs])),  # noqa
                                  'mixed': bool(np.any([sync.get('mixed', False) for sync in all_syncs]))}  # noqa

    @observe('is_active', 'layer_selected', 'viewer_selected',
             'stretch_hist_zoom_limits')
    @skip_if_no_updates_since_last_active()
    def _request_update_stretch_histogram(self, msg={}):
        if not hasattr(self, 'viewer'):  # pragma: no cover
            # plugin hasn't been fully initialized yet
            return

        # NOTE: this method is separate from _update_stretch_histogram so that
        # _update_stretch_histogram can be called manually (or from the
        # update_callback on the Plot object itself) without going through
        # the skip_if_no_updates_since_last_active check (and can therefore
        # be executed even if the plugin is not active)
        self._update_stretch_histogram(msg)

    def _zoom_limits_update_stretch_histogram(self, msg={}):
        if not self.stretch_hist_zoom_limits:
            # there isn't anything to update, let's not waste resources
            return
        self._update_stretch_histogram()

    @with_spinner('stretch_hist_spinner')
    def _update_stretch_histogram(self, msg={}):
        if not self.stretch_function_sync.get('in_subscribed_states'):  # pragma: no cover
            # no (image) viewer with stretch function options
            return

        if not self.viewer.selected or not self.layer.selected:  # pragma: no cover
            # nothing to plot, will be hidden in UI
            return

        if self.layer_multiselect and len(self.layer.selected) > 1:
            # currently only support single-layer, if multiple layers are selected, the plot
            # will be hidden in the UI
            return

        if not self._viewer_is_image_viewer() or not self._layer_is_image_layer():
            # don't update histogram if selected viewer is not an image viewer:
            return

        viewer = self.viewer.selected_obj[0] if self.viewer_multiselect else self.viewer.selected_obj  # noqa

        # manage viewer zoom limit callbacks
        if ((isinstance(msg, dict) and msg.get('name') == 'viewer_selected')
                or not self.stretch_hist_zoom_limits):
            vs = viewer.state
            for attr in ('x_min', 'x_max', 'y_min', 'y_max'):
                vs.add_callback(attr, self._zoom_limits_update_stretch_histogram)
        if isinstance(msg, dict) and msg.get('name') == 'viewer_selected':
            viewer_label_old = msg.get('old')
            if isinstance(viewer_label_old, list):
                viewer_label_old = viewer_label_old[0]
            # If the previously selected viewer was deleted, we don't need to do this.
            if viewer_label_old in self.app._viewer_store:
                vs_old = self.app.get_viewer(viewer_label_old).state
                for attr in ('x_min', 'x_max', 'y_min', 'y_max'):
                    vs_old.remove_callback(attr, self._zoom_limits_update_stretch_histogram)

        if not len(self.layer.selected_obj):
            # skip further updates if no data are available:
            return
        if isinstance(self.layer.selected_obj[0], list):
            if not len(self.layer.selected_obj[0]):
                return
            # multiselect case (but we won't check multiselect since the selection can lag behind)
            layer = self.layer.selected_obj[0][0]
        else:
            layer = self.layer.selected_obj[0]
        data = layer.layer

        if isinstance(data, GroupedSubset):
            # don't update histogram for subsets:
            return

        comp = data.get_component(layer.state.attribute)

        # TODO: further optimization could be done by caching sub_data
        if self.stretch_hist_zoom_limits and (not self.layer_multiselect or len(self.layer_selected) == 1):  # noqa
            if hasattr(viewer, '_get_zoom_limits'):
                # Viewer limits. This takes account of Imviz linking.
                xy_limits = viewer._get_zoom_limits(data).astype(int)
                x_limits = xy_limits[:, 0]
                y_limits = xy_limits[:, 1]
                x_min = max(x_limits.min(), 0)
                x_max = x_limits.max()
                y_min = max(y_limits.min(), 0)
                y_max = y_limits.max()

                sub_data = comp.data[y_min:y_max, x_min:x_max]

            else:
                # spectrum-2d-viewer, for example.  We'll assume the viewer
                # limits correspond to the fixed data components from glue
                # and filter directly.
                x_data = data.get_component(data.components[1]).data
                y_data = data.get_component(data.components[0]).data

                inverted_x = getattr(viewer, 'inverted_x_axis', False)
                x_min = viewer.state.x_min if not inverted_x else viewer.state.x_max
                x_max = viewer.state.x_max if not inverted_x else viewer.state.x_min
                inds = np.where((x_data >= x_min) &
                                (x_data <= x_max) &
                                (y_data >= viewer.state.y_min) &
                                (y_data <= viewer.state.y_max))

                sub_data = comp.data[inds]

        else:
            # include all data, regardless of zoom limits
            sub_data = comp.data

        self.stretch_histogram.viewer.state.random_subset = RANDOM_SUBSET_SIZE
        self.stretch_histogram._update_data('histogram', x=sub_data)

        if len(sub_data) > 0:

            # Use glue to compute the statistics since this allows us to use
            # a random subset of the data to compute the histogram.
            # The 2.5 and 97.5 hardcoded here is equivalent to
            # PercentileInterval(95).get_limits(sub_data)
            glue_data = self.stretch_histogram.app.data_collection['histogram']
            hist_lims = (
                glue_data.compute_statistic('percentile', glue_data.id['x'],
                                            percentile=2.5, random_subset=RANDOM_SUBSET_SIZE),
                glue_data.compute_statistic('percentile', glue_data.id['x'],
                                            percentile=97.5, random_subset=RANDOM_SUBSET_SIZE)
            )

            # set the stepsize for vmin/vmax to be approximately 1% of the range of the
            # histogram (within the percentile interval), rounded to 1-2 significant digits
            # to avoid random step sizes.  This logic is somewhat arbitrary and can be safely
            # modified or eventually exposed to the user if that would be useful.
            stretch_vstep = (hist_lims[1] - hist_lims[0]) / 100.
            self.stretch_vstep = _round_step(stretch_vstep)[0]

            with delay_callback(self.stretch_histogram.viewer.state, 'hist_x_min', 'hist_x_max'):
                self.stretch_histogram.viewer.state.hist_x_min = hist_lims[0]
                self.stretch_histogram.viewer.state.hist_x_max = hist_lims[1]

        self.stretch_histogram.figure.title = f"{len(sub_data)} pixels"

        # update the n_bins since this may be a new layer
        self._histogram_nbins_changed()
        # update the curve/colorbar
        self._update_stretch_curve(msg)

    @observe('image_color_mode_value', 'image_color_value', 'image_colormap_value',
             'image_contrast_value', 'image_bias_value',
             'stretch_hist_nbins',
             'stretch_curve_visible',
             'stretch_function_value', 'stretch_vmin_value', 'stretch_vmax_value',
             'stretch_params_value', 'stretch_preset_value',
             'layer_multiselect'
             )
    @skip_if_no_updates_since_last_active()
    def _update_stretch_curve(self, msg=None):
        if not self._viewer_is_image_viewer() or not hasattr(self, 'stretch_histogram'):
            # don't update histogram if selected viewer is not an image viewer,
            # or the stretch histogram hasn't been initialized:
            return

        if self.layer_multiselect and len(self.layer.selected) > 1:
            # currently only support single-layer, if multiple layers are selected, the plot
            # will be hidden in the UI
            return

        # could be multi or single-viewer and/or multi-layer with a single entry,
        # either way, we act on the first entry
        layer = self.layer.selected_obj[0]
        while isinstance(layer, list):
            if not len(layer):
                return
            layer = layer[0]

        if isinstance(layer.layer, GroupedSubset):
            # don't update histogram for subsets, will be hidden in UI
            return

        # create the new/updated stretch curve following the colormapping
        # procedure in glue's CompositeArray:
        interval = ManualInterval(self.stretch_vmin_value, self.stretch_vmax_value)
        contrast_bias = ContrastBiasStretch(self.image_contrast_value, self.image_bias_value)
        stretch = layer.state.stretch_object
        layer_cmap = layer.state.cmap

        # show the colorbar
        color_mode = self.image_color_mode_value

        # NOTE: Index 0 in marks is assumed to be the bin centers.
        x = self.stretch_histogram.figure.marks[0].x
        y = np.ones_like(x)

        # Copied from the __call__ internals of glue/viewers/image/composite_array.py
        data = interval(x)
        data = contrast_bias(data, out=data)
        data = stretch(data, out=data)

        if color_mode == 'Colormaps':
            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])
            else:
                layer_cmap = cmap

            # Compute colormapped image
            plane = layer_cmap(data)

        else:  # Color (Monochromatic)
            if self.image_color_value is None:
                # do not crash if image_color_value is not yet assigned,
                # _update_stretch_curve observes image_color_value so will get called again
                return False
            # Get color
            color = COLOR_CONVERTER.to_rgba_array(self.image_color_value)[0]
            plane = data[:, np.newaxis] * color
            plane[:, 3] = 1

        plane = np.clip(plane, 0, 1, out=plane)
        ipycolors = [matplotlib.colors.rgb2hex(p, keep_alpha=False) for p in plane]

        colorbar_mark = self.stretch_histogram.marks['colorbar']
        colorbar_mark.x = x
        colorbar_mark.y = y
        colorbar_mark.colors = ipycolors

        # show "knot" locations if the stretch_function is a spline
        if isinstance(stretch, SplineStretch) and self.stretch_curve_visible:
            knot_mark = self.stretch_histogram.marks['stretch_knots']
            knot_mark.x = (self.stretch_vmin_value +
                           np.asarray(stretch._x) * (self.stretch_vmax_value - self.stretch_vmin_value))  # noqa
            # scale to 0.9 so always falls below colorbar (same as for stretch_curve)
            knot_mark.y = 0.9 * np.asarray(stretch._y)
        else:
            self.stretch_histogram.clear_marks('stretch_knots')

        if self.stretch_curve_visible:
            # create a photoshop style "curve" for the stretch function
            curve_x = np.linspace(self.stretch_vmin_value, self.stretch_vmax_value, 50)
            curve_y = interval(curve_x)
            curve_y = contrast_bias(curve_y)
            curve_y = stretch(curve_y)

            curve_mark = self.stretch_histogram.marks['stretch_curve']
            curve_mark.x = curve_x
            curve_mark.y = 0.9 * curve_y
        else:
            self.stretch_histogram.clear_marks('stretch_curve')

        self.stretch_histogram._refresh_marks()

    @observe('stretch_vmin_value')
    def _stretch_vmin_changed(self, msg=None):
        self.stretch_histogram.marks['vmin'].x = [self.stretch_vmin_value, self.stretch_vmin_value]

    @observe('stretch_vmax_value')
    def _stretch_vmax_changed(self, msg=None):
        self.stretch_histogram.marks['vmax'].x = [self.stretch_vmax_value, self.stretch_vmax_value]

    @observe("stretch_hist_nbins")
    def _histogram_nbins_changed(self, msg={}):
        if self.stretch_histogram is None:
            return
        if self.stretch_hist_nbins == '' or self.stretch_hist_nbins < 1:
            return
        self.stretch_histogram.viewer.state.hist_n_bin = self.stretch_hist_nbins
        # for some reason, this resets the internal marks, so we need to ensure the manual
        # marks are still plotted
        self.stretch_histogram._refresh_marks()

    def set_histogram_limits(self, x_min=None, x_max=None, y_min=None, y_max=None):
        # NOTE: leaving this out of user API until API is finalized with interactive setting
        self.stretch_histogram.set_limits(x_min=x_min, x_max=x_max,
                                          y_min=y_min, y_max=y_max)

    def _viewer_is_image_viewer(self):
        # Import here to prevent circular import (and not at the top of the method so the import
        # check is avoided, whenever possible).
        from jdaviz.configs.imviz.plugins.viewers import ImvizImageView
        from jdaviz.configs.cubeviz.plugins.viewers import CubevizImageView
        from jdaviz.configs.mosviz.plugins.viewers import MosvizImageView, MosvizProfile2DView
        from jdaviz.configs.rampviz.plugins.viewers import RampvizImageView

        def _is_image_viewer(viewer):
            return isinstance(viewer, (ImvizImageView, CubevizImageView,
                                       MosvizImageView, MosvizProfile2DView,
                                       RampvizImageView))

        viewers = self.viewer.selected_obj
        if not isinstance(viewers, list):
            viewers = [viewers]

        return np.all([_is_image_viewer(viewer) for viewer in viewers])

    def _layer_is_image_layer(self):
        from glue_jupyter.bqplot.image.state import BqplotImageLayerState
        return np.all([isinstance(lyr.state, BqplotImageLayerState)
                       for lyr in self.layer.selected_obj_flattened])

    def image_segmentation_map_presets(self, *args, **kwargs):
        # if 'Random' colormap is used for visualizing image segmentation,
        # ensure the stretch limits are the min and max, the stretch function
        # is linear, the contrast is 1.0, and the bias is 0.5. This ensures
        # that all label colors are unique:
        if self.image_colormap_value != 'Random':
            return

        self.stretch_preset.value = 100
        self.stretch_function.value = 'linear'
        self.image_contrast_value = 1
        self.image_bias_value = 0.5

    def vue_image_segmentation_map_presets(self, *args, **kwargs):
        self.image_segmentation_map_presets(*args, **kwargs)
