#  coding: utf-8
# /*##########################################################################
#
# Copyright (c) 2014-2016 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
# ###########################################################################*/
"""Implementation of the interaction for the :class:`Plot`."""
__authors__ = ["T. Vincent"]
__license__ = "MIT"
__date__ = "18/02/2016"
import math
import numpy
import time
import weakref
from . import Colors
from .Interaction import (ClickOrDrag, LEFT_BTN, RIGHT_BTN,
                          State, StateMachine)
from .PlotEvents import (prepareCurveSignal, prepareDrawingSignal,
                         prepareHoverSignal, prepareImageSignal,
                         prepareMarkerSignal, prepareMouseSignal)
from .BackendBase import (CURSOR_POINTING, CURSOR_SIZE_HOR,
                          CURSOR_SIZE_VER, CURSOR_SIZE_ALL)
# Float 32 info ###############################################################
# Using min/max value below limits of float32
# so operation with such value (e.g., max - min) do not overflow
FLOAT32_SAFE_MIN = -1e37
FLOAT32_MINPOS = numpy.finfo(numpy.float32).tiny
FLOAT32_SAFE_MAX = 1e37
# TODO double support
# Base class ##################################################################
class _PlotInteraction(object):
    """Base class for interaction handler.
    It provides a weakref to the plot and methods to set/reset overlay.
    """
    def __init__(self, plot):
        """Init.
        :param plot: The plot to apply modifications to.
        """
        self._needReplot = False
        self._selectionAreas = set()
        self._plot = weakref.ref(plot)  # Avoid cyclic-ref
    @property
    def plot(self):
        plot = self._plot()
        assert plot is not None
        return plot
    def setSelectionArea(self, points, fill, color, name=''):
        """Set a polygon selection area overlaid on the plot.
        Multiple simultaneous areas are supported through the name parameter.
        :param points: The 2D coordinates of the points of the polygon
        :type points: An iterable of (x, y) coordinates
        :param str fill: The fill mode: 'hatch', 'solid' or None
        :param color: RGBA color to use
        :type color: list or tuple of 4 float in the range [0, 1]
        :param name: The key associated with this selection area
        """
        points = numpy.asarray(points)
        # TODO Not very nice, but as is for now
        legend = '__SELECTION_AREA__' + name
        fill = bool(fill)  # TODO not very nice either
        self.plot.addItem(points[:, 0], points[:, 1], legend=legend,
                          replace=False,
                          shape='polygon', color=color, fill=fill,
                          overlay=True)
        self._selectionAreas.add(legend)
    def resetSelectionArea(self):
        """Remove all selection areas set by setSelectionArea."""
        for legend in self._selectionAreas:
            self.plot.remove(legend, kind='item')
        self._selectionAreas = set()
# Zoom/Pan ####################################################################
def _scale1DRange(min_, max_, center, scale, isLog):
    """Scale a 1D range given a scale factor and an center point.
    Keeps the values in a smaller range than float32.
    :param float min_: The current min value of the range.
    :param float max_: The current max value of the range.
    :param float center: The center of the zoom (i.e., invariant point).
    :param float scale: The scale to use for zoom
    :param bool isLog: Whether using log scale or not.
    :return: The zoomed range.
    :rtype: tuple of 2 floats: (min, max)
    """
    if isLog:
        # Min and center can be < 0 when
        # autoscale is off and switch to log scale
        # max_ < 0 should not happen
        min_ = numpy.log10(min_) if min_ > 0. else FLOAT32_MINPOS
        center = numpy.log10(center) if center > 0. else FLOAT32_MINPOS
        max_ = numpy.log10(max_) if max_ > 0. else FLOAT32_MINPOS
    if min_ == max_:
        return min_, max_
    offset = (center - min_) / (max_ - min_)
    range_ = (max_ - min_) / scale
    newMin = center - offset * range_
    newMax = center + (1. - offset) * range_
    if isLog:
        # No overflow as exponent is log10 of a float32
        newMin = pow(10., newMin)
        newMax = pow(10., newMax)
        newMin = numpy.clip(newMin, FLOAT32_MINPOS, FLOAT32_SAFE_MAX)
        newMax = numpy.clip(newMax, FLOAT32_MINPOS, FLOAT32_SAFE_MAX)
    else:
        newMin = numpy.clip(newMin, FLOAT32_SAFE_MIN, FLOAT32_SAFE_MAX)
        newMax = numpy.clip(newMax, FLOAT32_SAFE_MIN, FLOAT32_SAFE_MAX)
    return newMin, newMax
