# 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)