From faed2c9bd81ec2910dbc0bd5020f2ac0f5ba881f Mon Sep 17 00:00:00 2001 From: Jonathan Foster Date: Wed, 20 Sep 2023 15:17:18 -0400 Subject: [PATCH 01/10] Add a region layer for images --- glue/core/data.py | 201 ++++++++++++++++++++++++++ glue/viewers/image/viewer.py | 16 +- glue/viewers/scatter/layer_artist.py | 188 +++++++++++++++++++++++- glue/viewers/scatter/plot_polygons.py | 157 ++++++++++++++++++++ glue/viewers/scatter/state.py | 92 +++++++++++- 5 files changed, 649 insertions(+), 5 deletions(-) create mode 100644 glue/viewers/scatter/plot_polygons.py diff --git a/glue/core/data.py b/glue/core/data.py index 6313eace5..87ae4d810 100644 --- a/glue/core/data.py +++ b/glue/core/data.py @@ -2059,3 +2059,204 @@ def pixel_label(i, ndim): if 1 <= ndim <= 3: label += " [{0}]".format('xyz'[ndim - 1 - i]) return label + + +class RegionData(Data): + """ + A glue Data object for storing data that is associated with a region. + + This object can be used when a dataset describes 2D regions or 1D ranges. It + contains exactly one :class:`~glue.core.component.ExtendedComponent` object + which contains the boundaries of the regions, and must also contain + one or two components that give the center of the regions in whatever data + coordinates the regions are described in. Links in glue are not made + directly on the :class:`~glue.core.component.ExtendedComponent`, but instead + on the center components. + + Thus, a subset that includes the center of a region will include that region, + but a subset that includes just a little part of the region will not include + that region. These center components are not the same pixel components. For + example, a dataset that is a table of 2D regions will have a single + :class:`~glue.core.component.CoordinateComponent`, but must have two of these center + components. + + A typical use case for this object is to store the properties of geographic + regions, where the boundaries of the regions are stored in an + :class:`~glue.core.component.ExtendedComponent` and the centers of the + regions are stored in two components, one for the longitude and one for the + latitude. Additional components may describe arbitrary properties of these + geographic regions (e.g. population, area, etc). + + + Parameters + ---------- + label : `str`, optional + The label of the data. + coords : :class:`~glue.core.coordinates.Coordinates`, optional + The coordinates associated with the data. + **kwargs + All other keyword arguments are passed to the :class:`~glue.core.data.Data` + constructor. + + Attributes + ---------- + extended_component_id : :class:`~glue.core.component_id.ComponentID` + The ID of the :class:`~glue.core.component.ExtendedComponent` object + that contains the boundaries of the regions. + center_x_id : :class:`~glue.core.component_id.ComponentID` + The ID of the Component object that contains the x-coordinate of the + center of the regions. This is actually stored in the component + with the extended_component_id, but it is convenient to have it here. + center_y_id : :class:`~glue.core.component_id.ComponentID` + The ID of the Component object that contains the y-coordinate of the + center of the regions. This is actually stored in the component + with the extended_component_id, but it is convenient to have it here. + + Examples + -------- + + There are two main options for initializing a :class:`~glue.core.data.RegionData` + object. The first is to simply pass in a list of ``Shapely.Geometry`` objects + with dimesionality N, from which we will create N+1 components: one + :class:`~glue.core.component.ExtendedComponent` with the boundaries, and N + regular Component(s) with the center coordinates computed from the Shapley + method :meth:`~shapely.GeometryCollection.representative_point`: + + >>> geometries = [shapely.geometry.Point(0, 0).buffer(1), shapely.geometry.Point(1, 1).buffer(1)] + >>> my_region_data = RegionData(label='My Regions', boundary=geometries) + + This will create a :class:`~glue.core.data.RegionData` object with three + components: one :class:`~glue.core.component.ExtendedComponent` with label + "geo" and two regular Components with labels "Center [x] for boundary" + and "Center [y] for boundary". + + The second is to explicitly create an :class:`~glue.core.component.ExtendedComponent` + (which requires passing in the ComponentIDs for the center coordinates) and + then use `add_component` to add this component to a :class:`~glue.core.data.RegionData` + object. You might use this approach if your dataset already contains points that + represent the centers of your regions and you want to avoid re-calculating them. For example: + + >>> center_x = [0, 1] + >>> center_y = [0, 1] + >>> geometries = [shapely.geometry.Point(0, 0).buffer(1), shapely.geometry.Point(1, 1).buffer(1)] + + >>> my_region_data = RegionData(label='My Regions') + >>> # Region IDs are created and returned when we add a Component to a Data object + >>> cen_x_id = my_region_data.add_component(center_x, label='Center [x]') + >>> cen_y_id = my_region_data.add_component(center_y, label='Center [y]') + >>> extended_comp = ExtendedComponent(geometries, center_comp_ids=[cen_x_id, cen_y_id]) + >>> my_region_data.add_component(extended_comp, label='boundaries') + + """ + + def __init__(self, label="", coords=None, **kwargs): + self._extended_component_id = None + self._center_x_id = None + self._center_y_id = None + # __init__ calls add_component which deals with ExtendedComponent logic + super().__init__(label=label, coords=coords, **kwargs) + + def __repr__(self): + return f'RegionData (label: {self.label} | extended_component: {self.extended_component_id})' + + @property + def center_x_id(self): + return self.get_component(self.extended_component_id).x + + @property + def center_y_id(self): + return self.get_component(self.extended_component_id).y + + @property + def extended_component_id(self): + return self._extended_component_id + + @contract(component='component_like', label='cid_like') + def add_component(self, component, label): + """ Add a new component to this data set, allowing only one :class:`~glue.core.component.ExtendedComponent` + + If component is an array of Shapely objects then we use + :meth:`~shapely.GeometryCollection.representative_point`: to + create two new components for the center coordinates of the regions and + add them to the :class:`~glue.core.data.RegionData` object as well. + + If component is an :class:`~glue.core.component.ExtendedComponent`, + then we simply add it to the :class:`~glue.core.data.RegionData` object. + + We do this here instead of extending ``Component.autotyped`` because + we only want to use :class:`~glue.core.component.ExtendedComponent` objects + in the context of a :class:`~glue.core.data.RegionData` object. + + Parameters + ---------- + component : :class:`~glue.core.component.Component` or array-like + Object to add. If this is an array of Shapely objects, then we + create two new components for the center coordinates of the regions + as well. + label : `str` or :class:`~glue.core.component_id.ComponentID` + The label. If this is a string, a new + :class:`glue.core.component_id.ComponentID` + with this label will be created and associated with the Component. + + Raises + ------ + `ValueError`, if the :class:`~glue.core.data.RegionData` already has an extended component + """ + + if not isinstance(component, Component): + if all(isinstance(s, shapely.Geometry) for s in component): + center_x = [] + center_y = [] + for s in component: + rep = s.representative_point() + center_x.append(rep.x) + center_y.append(rep.y) + cen_x_id = super().add_component(np.asarray(center_x), f"Center [x] for {label}") + cen_y_id = super().add_component(np.asarray(center_y), f"Center [y] for {label}") + ext_component = ExtendedComponent(np.asarray(component), center_comp_ids=[cen_x_id, cen_y_id]) + self._extended_component_id = super().add_component(ext_component, label) + return self._extended_component_id + + if isinstance(component, ExtendedComponent): + if self.extended_component_id is not None: + raise ValueError(f"Cannot add another ExtendedComponent; existing extended component: {self.extended_component_id}") + else: + self._extended_component_id = super().add_component(component, label) + return self._extended_component_id + else: + return super().add_component(component, label) + + def get_transform_to_cid(self, this_cid, other_cid): + """ + Returns the conversion function that maps cid to the coordinates + that the ExtendedComponent are in (i.e. self.region_x_att and self.region_y_att) + """ + func = None + link = self._get_external_link(other_cid) + if this_cid in link.get_from_ids(): + func = link.get_using() + elif this_cid in link.get_to_ids(): + func = link.get_inverse() + if func: + return func + else: + return None + + def check_if_linked_cid(self, target_cids, other_cid): + """ + Check if a ComponentID is in the set of components that regions are over. + """ + from glue.core.link_manager import is_equivalent_cid # avoid circular import + + for target_cid in target_cids: + if is_equivalent_cid(self, target_cid, other_cid): + return True + else: + link = self._get_external_link(other_cid) + if not link: + return False + for target_cid in target_cids: + if target_cid in link: + return True + else: + return False diff --git a/glue/viewers/image/viewer.py b/glue/viewers/image/viewer.py index 02f23e243..354f8732f 100644 --- a/glue/viewers/image/viewer.py +++ b/glue/viewers/image/viewer.py @@ -5,8 +5,9 @@ from glue.core.subset import roi_to_subset_state from glue.core.coordinates import Coordinates, LegacyCoordinates from glue.core.coordinate_helpers import dependent_axes +from glue.core.data import RegionData -from glue.viewers.scatter.layer_artist import ScatterLayerArtist +from glue.viewers.scatter.layer_artist import ScatterLayerArtist, ScatterRegionLayerArtist from glue.viewers.image.layer_artist import ImageLayerArtist, ImageSubsetLayerArtist from glue.viewers.image.compat import update_image_viewer_state @@ -172,15 +173,24 @@ def _scatter_artist(self, axes, state, layer=None, layer_state=None): raise Exception("Can only add a scatter plot overlay once an image is present") return ScatterLayerArtist(axes, state, layer=layer, layer_state=None) + def _region_artist(self, axes, state, layer=None, layer_state=None): + if len(self._layer_artist_container) == 0: + raise Exception("Can only add a region plot overlay once an image is present") + return ScatterRegionLayerArtist(axes, state, layer=layer, layer_state=None) + def get_data_layer_artist(self, layer=None, layer_state=None): - if layer.ndim == 1: + if isinstance(layer, RegionData): + cls = self._region_artist + elif layer.ndim == 1: cls = self._scatter_artist else: cls = ImageLayerArtist return self.get_layer_artist(cls, layer=layer, layer_state=layer_state) def get_subset_layer_artist(self, layer=None, layer_state=None): - if layer.ndim == 1: + if isinstance(layer.data, RegionData): + cls = self._region_artist + elif layer.ndim == 1: cls = self._scatter_artist else: cls = ImageSubsetLayerArtist diff --git a/glue/viewers/scatter/layer_artist.py b/glue/viewers/scatter/layer_artist.py index bb8ac5c6e..0726199bd 100644 --- a/glue/viewers/scatter/layer_artist.py +++ b/glue/viewers/scatter/layer_artist.py @@ -10,12 +10,19 @@ from glue.config import stretches from glue.utils import defer_draw, ensure_numerical, datetime64_to_mpl -from glue.viewers.scatter.state import ScatterLayerState +from glue.viewers.scatter.state import ScatterLayerState, ScatterRegionLayerState from glue.viewers.scatter.python_export import python_export_scatter_layer +from glue.viewers.scatter.plot_polygons import (UpdateableRegionCollection, + get_geometry_type, + _sanitize_geoms, + _PolygonPatch, + transform_shapely) from glue.viewers.matplotlib.layer_artist import MatplotlibLayerArtist from glue.core.exceptions import IncompatibleAttribute +from glue.core.data import BaseData from matplotlib.lines import Line2D +from shapely.ops import transform # We keep the following so that scripts exported with previous versions of glue # continue to work, as they imported STRETCHES from here. @@ -594,3 +601,182 @@ def _use_plot_artist(self): res = self.state.cmap_mode == 'Fixed' and self.state.size_mode == 'Fixed' return res and (not hasattr(self._viewer_state, 'plot_mode') or not self._viewer_state.plot_mode == 'polar') + + +class ScatterRegionLayerArtist(MatplotlibLayerArtist): + + _layer_state_cls = ScatterRegionLayerState + # _python_exporter = python_export_scatter_layer # TODO: Update this to work with regions + + def __init__(self, axes, viewer_state, layer_state=None, layer=None): + + super().__init__(axes, viewer_state, + layer_state=layer_state, layer=layer) + + if isinstance(layer, BaseData): + data = layer + else: + data = layer.data + + self.data = data + self.region_att = data._extended_component_id + + self.region_comp = data.get_component(self.region_att) + + self.region_x_att = self.data.center_x_id + self.region_y_att = self.data.center_y_id + self.region_xy_ids = [self.region_x_att, self.region_y_att] + + self._viewer_state.add_global_callback(self._update_scatter_region) + self.state.add_global_callback(self._update_scatter_region) + self._set_axes(axes) + + def _set_axes(self, axes): + self.axes = axes + self.region_collection = UpdateableRegionCollection([]) + self.axes.add_collection(self.region_collection) + # This is a little unnecessary, but keeps code more parallel + self.mpl_artists = [self.region_collection] + + @defer_draw + def _update_data(self): + # Layer artist has been cleared already + if len(self.mpl_artists) == 0: + return + + try: + # These must be special attributes that are linked to a region_att + if ((not self.data.check_if_linked_cid(self.region_xy_ids, self._viewer_state.x_att)) and + (not self.data.check_if_linked_cid(self.region_xy_ids, self._viewer_state.x_att_world))): + raise IncompatibleAttribute + x = ensure_numerical(self.layer[self._viewer_state.x_att].ravel()) + except (IncompatibleAttribute, IndexError): + # The following includes a call to self.clear() + self.disable_invalid_attributes(self._viewer_state.x_att) + return + else: + self.enable() + + try: + # These must be special attributes that are linked to a region_att + if ((not self.data.check_if_linked_cid(self.region_xy_ids, self._viewer_state.y_att)) and + (not self.data.check_if_linked_cid(self.region_xy_ids, self._viewer_state.y_att_world))): + raise IncompatibleAttribute + y = ensure_numerical(self.layer[self._viewer_state.y_att].ravel()) + except (IncompatibleAttribute, IndexError): + # The following includes a call to self.clear() + self.disable_invalid_attributes(self._viewer_state.y_att) + return + else: + self.enable() + + regions = self.layer[self.region_att] + + x_conversion_func = self.data.get_transform_to_cid(self.region_x_att, self._viewer_state.x_att) + y_conversion_func = self.data.get_transform_to_cid(self.region_y_att, self._viewer_state.y_att) + + def conversion_func(x, y, z=None): + if x_conversion_func: + x = x_conversion_func(x) + if y_conversion_func: + y = y_conversion_func(y) + return tuple([x, y]) + regions = np.array([transform(conversion_func, g) for g in regions]) + + # If we are using world coordinates (i.e. the regions are specified in world coordinates) + # we need to transform the geometries of the regions into pixel coordinates for display + # Note that this calls a custom version of the transform function from shapely + # to accomodate glue WCS objects + if self._viewer_state._display_world: + world2pix = self._viewer_state.reference_data.coords.world_to_pixel_values + regions = np.array([transform_shapely(world2pix, g) for g in regions]) + # decompose GeometryCollections + geoms, multiindex = _sanitize_geoms(regions, prefix="Geom") + self.multiindex_geometry = multiindex + + geom_types = get_geometry_type(geoms) + poly_idx = np.asarray((geom_types == "Polygon") | (geom_types == "MultiPolygon")) + polys = geoms[poly_idx] + + # decompose MultiPolygons + geoms, multiindex = _sanitize_geoms(polys, prefix="Multi") + self.region_collection.patches = [_PolygonPatch(poly) for poly in geoms] + + self.geoms = geoms + self.multiindex = multiindex + + @defer_draw + def _update_visual_attributes(self, changed, force=False): + + if not self.enabled: + return + + if self.state.cmap_mode == 'Fixed': + if force or 'color' in changed or 'cmap_mode' in changed or 'fill' in changed: + self.region_collection.set_array(None) + if self.state.fill: + self.region_collection.set_facecolors(self.state.color) + self.region_collection.set_edgecolors('none') + else: + self.region_collection.set_facecolors('none') + self.region_collection.set_edgecolors(self.state.color) + elif force or any(prop in changed for prop in CMAP_PROPERTIES) or 'fill' in changed: + self.region_collection.set_edgecolors(None) + self.region_collection.set_facecolors(None) + c = ensure_numerical(self.layer[self.state.cmap_att].ravel()) + c_values = np.take(c, self.multiindex_geometry, axis=0) # Decompose Geoms + c_values = np.take(c_values, self.multiindex, axis=0) # Decompose MultiPolys + set_mpl_artist_cmap(self.region_collection, c_values, self.state) + if self.state.fill: + self.region_collection.set_edgecolors('none') + else: + self.region_collection.set_facecolors('none') + + for artist in [self.region_collection]: + + if artist is None: + continue + + if force or 'alpha' in changed: + artist.set_alpha(self.state.alpha) + + if force or 'zorder' in changed: + artist.set_zorder(self.state.zorder) + + if force or 'visible' in changed: + artist.set_visible(self.state.visible) + + self.redraw() + + @defer_draw + def _update_scatter_region(self, force=False, **kwargs): + + if (self._viewer_state.x_att is None or + self._viewer_state.y_att is None or + self.state.layer is None): + return + + # NOTE: we need to evaluate this even if force=True so that the cache + # of updated properties is up to date after this method has been called. + changed = self.pop_changed_properties() + + full_sphere = getattr(self._viewer_state, 'using_full_sphere', False) + change_from_limits = full_sphere and len(changed & LIMIT_PROPERTIES) > 0 + if force or change_from_limits or len(changed & DATA_PROPERTIES) > 0: + self._update_data() + force = True + + if force or len(changed & VISUAL_PROPERTIES) > 0: + self._update_visual_attributes(changed, force=force) + + @defer_draw + def update(self): + self._update_scatter_region(force=True) + self.redraw() + + @defer_draw + def update_component_limits(self, components_changed): + for limit_helper in [self.state.cmap_lim_helper]: + if limit_helper.attribute in components_changed: + limit_helper.update_values(force=True) + self.redraw() diff --git a/glue/viewers/scatter/plot_polygons.py b/glue/viewers/scatter/plot_polygons.py new file mode 100644 index 000000000..bc197fb99 --- /dev/null +++ b/glue/viewers/scatter/plot_polygons.py @@ -0,0 +1,157 @@ +""" +Some lightly edited code from geopandas.plotting.py for efficiently +plotting multiple polygons in matplotlib. +""" +# Copyright (c) 2013-2022, GeoPandas developers. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of GeoPandas nor the names of its contributors may +# be used to endorse or promote products derived from this software without +# specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +# ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import numpy as np +import shapely +from matplotlib.collections import PatchCollection +from shapely.errors import GeometryTypeError + + +class UpdateableRegionCollection(PatchCollection): + """ + Allow paths in PatchCollection to be modified after creation. + """ + + def __init__(self, patches, *args, **kwargs): + self.patches = patches + PatchCollection.__init__(self, patches, *args, **kwargs) + + def get_paths(self): + self.set_paths(self.patches) + return self._paths + + +def _sanitize_geoms(geoms, prefix="Multi"): + """ + Returns Series like geoms and index, except that any Multi geometries + are split into their components and indices are repeated for all component + in the same Multi geometry. At the same time, empty or missing geometries are + filtered out. Maintains 1:1 matching of geometry to value. + Prefix specifies type of geometry to be flatten. 'Multi' for MultiPoint and similar, + "Geom" for GeometryCollection. + Returns + ------- + components : list of geometry + component_index : index array + indices are repeated for all components in the same Multi geometry + """ + # TODO(shapely) look into simplifying this with + # shapely.get_parts(geoms, return_index=True) from shapely 2.0 + components, component_index = [], [] + + geom_types = get_geometry_type(geoms).astype("str") + + if ( + not np.char.startswith(geom_types, prefix).any() + # and not geoms.is_empty.any() + # and not geoms.isna().any() + ): + return geoms, np.arange(len(geoms)) + + for ix, (geom, geom_type) in enumerate(zip(geoms, geom_types)): + if geom is not None and geom_type.startswith(prefix): + for poly in geom.geoms: + components.append(poly) + component_index.append(ix) + elif geom is None: + continue + else: + components.append(geom) + component_index.append(ix) + + return components, np.array(component_index) + + +def get_geometry_type(data): + _names = { + "MISSING": None, + "NAG": None, + "POINT": "Point", + "LINESTRING": "LineString", + "LINEARRING": "LinearRing", + "POLYGON": "Polygon", + "MULTIPOINT": "MultiPoint", + "MULTILINESTRING": "MultiLineString", + "MULTIPOLYGON": "MultiPolygon", + "GEOMETRYCOLLECTION": "GeometryCollection", + } + + type_mapping = {p.value: _names[p.name] for p in shapely.GeometryType} + geometry_type_ids = list(type_mapping.keys()) + geometry_type_values = np.array(list(type_mapping.values()), dtype=object) + res = shapely.get_type_id(data) + return geometry_type_values[np.searchsorted(geometry_type_ids, res)] + + +def transform_shapely(func, geom): + """ + A simplified/modified version of shapely.ops.transform where the func + call signature is tuned for the coordinate transform functions + coming from glue. + """ + if geom.is_empty: + return geom + if geom.geom_type in ("Point", "LineString", "LinearRing", "Polygon"): + if geom.geom_type in ("Point", "LineString", "LinearRing"): + return type(geom)(func(geom.coords)) + elif geom.geom_type == "Polygon": + shell = type(geom.exterior)(func(geom.exterior.coords)) + holes = list( + type(ring)(func(ring.coords)) + for ring in geom.interiors + ) + return type(geom)(shell, holes) + + elif geom.geom_type.startswith("Multi") or geom.geom_type == "GeometryCollection": + return type(geom)([transform_shapely(func, part) for part in geom.geoms]) + else: + raise GeometryTypeError(f"Type {geom.geom_type!r} not recognized") + + +def _PolygonPatch(polygon, **kwargs): + """Constructs a matplotlib patch from a Polygon geometry + The `kwargs` are those supported by the matplotlib.patches.PathPatch class + constructor. Returns an instance of matplotlib.patches.PathPatch. + Example (using Shapely Point and a matplotlib axes):: + b = shapely.geometry.Point(0, 0).buffer(1.0) + patch = _PolygonPatch(b, fc='blue', ec='blue', alpha=0.5) + ax.add_patch(patch) + GeoPandas originally relied on the descartes package by Sean Gillies + (BSD license, https://pypi.org/project/descartes) for PolygonPatch, but + this dependency was removed in favor of the below matplotlib code. + """ + from matplotlib.patches import PathPatch + from matplotlib.path import Path + + path = Path.make_compound_path( + Path(np.asarray(polygon.exterior.coords)[:, :2]), + *[Path(np.asarray(ring.coords)[:, :2]) for ring in polygon.interiors], + ) + return PathPatch(path, **kwargs) diff --git a/glue/viewers/scatter/state.py b/glue/viewers/scatter/state.py index 0ad007bb9..1831e7abe 100644 --- a/glue/viewers/scatter/state.py +++ b/glue/viewers/scatter/state.py @@ -16,7 +16,7 @@ from matplotlib.projections import get_projection_names -__all__ = ['ScatterViewerState', 'ScatterLayerState'] +__all__ = ['ScatterViewerState', 'ScatterLayerState', 'ScatterRegionLayerState'] class ScatterViewerState(MatplotlibDataViewerState): @@ -343,6 +343,7 @@ def __init__(self, viewer_state=None, layer=None, **kwargs): self._on_layer_change() self.cmap = colormaps.members[0][1] + self.add_callback('cmap_att', self._check_for_preferred_cmap) self.size = self.layer.style.markersize @@ -350,6 +351,15 @@ def __init__(self, viewer_state=None, layer=None, **kwargs): self.update_from_dict(kwargs) + def _check_for_preferred_cmap(self, *args): + if isinstance(self.layer, BaseData): + layer = self.layer + else: + layer = self.layer.data + actual_component = layer.get_component(self.cmap_att) + if getattr(actual_component, 'preferred_cmap', False): + self.cmap = actual_component.preferred_cmap + def _update_points_mode(self, *args): if getattr(self.viewer_state, 'using_polar', False) or getattr(self.viewer_state, 'using_full_sphere', False): self.points_mode_helper.choices = ['markers'] @@ -493,3 +503,83 @@ def __setgluestate__(cls, rec, context): rec['values']['markers_visible'] = False rec['values']['line_visible'] = True return super(ScatterLayerState, cls).__setgluestate__(rec, context) + + +class ScatterRegionLayerState(MatplotlibLayerState): + """ + A state class that includes all the attributes for layers in a scatter region layer. + """ + # Color + + cmap_mode = DDSCProperty(docstring="Whether to use color to encode an attribute") + cmap_att = DDSCProperty(docstring="The attribute to use for the color") + cmap_vmin = DDCProperty(docstring="The lower level for the colormap") + cmap_vmax = DDCProperty(docstring="The upper level for the colormap") + cmap = DDCProperty(docstring="The colormap to use (when in colormap mode)") + + fill = DDCProperty(True, docstring="Whether to fill the regions") + + def __init__(self, viewer_state=None, layer=None, **kwargs): + + super().__init__(viewer_state=viewer_state, layer=layer) + self.limits_cache = {} + + self.cmap_lim_helper = StateAttributeLimitsHelper(self, attribute='cmap_att', + lower='cmap_vmin', upper='cmap_vmax', + limits_cache=self.limits_cache) + self.cmap_att_helper = ComponentIDComboHelper(self, 'cmap_att', + numeric=True, datetime=False, + categorical=True) + + ScatterRegionLayerState.cmap_mode.set_choices(self, ['Fixed', 'Linear']) + + if self.viewer_state is not None: + self.viewer_state.add_callback('x_att', self._on_xy_change, priority=10000) + self.viewer_state.add_callback('y_att', self._on_xy_change, priority=10000) + self._on_xy_change() + + self.add_callback('layer', self._on_layer_change) + if layer is not None: + self._on_layer_change() + + self.cmap = colormaps.members[0][1] + + self.add_callback('cmap_att', self._check_for_preferred_cmap) + self.update_from_dict(kwargs) + + def _check_for_preferred_cmap(self, *args): + if isinstance(self.layer, BaseData): + layer = self.layer + else: + layer = self.layer.data + actual_component = layer.get_component(self.cmap_att) + if getattr(actual_component, 'preferred_cmap', False): + self.cmap = actual_component.preferred_cmap + + def _on_layer_change(self, layer=None): + with delay_callback(self, 'cmap_vmin', 'cmap_vmax'): + + if self.layer is None: + self.cmap_att_helper.set_multiple_data([]) + else: + self.cmap_att_helper.set_multiple_data([self.layer]) + + def _on_xy_change(self, *event): + + if self.viewer_state.x_att is None or self.viewer_state.y_att is None: + return + + if isinstance(self.layer, BaseData): + layer = self.layer + else: + layer = self.layer.data + + def flip_cmap(self): + """ + Flip the cmap_vmin/cmap_vmax limits. + """ + self.cmap_lim_helper.flip_limits() + + @property + def cmap_name(self): + return colormaps.name_from_cmap(self.cmap) From 81a0d8c7b5eb62dd8d1acc19174043f287a189ad Mon Sep 17 00:00:00 2001 From: Jonathan Foster Date: Thu, 21 Sep 2023 17:36:34 -0400 Subject: [PATCH 02/10] Move setup to occur only when there is data --- glue/viewers/scatter/layer_artist.py | 30 ++++++++++++++-------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/glue/viewers/scatter/layer_artist.py b/glue/viewers/scatter/layer_artist.py index 0726199bd..6eb215c85 100644 --- a/glue/viewers/scatter/layer_artist.py +++ b/glue/viewers/scatter/layer_artist.py @@ -612,21 +612,6 @@ def __init__(self, axes, viewer_state, layer_state=None, layer=None): super().__init__(axes, viewer_state, layer_state=layer_state, layer=layer) - - if isinstance(layer, BaseData): - data = layer - else: - data = layer.data - - self.data = data - self.region_att = data._extended_component_id - - self.region_comp = data.get_component(self.region_att) - - self.region_x_att = self.data.center_x_id - self.region_y_att = self.data.center_y_id - self.region_xy_ids = [self.region_x_att, self.region_y_att] - self._viewer_state.add_global_callback(self._update_scatter_region) self.state.add_global_callback(self._update_scatter_region) self._set_axes(axes) @@ -644,6 +629,21 @@ def _update_data(self): if len(self.mpl_artists) == 0: return + if self.layer is not None: + if isinstance(self.layer, BaseData): + data = self.layer + else: + data = self.layer.data + + self.data = data + self.region_att = data._extended_component_id + + self.region_comp = data.get_component(self.region_att) + + self.region_x_att = self.data.center_x_id + self.region_y_att = self.data.center_y_id + self.region_xy_ids = [self.region_x_att, self.region_y_att] + try: # These must be special attributes that are linked to a region_att if ((not self.data.check_if_linked_cid(self.region_xy_ids, self._viewer_state.x_att)) and From d00bbc9c849b506efad6f24ac1076616cd1c7c3e Mon Sep 17 00:00:00 2001 From: Jonathan Foster Date: Tue, 26 Sep 2023 10:53:43 -0400 Subject: [PATCH 03/10] Update for new RegionData conv functions --- glue/viewers/image/viewer.py | 2 +- glue/viewers/scatter/layer_artist.py | 32 +++++++--------------------- 2 files changed, 9 insertions(+), 25 deletions(-) diff --git a/glue/viewers/image/viewer.py b/glue/viewers/image/viewer.py index 354f8732f..62fad9d36 100644 --- a/glue/viewers/image/viewer.py +++ b/glue/viewers/image/viewer.py @@ -5,7 +5,7 @@ from glue.core.subset import roi_to_subset_state from glue.core.coordinates import Coordinates, LegacyCoordinates from glue.core.coordinate_helpers import dependent_axes -from glue.core.data import RegionData +from glue.core.data_region import RegionData from glue.viewers.scatter.layer_artist import ScatterLayerArtist, ScatterRegionLayerArtist from glue.viewers.image.layer_artist import ImageLayerArtist, ImageSubsetLayerArtist diff --git a/glue/viewers/scatter/layer_artist.py b/glue/viewers/scatter/layer_artist.py index 6eb215c85..dee4006a6 100644 --- a/glue/viewers/scatter/layer_artist.py +++ b/glue/viewers/scatter/layer_artist.py @@ -634,20 +634,12 @@ def _update_data(self): data = self.layer else: data = self.layer.data - - self.data = data - self.region_att = data._extended_component_id - - self.region_comp = data.get_component(self.region_att) - - self.region_x_att = self.data.center_x_id - self.region_y_att = self.data.center_y_id - self.region_xy_ids = [self.region_x_att, self.region_y_att] + region_att = data.extended_component_id try: # These must be special attributes that are linked to a region_att - if ((not self.data.check_if_linked_cid(self.region_xy_ids, self._viewer_state.x_att)) and - (not self.data.check_if_linked_cid(self.region_xy_ids, self._viewer_state.x_att_world))): + if ((not data.linked_to_center_comp(self._viewer_state.x_att)) and + (not data.linked_to_center_comp(self._viewer_state.x_att_world))): raise IncompatibleAttribute x = ensure_numerical(self.layer[self._viewer_state.x_att].ravel()) except (IncompatibleAttribute, IndexError): @@ -659,8 +651,8 @@ def _update_data(self): try: # These must be special attributes that are linked to a region_att - if ((not self.data.check_if_linked_cid(self.region_xy_ids, self._viewer_state.y_att)) and - (not self.data.check_if_linked_cid(self.region_xy_ids, self._viewer_state.y_att_world))): + if ((not data.linked_to_center_comp(self._viewer_state.y_att)) and + (not data.linked_to_center_comp(self._viewer_state.y_att_world))): raise IncompatibleAttribute y = ensure_numerical(self.layer[self._viewer_state.y_att].ravel()) except (IncompatibleAttribute, IndexError): @@ -670,18 +662,10 @@ def _update_data(self): else: self.enable() - regions = self.layer[self.region_att] - - x_conversion_func = self.data.get_transform_to_cid(self.region_x_att, self._viewer_state.x_att) - y_conversion_func = self.data.get_transform_to_cid(self.region_y_att, self._viewer_state.y_att) + regions = self.layer[region_att] - def conversion_func(x, y, z=None): - if x_conversion_func: - x = x_conversion_func(x) - if y_conversion_func: - y = y_conversion_func(y) - return tuple([x, y]) - regions = np.array([transform(conversion_func, g) for g in regions]) + tfunc = data.get_transform_to_cids([self._viewer_state.x_att, self._viewer_state.y_att]) + regions = np.array([transform(tfunc, g) for g in regions]) # If we are using world coordinates (i.e. the regions are specified in world coordinates) # we need to transform the geometries of the regions into pixel coordinates for display From 196a923a519f6b71a6b9c78ab5a56de2efdc36ff Mon Sep 17 00:00:00 2001 From: Jonathan Foster Date: Tue, 26 Sep 2023 16:53:25 -0400 Subject: [PATCH 04/10] Improve logic for world coordinate display --- glue/viewers/scatter/layer_artist.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/glue/viewers/scatter/layer_artist.py b/glue/viewers/scatter/layer_artist.py index dee4006a6..1a032f5fb 100644 --- a/glue/viewers/scatter/layer_artist.py +++ b/glue/viewers/scatter/layer_artist.py @@ -664,16 +664,23 @@ def _update_data(self): regions = self.layer[region_att] - tfunc = data.get_transform_to_cids([self._viewer_state.x_att, self._viewer_state.y_att]) - regions = np.array([transform(tfunc, g) for g in regions]) # If we are using world coordinates (i.e. the regions are specified in world coordinates) # we need to transform the geometries of the regions into pixel coordinates for display # Note that this calls a custom version of the transform function from shapely # to accomodate glue WCS objects if self._viewer_state._display_world: + # First, convert to world coordinates + tfunc = data.get_transform_to_cids([self._viewer_state.x_att_world, self._viewer_state.y_att_world]) + regions = np.array([transform(tfunc, g) for g in regions]) + + # Then convert to pixels for display world2pix = self._viewer_state.reference_data.coords.world_to_pixel_values regions = np.array([transform_shapely(world2pix, g) for g in regions]) + else: + tfunc = data.get_transform_to_cids([self._viewer_state.x_att, self._viewer_state.y_att]) + regions = np.array([transform(tfunc, g) for g in regions]) + # decompose GeometryCollections geoms, multiindex = _sanitize_geoms(regions, prefix="Geom") self.multiindex_geometry = multiindex From 41653fee2ced3b4a81b70a4f5390aa7889e36e26 Mon Sep 17 00:00:00 2001 From: Jonathan Foster Date: Tue, 26 Sep 2023 16:55:42 -0400 Subject: [PATCH 05/10] Fix formatting --- glue/viewers/scatter/layer_artist.py | 1 - 1 file changed, 1 deletion(-) diff --git a/glue/viewers/scatter/layer_artist.py b/glue/viewers/scatter/layer_artist.py index 1a032f5fb..f73c5d364 100644 --- a/glue/viewers/scatter/layer_artist.py +++ b/glue/viewers/scatter/layer_artist.py @@ -664,7 +664,6 @@ def _update_data(self): regions = self.layer[region_att] - # If we are using world coordinates (i.e. the regions are specified in world coordinates) # we need to transform the geometries of the regions into pixel coordinates for display # Note that this calls a custom version of the transform function from shapely From ee092bf656d21663536360a7d4a4cd50ed60dbfb Mon Sep 17 00:00:00 2001 From: Jonathan Foster Date: Mon, 16 Oct 2023 13:04:08 -0400 Subject: [PATCH 06/10] Update name of callback for when components change --- glue/viewers/scatter/layer_artist.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/glue/viewers/scatter/layer_artist.py b/glue/viewers/scatter/layer_artist.py index f73c5d364..ee7a2eb18 100644 --- a/glue/viewers/scatter/layer_artist.py +++ b/glue/viewers/scatter/layer_artist.py @@ -765,7 +765,7 @@ def update(self): self.redraw() @defer_draw - def update_component_limits(self, components_changed): + def _on_components_changed(self, components_changed): for limit_helper in [self.state.cmap_lim_helper]: if limit_helper.attribute in components_changed: limit_helper.update_values(force=True) From 8a1044deaa7e013a1874950ee8671fe4539c16d3 Mon Sep 17 00:00:00 2001 From: Jonathan Foster Date: Mon, 16 Oct 2023 15:02:54 -0400 Subject: [PATCH 07/10] Add a percentile combo for region colors --- glue/viewers/scatter/layer_artist.py | 1 + glue/viewers/scatter/state.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/glue/viewers/scatter/layer_artist.py b/glue/viewers/scatter/layer_artist.py index ee7a2eb18..be737c401 100644 --- a/glue/viewers/scatter/layer_artist.py +++ b/glue/viewers/scatter/layer_artist.py @@ -766,6 +766,7 @@ def update(self): @defer_draw def _on_components_changed(self, components_changed): + import pdb; pdb.set_trace() for limit_helper in [self.state.cmap_lim_helper]: if limit_helper.attribute in components_changed: limit_helper.update_values(force=True) diff --git a/glue/viewers/scatter/state.py b/glue/viewers/scatter/state.py index 1831e7abe..528182bd3 100644 --- a/glue/viewers/scatter/state.py +++ b/glue/viewers/scatter/state.py @@ -516,6 +516,8 @@ class ScatterRegionLayerState(MatplotlibLayerState): cmap_vmin = DDCProperty(docstring="The lower level for the colormap") cmap_vmax = DDCProperty(docstring="The upper level for the colormap") cmap = DDCProperty(docstring="The colormap to use (when in colormap mode)") + percentile = DDSCProperty(docstring='The percentile value used to ' + 'automatically calculate levels') fill = DDCProperty(True, docstring="Whether to fill the regions") @@ -526,11 +528,23 @@ def __init__(self, viewer_state=None, layer=None, **kwargs): self.cmap_lim_helper = StateAttributeLimitsHelper(self, attribute='cmap_att', lower='cmap_vmin', upper='cmap_vmax', + percentile='percentile', limits_cache=self.limits_cache) + self.cmap_att_helper = ComponentIDComboHelper(self, 'cmap_att', numeric=True, datetime=False, categorical=True) + percentile_display = {100: 'Min/Max', + 99.5: '99.5%', + 99: '99%', + 95: '95%', + 90: '90%', + 'Custom': 'Custom'} + + ScatterRegionLayerState.percentile.set_choices(self, [100, 99.5, 99, 95, 90, 'Custom']) + ScatterRegionLayerState.percentile.set_display_func(self, percentile_display.get) + ScatterRegionLayerState.cmap_mode.set_choices(self, ['Fixed', 'Linear']) if self.viewer_state is not None: From ed8c1a1879521098955e17380678c93061fb3689 Mon Sep 17 00:00:00 2001 From: Jonathan Foster Date: Tue, 31 Oct 2023 16:12:31 -0400 Subject: [PATCH 08/10] Move RegionData out of glue.core.data --- glue/core/data.py | 201 ---------------------------------------------- 1 file changed, 201 deletions(-) diff --git a/glue/core/data.py b/glue/core/data.py index 87ae4d810..6313eace5 100644 --- a/glue/core/data.py +++ b/glue/core/data.py @@ -2059,204 +2059,3 @@ def pixel_label(i, ndim): if 1 <= ndim <= 3: label += " [{0}]".format('xyz'[ndim - 1 - i]) return label - - -class RegionData(Data): - """ - A glue Data object for storing data that is associated with a region. - - This object can be used when a dataset describes 2D regions or 1D ranges. It - contains exactly one :class:`~glue.core.component.ExtendedComponent` object - which contains the boundaries of the regions, and must also contain - one or two components that give the center of the regions in whatever data - coordinates the regions are described in. Links in glue are not made - directly on the :class:`~glue.core.component.ExtendedComponent`, but instead - on the center components. - - Thus, a subset that includes the center of a region will include that region, - but a subset that includes just a little part of the region will not include - that region. These center components are not the same pixel components. For - example, a dataset that is a table of 2D regions will have a single - :class:`~glue.core.component.CoordinateComponent`, but must have two of these center - components. - - A typical use case for this object is to store the properties of geographic - regions, where the boundaries of the regions are stored in an - :class:`~glue.core.component.ExtendedComponent` and the centers of the - regions are stored in two components, one for the longitude and one for the - latitude. Additional components may describe arbitrary properties of these - geographic regions (e.g. population, area, etc). - - - Parameters - ---------- - label : `str`, optional - The label of the data. - coords : :class:`~glue.core.coordinates.Coordinates`, optional - The coordinates associated with the data. - **kwargs - All other keyword arguments are passed to the :class:`~glue.core.data.Data` - constructor. - - Attributes - ---------- - extended_component_id : :class:`~glue.core.component_id.ComponentID` - The ID of the :class:`~glue.core.component.ExtendedComponent` object - that contains the boundaries of the regions. - center_x_id : :class:`~glue.core.component_id.ComponentID` - The ID of the Component object that contains the x-coordinate of the - center of the regions. This is actually stored in the component - with the extended_component_id, but it is convenient to have it here. - center_y_id : :class:`~glue.core.component_id.ComponentID` - The ID of the Component object that contains the y-coordinate of the - center of the regions. This is actually stored in the component - with the extended_component_id, but it is convenient to have it here. - - Examples - -------- - - There are two main options for initializing a :class:`~glue.core.data.RegionData` - object. The first is to simply pass in a list of ``Shapely.Geometry`` objects - with dimesionality N, from which we will create N+1 components: one - :class:`~glue.core.component.ExtendedComponent` with the boundaries, and N - regular Component(s) with the center coordinates computed from the Shapley - method :meth:`~shapely.GeometryCollection.representative_point`: - - >>> geometries = [shapely.geometry.Point(0, 0).buffer(1), shapely.geometry.Point(1, 1).buffer(1)] - >>> my_region_data = RegionData(label='My Regions', boundary=geometries) - - This will create a :class:`~glue.core.data.RegionData` object with three - components: one :class:`~glue.core.component.ExtendedComponent` with label - "geo" and two regular Components with labels "Center [x] for boundary" - and "Center [y] for boundary". - - The second is to explicitly create an :class:`~glue.core.component.ExtendedComponent` - (which requires passing in the ComponentIDs for the center coordinates) and - then use `add_component` to add this component to a :class:`~glue.core.data.RegionData` - object. You might use this approach if your dataset already contains points that - represent the centers of your regions and you want to avoid re-calculating them. For example: - - >>> center_x = [0, 1] - >>> center_y = [0, 1] - >>> geometries = [shapely.geometry.Point(0, 0).buffer(1), shapely.geometry.Point(1, 1).buffer(1)] - - >>> my_region_data = RegionData(label='My Regions') - >>> # Region IDs are created and returned when we add a Component to a Data object - >>> cen_x_id = my_region_data.add_component(center_x, label='Center [x]') - >>> cen_y_id = my_region_data.add_component(center_y, label='Center [y]') - >>> extended_comp = ExtendedComponent(geometries, center_comp_ids=[cen_x_id, cen_y_id]) - >>> my_region_data.add_component(extended_comp, label='boundaries') - - """ - - def __init__(self, label="", coords=None, **kwargs): - self._extended_component_id = None - self._center_x_id = None - self._center_y_id = None - # __init__ calls add_component which deals with ExtendedComponent logic - super().__init__(label=label, coords=coords, **kwargs) - - def __repr__(self): - return f'RegionData (label: {self.label} | extended_component: {self.extended_component_id})' - - @property - def center_x_id(self): - return self.get_component(self.extended_component_id).x - - @property - def center_y_id(self): - return self.get_component(self.extended_component_id).y - - @property - def extended_component_id(self): - return self._extended_component_id - - @contract(component='component_like', label='cid_like') - def add_component(self, component, label): - """ Add a new component to this data set, allowing only one :class:`~glue.core.component.ExtendedComponent` - - If component is an array of Shapely objects then we use - :meth:`~shapely.GeometryCollection.representative_point`: to - create two new components for the center coordinates of the regions and - add them to the :class:`~glue.core.data.RegionData` object as well. - - If component is an :class:`~glue.core.component.ExtendedComponent`, - then we simply add it to the :class:`~glue.core.data.RegionData` object. - - We do this here instead of extending ``Component.autotyped`` because - we only want to use :class:`~glue.core.component.ExtendedComponent` objects - in the context of a :class:`~glue.core.data.RegionData` object. - - Parameters - ---------- - component : :class:`~glue.core.component.Component` or array-like - Object to add. If this is an array of Shapely objects, then we - create two new components for the center coordinates of the regions - as well. - label : `str` or :class:`~glue.core.component_id.ComponentID` - The label. If this is a string, a new - :class:`glue.core.component_id.ComponentID` - with this label will be created and associated with the Component. - - Raises - ------ - `ValueError`, if the :class:`~glue.core.data.RegionData` already has an extended component - """ - - if not isinstance(component, Component): - if all(isinstance(s, shapely.Geometry) for s in component): - center_x = [] - center_y = [] - for s in component: - rep = s.representative_point() - center_x.append(rep.x) - center_y.append(rep.y) - cen_x_id = super().add_component(np.asarray(center_x), f"Center [x] for {label}") - cen_y_id = super().add_component(np.asarray(center_y), f"Center [y] for {label}") - ext_component = ExtendedComponent(np.asarray(component), center_comp_ids=[cen_x_id, cen_y_id]) - self._extended_component_id = super().add_component(ext_component, label) - return self._extended_component_id - - if isinstance(component, ExtendedComponent): - if self.extended_component_id is not None: - raise ValueError(f"Cannot add another ExtendedComponent; existing extended component: {self.extended_component_id}") - else: - self._extended_component_id = super().add_component(component, label) - return self._extended_component_id - else: - return super().add_component(component, label) - - def get_transform_to_cid(self, this_cid, other_cid): - """ - Returns the conversion function that maps cid to the coordinates - that the ExtendedComponent are in (i.e. self.region_x_att and self.region_y_att) - """ - func = None - link = self._get_external_link(other_cid) - if this_cid in link.get_from_ids(): - func = link.get_using() - elif this_cid in link.get_to_ids(): - func = link.get_inverse() - if func: - return func - else: - return None - - def check_if_linked_cid(self, target_cids, other_cid): - """ - Check if a ComponentID is in the set of components that regions are over. - """ - from glue.core.link_manager import is_equivalent_cid # avoid circular import - - for target_cid in target_cids: - if is_equivalent_cid(self, target_cid, other_cid): - return True - else: - link = self._get_external_link(other_cid) - if not link: - return False - for target_cid in target_cids: - if target_cid in link: - return True - else: - return False From 8d6f5a8ee633bc1c88baef66bc7575b6341cc010 Mon Sep 17 00:00:00 2001 From: Jonathan Foster Date: Tue, 31 Oct 2023 16:20:30 -0400 Subject: [PATCH 09/10] Remove debugging --- glue/viewers/scatter/layer_artist.py | 1 - 1 file changed, 1 deletion(-) diff --git a/glue/viewers/scatter/layer_artist.py b/glue/viewers/scatter/layer_artist.py index be737c401..ee7a2eb18 100644 --- a/glue/viewers/scatter/layer_artist.py +++ b/glue/viewers/scatter/layer_artist.py @@ -766,7 +766,6 @@ def update(self): @defer_draw def _on_components_changed(self, components_changed): - import pdb; pdb.set_trace() for limit_helper in [self.state.cmap_lim_helper]: if limit_helper.attribute in components_changed: limit_helper.update_values(force=True) From f87062cfd3356a9bf4e01383cf37b975317f3d14 Mon Sep 17 00:00:00 2001 From: Jonathan Foster Date: Thu, 9 Nov 2023 09:45:56 -0500 Subject: [PATCH 10/10] Avoid breaking RegionScatter with custom color stretch --- glue/viewers/scatter/layer_artist.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/glue/viewers/scatter/layer_artist.py b/glue/viewers/scatter/layer_artist.py index ee7a2eb18..8560a35f9 100644 --- a/glue/viewers/scatter/layer_artist.py +++ b/glue/viewers/scatter/layer_artist.py @@ -768,5 +768,5 @@ def update(self): def _on_components_changed(self, components_changed): for limit_helper in [self.state.cmap_lim_helper]: if limit_helper.attribute in components_changed: - limit_helper.update_values(force=True) + limit_helper.update_values('attribute') self.redraw()