def _applyZoomToPlot(plot, cx, cy, scaleF):
    """Zoom in/out plot given a scale and a center point.
    :param plot: The plot on which to apply zoom.
    :param float cx: X coord in data coordinates of the zoom center.
    :param float cy: Y coord in data coordinates of the zoom center.
    :param float scaleF: Scale factor of zoom.
    """
    dataCenterPos = plot.pixelToData(cx, cy)
    assert dataCenterPos is not None
    xMin, xMax = plot.getGraphXLimits()
    xMin, xMax = _scale1DRange(xMin, xMax, dataCenterPos[0], scaleF,
                               plot.isXAxisLogarithmic())
    yMin, yMax = plot.getGraphYLimits()
    yMin, yMax = _scale1DRange(yMin, yMax, dataCenterPos[1], scaleF,
                               plot.isYAxisLogarithmic())
    dataPos = plot.pixelToData(cx, cy, axis="right")
    assert dataPos is not None
    y2Center = dataPos[1]
    y2Min, y2Max = plot.getGraphYLimits(axis="right")
    y2Min, y2Max = _scale1DRange(y2Min, y2Max, y2Center, scaleF,
                                 plot.isYAxisLogarithmic())
    plot.setLimits(xMin, xMax, yMin, yMax, y2Min, y2Max)
class _ZoomOnWheel(ClickOrDrag, _PlotInteraction):
    """:class:`ClickOrDrag` state machine with zooming on mouse wheel.
    Base class for :class:`Pan` and :class:`Zoom`
    """
    class ZoomIdle(ClickOrDrag.Idle):
        def onWheel(self, x, y, angle):
            scaleF = 1.1 if angle > 0 else 1./1.1
            _applyZoomToPlot(self.machine.plot, x, y, scaleF)
    def __init__(self, plot):
        """Init.
        :param plot: The plot to apply modifications to.
        """
        _PlotInteraction.__init__(self, plot)
        states = {
            'idle': _ZoomOnWheel.ZoomIdle,
            'rightClick': ClickOrDrag.RightClick,
            'clickOrDrag': ClickOrDrag.ClickOrDrag,
            'drag': ClickOrDrag.Drag
        }
        StateMachine.__init__(self, states, 'idle')
# Pan #########################################################################
[docs]class Pan(_ZoomOnWheel):
    """Pan plot content and zoom on wheel state machine."""
    def _pixelToData(self, x, y):
        xData, yData = self.plot.pixelToData(x, y)
        _, y2Data = self.plot.pixelToData(x, y, axis='right')
        return xData, yData, y2Data
    def beginDrag(self, x, y):
        self._previousDataPos = self._pixelToData(x, y)
    def drag(self, x, y):
        xData, yData, y2Data = self._pixelToData(x, y)
        lastX, lastY, lastY2 = self._previousDataPos
        xMin, xMax = self.plot.getGraphXLimits()
        yMin, yMax = self.plot.getGraphYLimits(axis='left')
        y2Min, y2Max = self.plot.getGraphYLimits(axis='right')
        if self.plot.isXAxisLogarithmic():
            try:
                dx = math.log10(xData) - math.log10(lastX)
                newXMin = pow(10., (math.log10(xMin) - dx))
                newXMax = pow(10., (math.log10(xMax) - dx))
            except (ValueError, OverflowError):
                newXMin, newXMax = xMin, xMax
            # Makes sure both values stays in positive float32 range
            if newXMin < FLOAT32_MINPOS or newXMax > FLOAT32_SAFE_MAX:
                newXMin, newXMax = xMin, xMax
        else:
            dx = xData - lastX
            newXMin, newXMax = xMin - dx, xMax - dx
            # Makes sure both values stays in float32 range
            if newXMin < FLOAT32_SAFE_MIN or newXMax > FLOAT32_SAFE_MAX:
                newXMin, newXMax = xMin, xMax
        if self.plot.isYAxisLogarithmic():
            try:
                dy = math.log10(yData) - math.log10(lastY)
                newYMin = pow(10., math.log10(yMin) - dy)
                newYMax = pow(10., math.log10(yMax) - dy)
                dy2 = math.log10(y2Data) - math.log10(lastY2)
                newY2Min = pow(10., math.log10(y2Min) - dy2)
                newY2Max = pow(10., math.log10(y2Max) - dy2)
            except (ValueError, OverflowError):
                newYMin, newYMax = yMin, yMax
                newY2Min, newY2Max = y2Min, y2Max
            # Makes sure y and y2 stays in positive float32 range
            if (newYMin < FLOAT32_MINPOS or newYMax > FLOAT32_SAFE_MAX or
                    newY2Min < FLOAT32_MINPOS or newY2Max > FLOAT32_SAFE_MAX):
                newYMin, newYMax = yMin, yMax
                newY2Min, newY2Max = y2Min, y2Max
        else:
            dy = yData - lastY
            dy2 = y2Data - lastY2
            newYMin, newYMax = yMin - dy, yMax - dy
            newY2Min, newY2Max = y2Min - dy2, y2Max - dy2
            # Makes sure y and y2 stays in float32 range
            if (newYMin < FLOAT32_SAFE_MIN or
                    newYMax > FLOAT32_SAFE_MAX or
                    newY2Min < FLOAT32_SAFE_MIN or
                    newY2Max > FLOAT32_SAFE_MAX):
                newYMin, newYMax = yMin, yMax
                newY2Min, newY2Max = y2Min, y2Max
        self.plot.setLimits(newXMin, newXMax,
                            newYMin, newYMax,
                            newY2Min, newY2Max)
        self._previousDataPos = self._pixelToData(x, y)
    def endDrag(self, startPos, endPos):
        del self._previousDataPos
    def cancel(self):
        pass
