summaryrefslogtreecommitdiff
path: root/silx/gui/plot/items/core.py
diff options
context:
space:
mode:
Diffstat (limited to 'silx/gui/plot/items/core.py')
-rw-r--r--silx/gui/plot/items/core.py189
1 files changed, 168 insertions, 21 deletions
diff --git a/silx/gui/plot/items/core.py b/silx/gui/plot/items/core.py
index 9426a13..edc6d89 100644
--- a/silx/gui/plot/items/core.py
+++ b/silx/gui/plot/items/core.py
@@ -37,6 +37,7 @@ except ImportError: # Python2 support
from copy import deepcopy
import logging
import enum
+from typing import Optional, Tuple
import warnings
import weakref
@@ -44,7 +45,9 @@ import numpy
import six
from ....utils.deprecation import deprecated
+from ....utils.proxy import docstring
from ....utils.enum import Enum as _Enum
+from ....math.combo import min_max
from ... import qt
from ... import colors
from ...colors import Colormap
@@ -164,6 +167,13 @@ class Item(qt.QObject):
See :class:`ItemChangedType` for flags description.
"""
+ _sigVisibleBoundsChanged = qt.Signal()
+ """Signal emitted when the visible extent of the item in the plot has changed.
+
+ This signal is emitted only if visible extent tracking is enabled
+ (see :meth:`_setVisibleBoundsTracking`).
+ """
+
def __init__(self):
qt.QObject.__init__(self)
self._dirty = True
@@ -176,6 +186,9 @@ class Item(qt.QObject):
self._ylabel = None
self.__name = ''
+ self.__visibleBoundsTracking = False
+ self.__previousVisibleBounds = None
+
self._backendRenderer = None
def getPlot(self):
@@ -194,7 +207,9 @@ class Item(qt.QObject):
"""
if plot is not None and self._plotRef is not None:
raise RuntimeError('Trying to add a node at two places.')
+ self.__disconnectFromPlotWidget()
self._plotRef = None if plot is None else weakref.ref(plot)
+ self.__connectToPlotWidget()
self._updated()
def getBounds(self): # TODO return a Bounds object rather than a tuple
@@ -300,6 +315,97 @@ class Item(qt.QObject):
info = deepcopy(info)
self._info = info
+ def getVisibleBounds(self) -> Optional[Tuple[float,float,float,float]]:
+ """Returns visible bounds of the item bounding box in the plot area.
+
+ :returns:
+ (xmin, xmax, ymin, ymax) in data coordinates of the visible area or
+ None if item is not visible in the plot area.
+ :rtype: Union[List[float],None]
+ """
+ plot = self.getPlot()
+ bounds = self.getBounds()
+ if plot is None or bounds is None or not self.isVisible():
+ return None
+
+ xmin, xmax = numpy.clip(bounds[:2], *plot.getXAxis().getLimits())
+ ymin, ymax = numpy.clip(
+ bounds[2:], *plot.getYAxis(self.__getYAxis()).getLimits())
+
+ if xmin == xmax or ymin == ymax: # Outside the plot area
+ return None
+ else:
+ return xmin, xmax, ymin, ymax
+
+ def _isVisibleBoundsTracking(self) -> bool:
+ """Returns True if visible bounds changes are tracked.
+
+ When enabled, :attr:`_sigVisibleBoundsChanged` is emitted upon changes.
+ :rtype: bool
+ """
+ return self.__visibleBoundsTracking
+
+ def _setVisibleBoundsTracking(self, enable: bool) -> None:
+ """Set whether or not to track visible bounds changes.
+
+ :param bool enable:
+ """
+ if enable != self.__visibleBoundsTracking:
+ self.__disconnectFromPlotWidget()
+ self.__previousVisibleBounds = None
+ self.__visibleBoundsTracking = enable
+ self.__connectToPlotWidget()
+
+ def __getYAxis(self) -> str:
+ """Returns current Y axis ('left' or 'right')"""
+ return self.getYAxis() if isinstance(self, YAxisMixIn) else 'left'
+
+ def __connectToPlotWidget(self) -> None:
+ """Connect to PlotWidget signals and install event filter"""
+ if not self._isVisibleBoundsTracking():
+ return
+
+ plot = self.getPlot()
+ if plot is not None:
+ for axis in (plot.getXAxis(), plot.getYAxis(self.__getYAxis())):
+ axis.sigLimitsChanged.connect(self._visibleBoundsChanged)
+
+ plot.installEventFilter(self)
+
+ self._visibleBoundsChanged()
+
+ def __disconnectFromPlotWidget(self) -> None:
+ """Disconnect from PlotWidget signals and remove event filter"""
+ if not self._isVisibleBoundsTracking():
+ return
+
+ plot = self.getPlot()
+ if plot is not None:
+ for axis in (plot.getXAxis(), plot.getYAxis(self.__getYAxis())):
+ axis.sigLimitsChanged.disconnect(self._visibleBoundsChanged)
+
+ plot.removeEventFilter(self)
+
+ def _visibleBoundsChanged(self, *args) -> None:
+ """Check if visible extent actually changed and emit signal"""
+ if not self._isVisibleBoundsTracking():
+ return # No visible extent tracking
+
+ plot = self.getPlot()
+ if plot is None or not plot.isVisible():
+ return # No plot or plot not visible
+
+ extent = self.getVisibleBounds()
+ if extent != self.__previousVisibleBounds:
+ self.__previousVisibleBounds = extent
+ self._sigVisibleBoundsChanged.emit()
+
+ def eventFilter(self, watched, event):
+ """Event filter to handle PlotWidget show events"""
+ if watched is self.getPlot() and event.type() == qt.QEvent.Show:
+ self._visibleBoundsChanged()
+ return super().eventFilter(watched, event)
+
def _updated(self, event=None, checkVisibility=True):
"""Mark the item as dirty (i.e., needing update).
@@ -375,6 +481,29 @@ class Item(qt.QObject):
return PickingResult(self, indices)
+class DataItem(Item):
+ """Item with a data extent in the plot"""
+
+ def _boundsChanged(self, checkVisibility: bool=True) -> None:
+ """Call this method in subclass when data bounds has changed.
+
+ :param bool checkVisibility:
+ """
+ if not checkVisibility or self.isVisible():
+ self._visibleBoundsChanged()
+
+ # TODO hackish data range implementation
+ plot = self.getPlot()
+ if plot is not None:
+ plot._invalidateDataRange()
+
+ @docstring(Item)
+ def setVisible(self, visible: bool):
+ if visible != self.isVisible():
+ self._boundsChanged(checkVisibility=False)
+ super().setVisible(visible)
+
+
# Mix-in classes ##############################################################
class ItemMixInBase(object):
@@ -836,6 +965,22 @@ class YAxisMixIn(ItemMixInBase):
assert yaxis in ('left', 'right')
if yaxis != self._yaxis:
self._yaxis = yaxis
+ # Handle data extent changed for DataItem
+ if isinstance(self, DataItem):
+ self._boundsChanged()
+
+ # Handle visible extent changed
+ if self._isVisibleBoundsTracking():
+ # Switch Y axis signal connection
+ plot = self.getPlot()
+ if plot is not None:
+ previousYAxis = 'left' if self.getXAxis() == 'right' else 'right'
+ plot.getYAxis(previousYAxis).sigLimitsChanged.disconnect(
+ self._visibleBoundsChanged)
+ plot.getYAxis(self.getYAxis()).sigLimitsChanged.connect(
+ self._visibleBoundsChanged)
+ self._visibleBoundsChanged()
+
self._updated(ItemChangedType.YAXIS)
@@ -1066,6 +1211,16 @@ class ScatterVisualizationMixIn(ItemMixInBase):
Available reduction functions are: 'mean' (default), 'count', 'sum'.
"""
+ DATA_BOUNDS_HINT = 'data_bounds_hint'
+ """The expected bounds of the data in data coordinates.
+
+ A 2-tuple of 2-tuple: ((ymin, ymax), (xmin, xmax)).
+ This provides a hint for the data ranges in both dimensions.
+ It is eventually enlarged with actually data ranges.
+
+ WARNING: dimension 0 i.e., Y first.
+ """
+
_SUPPORTED_VISUALIZATION_PARAMETER_VALUES = {
VisualizationParameter.GRID_MAJOR_ORDER: ('row', 'column'),
VisualizationParameter.BINNED_STATISTIC_FUNCTION: ('mean', 'count', 'sum'),
@@ -1191,7 +1346,7 @@ class ScatterVisualizationMixIn(ItemMixInBase):
return self.getVisualizationParameter(parameter)
-class PointsBase(Item, SymbolMixIn, AlphaMixIn):
+class PointsBase(DataItem, SymbolMixIn, AlphaMixIn):
"""Base class for :class:`Curve` and :class:`Scatter`"""
# note: _logFilterData must be overloaded if you overload
# getData to change its signature
@@ -1201,7 +1356,7 @@ class PointsBase(Item, SymbolMixIn, AlphaMixIn):
on top of images."""
def __init__(self):
- Item.__init__(self)
+ DataItem.__init__(self)
SymbolMixIn.__init__(self)
AlphaMixIn.__init__(self)
self._x = ()
@@ -1244,18 +1399,18 @@ class PointsBase(Item, SymbolMixIn, AlphaMixIn):
# expand errorbars to 2xN
if error.size == 1: # Scalar
error = numpy.full(
- (2, len(value)), error, dtype=numpy.float)
+ (2, len(value)), error, dtype=numpy.float64)
elif error.ndim == 1: # N array
newError = numpy.empty((2, len(value)),
- dtype=numpy.float)
+ dtype=numpy.float64)
newError[0, :] = error
newError[1, :] = error
error = newError
elif error.size == 2 * len(value): # 2xN array
error = numpy.array(
- error, copy=True, dtype=numpy.float)
+ error, copy=True, dtype=numpy.float64)
else:
_logger.error("Unhandled error array")
@@ -1309,9 +1464,9 @@ class PointsBase(Item, SymbolMixIn, AlphaMixIn):
if numpy.any(clipped):
# copy to keep original array and convert to float
- x = numpy.array(x, copy=True, dtype=numpy.float)
+ x = numpy.array(x, copy=True, dtype=numpy.float64)
x[clipped] = numpy.nan
- y = numpy.array(y, copy=True, dtype=numpy.float)
+ y = numpy.array(y, copy=True, dtype=numpy.float64)
y[clipped] = numpy.nan
if xPositive and xerror is not None:
@@ -1347,15 +1502,11 @@ class PointsBase(Item, SymbolMixIn, AlphaMixIn):
else:
x, y, _xerror, _yerror = data
- with warnings.catch_warnings():
- warnings.simplefilter('ignore', category=RuntimeWarning)
- # Ignore All-NaN slice encountered
- self._boundsCache[(xPositive, yPositive)] = (
- numpy.nanmin(x),
- numpy.nanmax(x),
- numpy.nanmin(y),
- numpy.nanmax(y)
- )
+ xmin, xmax = min_max(x, finite=True)
+ ymin, ymax = min_max(y, finite=True)
+ self._boundsCache[(xPositive, yPositive)] = tuple([
+ (bound if bound is not None else numpy.nan)
+ for bound in (xmin, xmax, ymin, ymax)])
return self._boundsCache[(xPositive, yPositive)]
def _getCachedData(self):
@@ -1477,11 +1628,7 @@ class PointsBase(Item, SymbolMixIn, AlphaMixIn):
self._filteredCache = {} # Reset cached filtered data
self._clippedCache = {} # Reset cached clipped bool array
- # TODO hackish data range implementation
- if self.isVisible():
- plot = self.getPlot()
- if plot is not None:
- plot._invalidateDataRange()
+ self._boundsChanged()
self._updated(ItemChangedType.DATA)