# Zoom ########################################################################
 
[docs]class Zoom(_ZoomOnWheel):
    """Zoom-in/out state machine.
    Zoom-in on selected area, zoom-out on right click,
    and zoom on mouse wheel.
    """
    _DOUBLE_CLICK_TIMEOUT = 0.4
    def __init__(self, plot, color):
        self.color = color
        self.zoomStack = []
        self._lastClick = 0., None
        super(Zoom, self).__init__(plot)
    def _areaWithAspectRatio(self, x0, y0, x1, y1):
        plotLeft, plotTop, plotW, plotH = self.plot.getPlotBoundsInPixels()
        areaX0, areaY0, areaX1, areaY1 = x0, y0, x1, y1
        if plotH != 0.:
            plotRatio = plotW / float(plotH)
            width, height = math.fabs(x1 - x0), math.fabs(y1 - y0)
            if height != 0. and width != 0.:
                if width / height > plotRatio:
                    areaHeight = width / plotRatio
                    areaX0, areaX1 = x0, x1
                    center = 0.5 * (y0 + y1)
                    areaY0 = center - numpy.sign(y1 - y0) * 0.5 * areaHeight
                    areaY1 = center + numpy.sign(y1 - y0) * 0.5 * areaHeight
                else:
                    areaWidth = height * plotRatio
                    areaY0, areaY1 = y0, y1
                    center = 0.5 * (x0 + x1)
                    areaX0 = center - numpy.sign(x1 - x0) * 0.5 * areaWidth
                    areaX1 = center + numpy.sign(x1 - x0) * 0.5 * areaWidth
        return areaX0, areaY0, areaX1, areaY1
    def click(self, x, y, btn):
        if btn == LEFT_BTN:
            lastClickTime, lastClickPos = self._lastClick
            # Signal mouse double clicked event first
            if (time.time() - lastClickTime) <= self._DOUBLE_CLICK_TIMEOUT:
                # Use position of first click
                eventDict = prepareMouseSignal('mouseDoubleClicked', 'left',
                                               *lastClickPos)
                self.plot.notify(**eventDict)
                self._lastClick = 0., None
            else:
                # Signal mouse clicked event
                dataPos = self.plot.pixelToData(x, y)
                assert dataPos is not None
                eventDict = prepareMouseSignal('mouseClicked', 'left',
                                               dataPos[0], dataPos[1],
                                               x, y)
                self.plot.notify(**eventDict)
                self._lastClick = time.time(), (dataPos[0], dataPos[1], x, y)
            # Zoom-in centered on mouse cursor
            # xMin, xMax = self.plot.getGraphXLimits()
            # yMin, yMax = self.plot.getGraphYLimits()
            # y2Min, y2Max = self.plot.getGraphYLimits(axis="right")
            # self.zoomStack.append((xMin, xMax, yMin, yMax, y2Min, y2Max))
            # self._zoom(x, y, 2)
        elif btn == RIGHT_BTN:
            try:
                xMin, xMax, yMin, yMax, y2Min, y2Max = self.zoomStack.pop()
            except IndexError:
                # Signal mouse clicked event
                dataPos = self.plot.pixelToData(x, y)
                assert dataPos is not None
                eventDict = prepareMouseSignal('mouseClicked', 'right',
                                               dataPos[0], dataPos[1],
                                               x, y)
                self.plot.notify(**eventDict)
            else:
                self.plot.setLimits(xMin, xMax, yMin, yMax, y2Min, y2Max)
    def beginDrag(self, x, y):
        dataPos = self.plot.pixelToData(x, y)
        assert dataPos is not None
        self.x0, self.y0 = x, y
    def drag(self, x1, y1):
        dataPos = self.plot.pixelToData(x1, y1)
        assert dataPos is not None
        if self.plot.isKeepDataAspectRatio():
            area = self._areaWithAspectRatio(self.x0, self.y0, x1, y1)
            areaX0, areaY0, areaX1, areaY1 = area
            areaPoints = ((areaX0, areaY0),
                          (areaX1, areaY0),
                          (areaX1, areaY1),
                          (areaX0, areaY1))
            areaPoints = numpy.array([self.plot.pixelToData(
                x, y, check=False) for (x, y) in areaPoints])
            if self.color != 'video inverted':
                areaColor = list(self.color)
                areaColor[3] *= 0.25
            else:
                areaColor = [1., 1., 1., 1.]
            self.setSelectionArea(areaPoints,
                                  fill=None,
                                  color=areaColor,
                                  name="zoomedArea")
        corners = ((self.x0, self.y0),
                   (self.x0, y1),
                   (x1, y1),
                   (x1, self.y0))
        corners = numpy.array([self.plot.pixelToData(x, y, check=False)
                               for (x, y) in corners])
        self.setSelectionArea(corners, fill=None, color=self.color)
    def endDrag(self, startPos, endPos):
        x0, y0 = startPos
        x1, y1 = endPos
        if x0 != x1 or y0 != y1:  # Avoid empty zoom area
            # Store current zoom state in stack
            xMin, xMax = self.plot.getGraphXLimits()
            yMin, yMax = self.plot.getGraphYLimits()
            y2Min, y2Max = self.plot.getGraphYLimits(axis="right")
            self.zoomStack.append((xMin, xMax, yMin, yMax, y2Min, y2Max))
            if self.plot.isKeepDataAspectRatio():
                x0, y0, x1, y1 = self._areaWithAspectRatio(x0, y0, x1, y1)
            # Convert to data space and set limits
            x0, y0 = self.plot.pixelToData(x0, y0, check=False)
            dataPos = self.plot.pixelToData(
                startPos[0], startPos[1], axis="right", check=False)
            y2_0 = dataPos[1]
            x1, y1 = self.plot.pixelToData(x1, y1, check=False)
            dataPos = self.plot.pixelToData(
                endPos[0], endPos[1], axis="right", check=False)
            y2_1 = dataPos[1]
            xMin, xMax = min(x0, x1), max(x0, x1)
            yMin, yMax = min(y0, y1), max(y0, y1)
            y2Min, y2Max = min(y2_0, y2_1), max(y2_0, y2_1)
            self.plot.setLimits(xMin, xMax, yMin, yMax, y2Min, y2Max)
        self.resetSelectionArea()
    def cancel(self):
        if isinstance(self.state, self.states['drag']):
            self.resetSelectionArea()
# Select ######################################################################
 
[docs]class Select(StateMachine, _PlotInteraction):
    """Base class for drawing selection areas."""
    def __init__(self, plot, parameters, states, state):
        """Init a state machine.
        :param plot: The plot to apply changes to.
        :param dict parameters: A dict of parameters such as color.
        :param dict states: The states of the state machine.
        :param str state: The name of the initial state.
        """
        _PlotInteraction.__init__(self, plot)
        self.parameters = parameters
        StateMachine.__init__(self, states, state)
    def onWheel(self, x, y, angle):
        scaleF = 1.1 if angle > 0 else 1./1.1
        _applyZoomToPlot(self.plot, x, y, scaleF)
    @property
    def color(self):
        return self.parameters.get('color', None)
 
[docs]class SelectPolygon(Select):
    """Drawing selection polygon area state machine."""
    class Idle(State):
        def onPress(self, x, y, btn):
            if btn == LEFT_BTN:
                self.goto('select', x, y)
                return True
    class Select(State):
        def enter(self, x, y):
            dataPos = self.machine.plot.pixelToData(x, y)
            assert dataPos is not None
            self.points = [dataPos, dataPos]
        def updateSelectionArea(self):
            self.machine.setSelectionArea(self.points,
                                          fill='hatch',
                                          color=self.machine.color)
            eventDict = prepareDrawingSignal('drawingProgress',
                                             'polygon',
                                             self.points,
                                             self.machine.parameters)
            self.machine.plot.notify(**eventDict)
        def onRelease(self, x, y, btn):
            if btn == LEFT_BTN:
                dataPos = self.machine.plot.pixelToData(x, y)
                assert dataPos is not None
                self.points[-1] = dataPos
                self.updateSelectionArea()
                if self.points[-2] != self.points[-1]:
                    self.points.append(dataPos)
                return True
        def onMove(self, x, y):
            dataPos = self.machine.plot.pixelToData(x, y)
            assert dataPos is not None
            self.points[-1] = dataPos
            self.updateSelectionArea()
        def onPress(self, x, y, btn):
            if btn == RIGHT_BTN:
                self.machine.resetSelectionArea()
                dataPos = self.machine.plot.pixelToData(x, y)
                assert dataPos is not None
                self.points[-1] = dataPos
                if self.points[-2] == self.points[-1]:
                    self.points.pop()
                self.points.append(self.points[0])
                eventDict = prepareDrawingSignal('drawingFinished',
                                                 'polygon',
                                                 self.points,
                                                 self.machine.parameters)
                self.machine.plot.notify(**eventDict)
                self.goto('idle')
    def __init__(self, plot, parameters):
        states = {
            'idle': SelectPolygon.Idle,
            'select': SelectPolygon.Select
        }
        super(SelectPolygon, self).__init__(plot, parameters,
                                            states, 'idle')
    def cancel(self):
        if isinstance(self.state, self.states['select']):
            self.resetSelectionArea()
 
[docs]class Select2Points(Select):
    """Base class for drawing selection based on 2 input points."""
    class Idle(State):
        def onPress(self, x, y, btn):
            if btn == LEFT_BTN:
                self.goto('start', x, y)
                return True
    class Start(State):
        def enter(self, x, y):
            self.machine.beginSelect(x, y)
        def onMove(self, x, y):
            self.goto('select', x, y)
        def onRelease(self, x, y, btn):
            if btn == LEFT_BTN:
                self.goto('select', x, y)
                return True
    class Select(State):
        def enter(self, x, y):
            self.onMove(x, y)
        def onMove(self, x, y):
            self.machine.select(x, y)
        def onRelease(self, x, y, btn):
            if btn == LEFT_BTN:
                self.machine.endSelect(x, y)
                self.goto('idle')
    def __init__(self, plot, parameters):
        states = {
            'idle': Select2Points.Idle,
            'start': Select2Points.Start,
            'select': Select2Points.Select
        }
        super(Select2Points, self).__init__(plot, parameters,
                                            states, 'idle')
    def beginSelect(self, x, y):
        pass
    def select(self, x, y):
        pass
    def endSelect(self, x, y):
        pass
    def cancelSelect(self):
        pass
    def cancel(self):
        if isinstance(self.state, self.states['select']):
            self.cancelSelect()
 
[docs]class SelectRectangle(Select2Points):
    """Drawing rectangle selection area state machine."""
    def beginSelect(self, x, y):
        self.startPt = self.plot.pixelToData(x, y)
        assert self.startPt is not None
    def select(self, x, y):
        dataPos = self.plot.pixelToData(x, y)
        assert dataPos is not None
        self.setSelectionArea((self.startPt,
                              (self.startPt[0], dataPos[1]),
                              dataPos,
                              (dataPos[0], self.startPt[1])),
                              fill='hatch',
                              color=self.color)
        eventDict = prepareDrawingSignal('drawingProgress',
                                         'rectangle',
                                         (self.startPt, dataPos),
                                         self.parameters)
        self.plot.notify(**eventDict)
    def endSelect(self, x, y):
        self.resetSelectionArea()
        dataPos = self.plot.pixelToData(x, y)
        assert dataPos is not None
        eventDict = prepareDrawingSignal('drawingFinished',
                                         'rectangle',
                                         (self.startPt, dataPos),
                                         self.parameters)
        self.plot.notify(**eventDict)
    def cancelSelect(self):
        self.resetSelectionArea()
 
[docs]class SelectLine(Select2Points):
    """Drawing line selection area state machine."""
    def beginSelect(self, x, y):
        self.startPt = self.plot.pixelToData(x, y)
        assert self.startPt is not None
    def select(self, x, y):
        dataPos = self.plot.pixelToData(x, y)
        assert dataPos is not None
        self.setSelectionArea((self.startPt, dataPos),
                              fill='hatch',
                              color=self.color)
        eventDict = prepareDrawingSignal('drawingProgress',
                                         'line',
                                         (self.startPt, dataPos),
                                         self.parameters)
        self.plot.notify(**eventDict)
    def endSelect(self, x, y):
        self.resetSelectionArea()
        dataPos = self.plot.pixelToData(x, y)
        assert dataPos is not None
        eventDict = prepareDrawingSignal('drawingFinished',
                                         'line',
                                         (self.startPt, dataPos),
                                         self.parameters)
        self.plot.notify(**eventDict)
    def cancelSelect(self):
        self.resetSelectionArea()
 
[docs]class Select1Point(Select):
    """Base class for drawing selection area based on one input point."""
    class Idle(State):
        def onPress(self, x, y, btn):
            if btn == LEFT_BTN:
                self.goto('select', x, y)
                return True
    class Select(State):
        def enter(self, x, y):
            self.onMove(x, y)
        def onMove(self, x, y):
            self.machine.select(x, y)
        def onRelease(self, x, y, btn):
            if btn == LEFT_BTN:
                self.machine.endSelect(x, y)
                self.goto('idle')
        def onWheel(self, x, y, angle):
            self.machine.onWheel(x, y, angle)  # Call select default wheel
            self.machine.select(x, y)
    def __init__(self, plot, parameters):
        states = {
            'idle': Select1Point.Idle,
            'select': Select1Point.Select
        }
        super(Select1Point, self).__init__(plot, parameters, states, 'idle')
    def select(self, x, y):
        pass
    def endSelect(self, x, y):
        pass
    def cancelSelect(self):
        pass
    def cancel(self):
        if isinstance(self.state, self.states['select']):
            self.cancelSelect()
 
[docs]class SelectHLine(Select1Point):
    """Drawing a horizontal line selection area state machine."""
    def _hLine(self, y):
        """Return points in data coords of the segment visible in the plot.
        Supports non-orthogonal axes.
        """
        left, top, width, height = self.plot.getPlotBoundsInPixels()
        dataPos1 = self.plot.pixelToData(left, y, check=False)
        dataPos2 = self.plot.pixelToData(left + width, y, check=False)
        return dataPos1, dataPos2
    def select(self, x, y):
        points = self._hLine(y)
        self.setSelectionArea(points, fill='hatch', color=self.color)
        eventDict = prepareDrawingSignal('drawingProgress',
                                         'hline',
                                         points,
                                         self.parameters)
        self.plot.notify(**eventDict)
    def endSelect(self, x, y):
        self.resetSelectionArea()
        eventDict = prepareDrawingSignal('drawingFinished',
                                         'hline',
                                         self._hLine(y),
                                         self.parameters)
        self.plot.notify(**eventDict)
    def cancelSelect(self):
        self.resetSelectionArea()
 
[docs]class SelectVLine(Select1Point):
    """Drawing a vertical line selection area state machine."""
    def _vLine(self, x):
        """Return points in data coords of the segment visible in the plot.
        Supports non-orthogonal axes.
        """
        left, top, width, height = self.plot.getPlotBoundsInPixels()
        dataPos1 = self.plot.pixelToData(x, top, check=False)
        dataPos2 = self.plot.pixelToData(x, top + height, check=False)
        return dataPos1, dataPos2
    def select(self, x, y):
        points = self._vLine(x)
        self.setSelectionArea(points, fill='hatch', color=self.color)
        eventDict = prepareDrawingSignal('drawingProgress',
                                         'vline',
                                         points,
                                         self.parameters)
        self.plot.notify(**eventDict)
    def endSelect(self, x, y):
        self.resetSelectionArea()
        eventDict = prepareDrawingSignal('drawingFinished',
                                         'vline',
                                         self._vLine(x),
                                         self.parameters)
        self.plot.notify(**eventDict)
    def cancelSelect(self):
        self.resetSelectionArea()
# ItemInteraction #############################################################
 
class ItemsInteraction(ClickOrDrag, _PlotInteraction):
    class Idle(ClickOrDrag.Idle):
        def __init__(self, *args, **kw):
            super(ItemsInteraction.Idle, self).__init__(*args, **kw)
            self._hoverMarker = None
        def onWheel(self, x, y, angle):
            scaleF = 1.1 if angle > 0 else 1./1.1
            _applyZoomToPlot(self.machine.plot, x, y, scaleF)
        def onPress(self, x, y, btn):
            if btn == LEFT_BTN:
                marker = self.machine.plot._pickMarker(
                    x, y,
                    lambda marker: marker['selectable'] or marker['draggable'])
                if marker is not None:
                    self.goto('clickOrDrag', x, y)
                    return True
                else:
                    picked = self.machine.plot._pickImageOrCurve(
                        x,
                        y,
                        lambda item: (item['selectable'] or
                                      item.get('draggable', False)))
                    if picked is not None:
                        self.goto('clickOrDrag', x, y)
                        return True
            return False
        def onMove(self, x, y):
            marker = self.machine.plot._pickMarker(x, y)
            if marker is not None:
                dataPos = self.machine.plot.pixelToData(x, y)
                assert dataPos is not None
                eventDict = prepareHoverSignal(
                    marker['legend'], 'marker',
                    dataPos, (x, y),
                    marker['draggable'],
                    marker['selectable'])
                self.machine.plot.notify(**eventDict)
            if marker != self._hoverMarker:
                self._hoverMarker = marker
                if marker is None:
                    self.machine.plot.setGraphCursorShape()
                elif marker['draggable']:
                    if marker['x'] is None:
                        self.machine.plot.setGraphCursorShape(CURSOR_SIZE_VER)
                    elif marker['y'] is None:
                        self.machine.plot.setGraphCursorShape(CURSOR_SIZE_HOR)
                    else:
                        self.machine.plot.setGraphCursorShape(CURSOR_SIZE_ALL)
                elif marker['selectable']:
                    self.machine.plot.setGraphCursorShape(CURSOR_POINTING)
            return True
    def __init__(self, plot):
        _PlotInteraction.__init__(self, plot)
        states = {
            'idle': ItemsInteraction.Idle,
            'clickOrDrag': ClickOrDrag.ClickOrDrag,
            'drag': ClickOrDrag.Drag
        }
        StateMachine.__init__(self, states, 'idle')
    def click(self, x, y, btn):
        # Signal mouse clicked event
        dataPos = self.plot.pixelToData(x, y)
        assert dataPos is not None
        eventDict = prepareMouseSignal('mouseClicked', btn,
                                       dataPos[0], dataPos[1],
                                       x, y)
        self.plot.notify(**eventDict)
        if btn == LEFT_BTN:
            marker = self.plot._pickMarker(
                x, y, lambda marker: marker['selectable'])
            if marker is not None:
                xData, yData = marker['x'], marker['y']
                if xData is None:
                    xData = [0, 1]
                if yData is None:
                    yData = [0, 1]
                eventDict = prepareMarkerSignal('markerClicked',
                                                'left',
                                                marker['legend'],
                                                'marker',
                                                marker['draggable'],
                                                marker['selectable'],
                                                (xData, yData),
                                                (x, y), None)
                self.plot.notify(**eventDict)
            else:
                picked = self.plot._pickImageOrCurve(
                    x, y, lambda item: item['selectable'])
                if picked is None:
                    pass
                elif picked[0] == 'curve':
                    curve = picked[1]
                    dataPos = self.plot.pixelToData(x, y)
                    assert dataPos is not None
                    eventDict = prepareCurveSignal('left',
                                                   curve['legend'],
                                                   'curve',
                                                   picked[2], picked[3],
                                                   dataPos[0], dataPos[1],
                                                   x, y)
                    self.plot.notify(**eventDict)
                elif picked[0] == 'image':
                    image = picked[1]
                    dataPos = self.plot.pixelToData(x, y)
                    assert dataPos is not None
                    # Get corresponding coordinate in image
                    column = int((dataPos[0] - image['origin'][0]) /
                                 float(image['scale'][0]))
                    row = int((dataPos[1] - image['origin'][1]) /
                              float(image['scale'][1]))
                    eventDict = prepareImageSignal('left',
                                                   image['legend'],
                                                   'image',
                                                   column, row,
                                                   dataPos[0], dataPos[1],
                                                   x, y)
                    self.plot.notify(**eventDict)
    def _signalMarkerMovingEvent(self, eventType, marker, x, y):
        assert marker is not None
        xData, yData = marker['x'], marker['y']
        if xData is None:
            xData = [0, 1]
        if yData is None:
            yData = [0, 1]
        posDataCursor = self.plot.pixelToData(x, y)
        assert posDataCursor is not None
        eventDict = prepareMarkerSignal(eventType,
                                        'left',
                                        marker['legend'],
                                        'marker',
                                        marker['draggable'],
                                        marker['selectable'],
                                        (xData, yData),
                                        (x, y),
                                        posDataCursor)
        self.plot.notify(**eventDict)
    def beginDrag(self, x, y):
        self._lastPos = self.plot.pixelToData(x, y)
        assert self._lastPos is not None
        self.imageLegend = None
        self.markerLegend = None
        marker = self.plot._pickMarker(
            x, y, lambda marker: marker['draggable'])
        if marker is not None:
            self.markerLegend = marker['legend']
            self._signalMarkerMovingEvent('markerMoving', marker, x, y)
        else:
            picked = self.plot._pickImageOrCurve(
                x,
                y,
                lambda item: item.get('draggable', False))
            if picked is None:
                self.imageLegend = None
                self.plot.setGraphCursorShape()
            else:
                assert picked[0] == 'image'  # For now only drag images
                self.imageLegend = picked[1]['legend']
    def drag(self, x, y):
        dataPos = self.plot.pixelToData(x, y)
        assert dataPos is not None
        xData, yData = dataPos
        if self.markerLegend is not None:
            marker = self.plot._getMarker(self.markerLegend)
            if marker['constraint'] is not None:
                xData, yData = marker['constraint'](xData, yData)
            self.plot._moveMarker(self.markerLegend, xData, yData)
            self._signalMarkerMovingEvent(
                'markerMoving', self.plot._getMarker(self.markerLegend), x, y)
        if self.imageLegend is not None:
            dx, dy = xData - self._lastPos[0], yData - self._lastPos[1]
            self.plot._moveImage(self.imageLegend, dx, dy)
        self._lastPos = xData, yData
    def endDrag(self, startPos, endPos):
        if self.markerLegend is not None:
            marker = self.plot._getMarker(self.markerLegend)
            posData = [marker['x'], marker['y']]
            if posData[0] is None:
                posData[0] = [0, 1]
            if posData[1] is None:
                posData[1] = [0, 1]
            eventDict = prepareMarkerSignal(
                'markerMoved',
                'left',
                marker['legend'],
                'marker',
                marker['draggable'],
                marker['selectable'],
                posData)
            self.plot.notify(**eventDict)
        self.plot.setGraphCursorShape()
        del self.markerLegend
        del self.imageLegend
        del self._lastPos
    def cancel(self):
        self.plot.setGraphCursorShape()
# FocusManager ################################################################
[docs]class FocusManager(StateMachine):
    """Manages focus across multiple event handlers
    On press an event handler can acquire focus.
    By default it looses focus when all buttons are released.
    """
    class Idle(State):
        def onPress(self, x, y, btn):
            for eventHandler in self.machine.eventHandlers:
                requestFocus = eventHandler.handleEvent('press', x, y, btn)
                if requestFocus:
                    self.goto('focus', eventHandler, btn)
                    break
        def _processEvent(self, *args):
            for eventHandler in self.machine.eventHandlers:
                consumeEvent = eventHandler.handleEvent(*args)
                if consumeEvent:
                    break
        def onMove(self, x, y):
            self._processEvent('move', x, y)
        def onRelease(self, x, y, btn):
            self._processEvent('release', x, y, btn)
        def onWheel(self, x, y, angle):
            self._processEvent('wheel', x, y, angle)
    class Focus(State):
        def enter(self, eventHandler, btn):
            self.eventHandler = eventHandler
            self.focusBtns = set((btn,))
        def onPress(self, x, y, btn):
            self.focusBtns.add(btn)
            self.eventHandler.handleEvent('press', x, y, btn)
        def onMove(self, x, y):
            self.eventHandler.handleEvent('move', x, y)
        def onRelease(self, x, y, btn):
            self.focusBtns.discard(btn)
            requestFocus = self.eventHandler.handleEvent('release', x, y, btn)
            if len(self.focusBtns) == 0 and not requestFocus:
                self.goto('idle')
        def onWheel(self, x, y, angleInDegrees):
            self.eventHandler.handleEvent('wheel', x, y, angleInDegrees)
    def __init__(self, eventHandlers=()):
        self.eventHandlers = list(eventHandlers)
        states = {
            'idle': FocusManager.Idle,
            'focus': FocusManager.Focus
        }
        super(FocusManager, self).__init__(states, 'idle')
    def cancel(self):
        for handler in self.eventHandlers:
            handler.cancel()
 
[docs]class ZoomAndSelect(FocusManager):
    """Combine Zoom and ItemInteraction state machine."""
    def __init__(self, plot, color):
        eventHandlers = ItemsInteraction(plot), Zoom(plot, color)
        super(ZoomAndSelect, self).__init__(eventHandlers)
    @property
    def color(self):
        return self.eventHandlers[1].color
# Interaction mode control ####################################################
 
[docs]class PlotInteraction(object):
    """Proxy to currently use state machine for interaction.
    This allows to switch interactive mode.
    :param plot: The :class:`Plot` to apply interaction to
    """
    _DRAW_MODES = {
        'polygon': SelectPolygon,
        'rectangle': SelectRectangle,
        'line': SelectLine,
        'vline': SelectVLine,
        'hline': SelectHLine,
    }
    def __init__(self, plot):
        self._plot = weakref.ref(plot)  # Avoid cyclic-ref
        self.zoomOnWheel = True
        """True to enable zoom on wheel, False otherwise."""
        # Default event handler
        self._eventHandler = ItemsInteraction(plot)
[docs]    def getInteractiveMode(self):
        """Returns the current interactive mode as a dict.
        The returned dict contains at least the key 'mode'.
        Mode can be: 'draw', 'pan', 'select', 'zoom'.
        It can also contains extra keys (e.g., 'color') specific to a mode
        as provided to :meth:`setInteractiveMode`.
        """
        if isinstance(self._eventHandler, ZoomAndSelect):
            return {'mode': 'zoom', 'color': self._eventHandler.color}
        elif isinstance(self._eventHandler, Select):
            result = self._eventHandler.parameters.copy()
            result['mode'] = 'draw'
            return result
        elif isinstance(self._eventHandler, Pan):
            return {'mode': 'pan'}
        else:
            return {'mode': 'select'}
 
[docs]    def setInteractiveMode(self, mode, color='black',
                           shape='polygon', label=None):
        """Switch the interactive mode.
        :param str mode: The name of the interactive mode.
                         In 'draw', 'pan', 'select', 'zoom'.
        :param color: Only for 'draw' and 'zoom' modes.
                      Color to use for drawing selection area. Default black.
        :type color: Color description: The name as a str or
                     a tuple of 4 floats.
        :param str shape: Only for 'draw' mode. The kind of shape to draw.
                          In 'polygon', 'rectangle', 'line', 'vline', 'hline'.
                          Default is 'polygon'.
        :param str label: Only for 'draw' mode.
        """
        assert mode in ('draw', 'pan', 'select', 'zoom')
        plot = self._plot()
        assert plot is not None
        if mode == 'draw':
            assert shape in self._DRAW_MODES
            eventHandlerClass = self._DRAW_MODES[shape]
            parameters = {
                'shape': shape,
                'label': label,
                'color': Colors.rgba(color)
            }
            self._eventHandler.cancel()
            self._eventHandler = eventHandlerClass(plot, parameters)
        elif mode == 'pan':
            # Ignores color, shape and label
            self._eventHandler.cancel()
            self._eventHandler = Pan(plot)
        elif mode == 'zoom':
            # Ignores shape and label
            if color != 'video inverted':
                color = Colors.rgba(color)
            self._eventHandler.cancel()
            self._eventHandler = ZoomAndSelect(plot, color)
        else:  # Default mode: interaction with plot objects
            # Ignores color, shape and label
            self._eventHandler.cancel()
            self._eventHandler = ItemsInteraction(plot)
 
[docs]    def handleEvent(self, event, *args, **kwargs):
        """Forward event to current interactive mode state machine."""
        if not self.zoomOnWheel and event == 'wheel':
            return  # Discard wheel events
        self._eventHandler.handleEvent(event, *args, **kwargs)