Files
Phoenix/wx/lib/plot/polyobjects.py
Scott Talbert 173d079681 Fix a bunch of Python 3.10 issues with pure-Python classes and demos
In Python 3.10, a change[1] was implemented where extension functions
that take integer arguments will no longer silently accept non-integer
arguments (e.g., floats) that can only be converted to integers with a
loss of precision.  This PR fixes most of these issues in the pure-Python
classes and demos by explicitly converting the parameters to int before
passing them to wxWidgets.  There is loss of precision, but this was
happening before (automatically) anyway as most wxWidgets DeviceContext
functions operate using integers.

Additionally, the PR fixes a few sizing issues, mostly with SpinCtrls being
too small on GTK3.

This is an example of the relevant exception:
Traceback (most recent call last):
  File "/usr/lib64/python3.10/site-packages/wx/lib/agw/pygauge.py", line 355, in OnPaint
    r.width = w
TypeError: 'float' object cannot be interpreted as an integer

Fixes #2038.

[1] https://bugs.python.org/issue37999
2021-12-01 14:19:00 -05:00

1529 lines
52 KiB
Python

# -*- coding: utf-8 -*-
# pylint: disable=E1101, C0330, C0103
# E1101: Module X has no Y member
# C0330: Wrong continued indentation
# C0103: Invalid attribute/variable/method name
"""
polyobjects.py
==============
This contains all of the PolyXXX objects used by :mod:`wx.lib.plot`.
"""
__docformat__ = "restructuredtext en"
# Standard Library
import time as _time
import wx
import warnings
from collections import namedtuple
# Third-Party
try:
import numpy as np
except:
msg = """
This module requires the NumPy module, which could not be
imported. It probably is not installed (it's not part of the
standard Python distribution). See the Numeric Python site
(http://numpy.scipy.org) for information on downloading source or
binaries."""
raise ImportError("NumPy not found.\n" + msg)
# Package
from .utils import pendingDeprecation
from .utils import TempStyle
from .utils import pairwise
class PolyPoints(object):
"""
Base Class for lines and markers.
:param points: The points to plot
:type points: list of ``(x, y)`` pairs
:param attr: Additional attributes
:type attr: dict
.. warning::
All methods are private.
"""
def __init__(self, points, attr):
self._points = np.array(points).astype(np.float64)
self._logscale = (False, False)
self._absScale = (False, False)
self._symlogscale = (False, False)
self._pointSize = (1.0, 1.0)
self.currentScale = (1, 1)
self.currentShift = (0, 0)
self.scaled = self.points
self.attributes = {}
self.attributes.update(self._attributes)
for name, value in attr.items():
if name not in self._attributes:
err_txt = "Style attribute incorrect. Should be one of {}"
raise KeyError(err_txt.format(self._attributes.keys()))
self.attributes[name] = value
@property
def logScale(self):
"""
A tuple of ``(x_axis_is_log10, y_axis_is_log10)`` booleans. If a value
is ``True``, then that axis is plotted on a logarithmic base 10 scale.
:getter: Returns the current value of logScale
:setter: Sets the value of logScale
:type: tuple of bool, length 2
:raises ValueError: when setting an invalid value
"""
return self._logscale
@logScale.setter
def logScale(self, logscale):
if not isinstance(logscale, tuple) or len(logscale) != 2:
raise ValueError("`logscale` must be a 2-tuple of bools")
self._logscale = logscale
def setLogScale(self, logscale):
"""
Set to change the axes to plot Log10(values)
Value must be a tuple of booleans (x_axis_bool, y_axis_bool)
.. deprecated:: Feb 27, 2016
Use the :attr:`~wx.lib.plot.polyobjects.PolyPoints.logScale`
property instead.
"""
pendingDeprecation("self.logScale property")
self._logscale = logscale
@property
def symLogScale(self):
"""
.. warning::
Not yet implemented.
A tuple of ``(x_axis_is_SymLog10, y_axis_is_SymLog10)`` booleans.
If a value is ``True``, then that axis is plotted on a symmetric
logarithmic base 10 scale.
A Symmetric Log10 scale means that values can be positive and
negative. Any values less than
:attr:`~wx.lig.plot.PolyPoints.symLogThresh` will be plotted on
a linear scale to avoid the plot going to infinity near 0.
:getter: Returns the current value of symLogScale
:setter: Sets the value of symLogScale
:type: tuple of bool, length 2
:raises ValueError: when setting an invalid value
.. notes::
This is a simplified example of how SymLog works::
if x >= thresh:
x = Log10(x)
elif x =< thresh:
x = -Log10(Abs(x))
else:
x = x
.. seealso::
+ :attr:`~wx.lib.plot.PolyPoints.symLogThresh`
+ See http://matplotlib.org/examples/pylab_examples/symlog_demo.html
for an example.
"""
return self._symlogscale
# TODO: Implement symmetric log scale
@symLogScale.setter
def symLogScale(self, symlogscale, thresh):
raise NotImplementedError("Symmetric Log Scale not yet implemented")
if not isinstance(symlogscale, tuple) or len(symlogscale) != 2:
raise ValueError("`symlogscale` must be a 2-tuple of bools")
self._symlogscale = symlogscale
@property
def symLogThresh(self):
"""
.. warning::
Not yet implemented.
A tuple of ``(x_thresh, y_thresh)`` floats that define where the plot
changes to linear scale when using a symmetric log scale.
:getter: Returns the current value of symLogThresh
:setter: Sets the value of symLogThresh
:type: tuple of float, length 2
:raises ValueError: when setting an invalid value
.. notes::
This is a simplified example of how SymLog works::
if x >= thresh:
x = Log10(x)
elif x =< thresh:
x = -Log10(Abs(x))
else:
x = x
.. seealso::
+ :attr:`~wx.lib.plot.PolyPoints.symLogScale`
+ See http://matplotlib.org/examples/pylab_examples/symlog_demo.html
for an example.
"""
return self._symlogscale
# TODO: Implement symmetric log scale threshold
@symLogThresh.setter
def symLogThresh(self, symlogscale, thresh):
raise NotImplementedError("Symmetric Log Scale not yet implemented")
if not isinstance(symlogscale, tuple) or len(symlogscale) != 2:
raise ValueError("`symlogscale` must be a 2-tuple of bools")
self._symlogscale = symlogscale
@property
def absScale(self):
"""
A tuple of ``(x_axis_is_abs, y_axis_is_abs)`` booleans. If a value
is ``True``, then that axis is plotted on an absolute value scale.
:getter: Returns the current value of absScale
:setter: Sets the value of absScale
:type: tuple of bool, length 2
:raises ValueError: when setting an invalid value
"""
return self._absScale
@absScale.setter
def absScale(self, absscale):
if not isinstance(absscale, tuple) and len(absscale) == 2:
raise ValueError("`absscale` must be a 2-tuple of bools")
self._absScale = absscale
@property
def points(self):
"""
Get or set the plotted points.
:getter: Returns the current value of points, adjusting for the
various scale options such as Log, Abs, or SymLog.
:setter: Sets the value of points.
:type: list of `(x, y)` pairs
.. Note::
Only set unscaled points - do not perform the log, abs, or symlog
adjustments yourself.
"""
data = np.array(self._points, copy=True) # need the copy
# TODO: get rid of the
# need for copy
# work on X:
if self.absScale[0]:
data = self._abs(data, 0)
if self.logScale[0]:
data = self._log10(data, 0)
if self.symLogScale[0]:
# TODO: implement symLogScale
# Should symLogScale override absScale? My vote is no.
# Should symLogScale override logScale? My vote is yes.
# - symLogScale could be a parameter passed to logScale...
pass
# work on Y:
if self.absScale[1]:
data = self._abs(data, 1)
if self.logScale[1]:
data = self._log10(data, 1)
if self.symLogScale[1]:
# TODO: implement symLogScale
pass
return data
@points.setter
def points(self, points):
self._points = points
def _log10(self, data, index):
""" Take the Log10 of the data, dropping any negative values """
data = np.compress(data[:, index] > 0, data, 0)
data[:, index] = np.log10(data[:, index])
return data
def _abs(self, data, index):
""" Take the Abs of the data """
data[:, index] = np.abs(data[:, index])
return data
def boundingBox(self):
"""
Returns the bouding box for the entire dataset as a tuple with this
format::
((minX, minY), (maxX, maxY))
:returns: boundingbox
:rtype: numpy array of ``[[minX, minY], [maxX, maxY]]``
"""
if len(self.points) == 0:
# no curves to draw
# defaults to (-1,-1) and (1,1) but axis can be set in Draw
minXY = np.array([-1.0, -1.0])
maxXY = np.array([1.0, 1.0])
else:
minXY = np.minimum.reduce(self.points)
maxXY = np.maximum.reduce(self.points)
return minXY, maxXY
def scaleAndShift(self, scale=(1, 1), shift=(0, 0)):
"""
Scales and shifts the data for plotting.
:param scale: The values to scale the data by.
:type scale: list of floats: ``[x_scale, y_scale]``
:param shift: The value to shift the data by. This should be in scaled
units
:type shift: list of floats: ``[x_shift, y_shift]``
:returns: None
"""
if len(self.points) == 0:
# no curves to draw
return
# TODO: Can we remove the if statement alltogether? Does
# scaleAndShift ever get called when the current value equals
# the new value?
# cast everything to list: some might be np.ndarray objects
if (list(scale) != list(self.currentScale)
or list(shift) != list(self.currentShift)):
# update point scaling
self.scaled = scale * self.points + shift
self.currentScale = scale
self.currentShift = shift
# else unchanged use the current scaling
def getLegend(self):
return self.attributes['legend']
def getClosestPoint(self, pntXY, pointScaled=True):
"""
Returns the index of closest point on the curve, pointXY,
scaledXY, distance x, y in user coords.
if pointScaled == True, then based on screen coords
if pointScaled == False, then based on user coords
"""
if pointScaled:
# Using screen coords
p = self.scaled
pxy = self.currentScale * np.array(pntXY) + self.currentShift
else:
# Using user coords
p = self.points
pxy = np.array(pntXY)
# determine distance for each point
d = np.sqrt(np.add.reduce((p - pxy) ** 2, 1)) # sqrt(dx^2+dy^2)
pntIndex = np.argmin(d)
dist = d[pntIndex]
return [pntIndex,
self.points[pntIndex],
self.scaled[pntIndex] / self._pointSize,
dist]
class PolyLine(PolyPoints):
"""
Creates PolyLine object
:param points: The points that make up the line
:type points: list of ``[x, y]`` values
:param **attr: keyword attributes
=========================== ============= ====================
Keyword and Default Description Type
=========================== ============= ====================
``colour='black'`` Line color :class:`wx.Colour`
``width=1`` Line width float
``style=wx.PENSTYLE_SOLID`` Line style :class:`wx.PenStyle`
``legend=''`` Legend string str
``drawstyle='line'`` see below str
=========================== ============= ====================
================== ==================================================
Draw style Description
================== ==================================================
``'line'`` Draws an straight line between consecutive points
``'steps-pre'`` Draws a line down from point A and then right to
point B
``'steps-post'`` Draws a line right from point A and then down
to point B
``'steps-mid-x'`` Draws a line horizontally to half way between A
and B, then draws a line vertically, then again
horizontally to point B.
``'steps-mid-y'`` Draws a line vertically to half way between A
and B, then draws a line horizonatally, then
again vertically to point B.
*Note: This typically does not look very good*
================== ==================================================
.. warning::
All methods except ``__init__`` are private.
"""
_attributes = {'colour': 'black',
'width': 1,
'style': wx.PENSTYLE_SOLID,
'legend': '',
'drawstyle': 'line',
}
_drawstyles = ("line", "steps-pre", "steps-post",
"steps-mid-x", "steps-mid-y")
def __init__(self, points, **attr):
PolyPoints.__init__(self, points, attr)
def draw(self, dc, printerScale, coord=None):
"""
Draw the lines.
:param dc: The DC to draw on.
:type dc: :class:`wx.DC`
:param printerScale:
:type printerScale: float
:param coord: The legend coordinate?
:type coord: ???
"""
colour = self.attributes['colour']
width = self.attributes['width'] * printerScale * self._pointSize[0]
style = self.attributes['style']
drawstyle = self.attributes['drawstyle']
if not isinstance(colour, wx.Colour):
colour = wx.Colour(colour)
pen = wx.Pen(colour, int(width), style)
pen.SetCap(wx.CAP_BUTT)
dc.SetPen(pen)
if coord is None:
if len(self.scaled): # bugfix for Mac OS X
for c1, c2 in zip(self.scaled, self.scaled[1:]):
self._path(dc, c1, c2, drawstyle)
else:
dc.DrawLines(coord) # draw legend line
def getSymExtent(self, printerScale):
"""
Get the Width and Height of the symbol.
:param printerScale:
:type printerScale: float
"""
h = self.attributes['width'] * printerScale * self._pointSize[0]
w = 5 * h
return (w, h)
def _path(self, dc, coord1, coord2, drawstyle):
"""
Calculates the path from coord1 to coord 2 along X and Y
:param dc: The DC to draw on.
:type dc: :class:`wx.DC`
:param coord1: The first coordinate in the coord pair
:type coord1: list, length 2: ``[x, y]``
:param coord2: The second coordinate in the coord pair
:type coord2: list, length 2: ``[x, y]``
:param drawstyle: The type of connector to use
:type drawstyle: str
"""
if drawstyle == 'line':
# Straight line between points.
line = [coord1, coord2]
elif drawstyle == 'steps-pre':
# Up/down to next Y, then right to next X
intermediate = [coord1[0], coord2[1]]
line = [coord1, intermediate, coord2]
elif drawstyle == 'steps-post':
# Right to next X, then up/down to Y
intermediate = [coord2[0], coord1[1]]
line = [coord1, intermediate, coord2]
elif drawstyle == 'steps-mid-x':
# need 3 lines between points: right -> up/down -> right
mid_x = ((coord2[0] - coord1[0]) / 2) + coord1[0]
intermediate1 = [mid_x, coord1[1]]
intermediate2 = [mid_x, coord2[1]]
line = [coord1, intermediate1, intermediate2, coord2]
elif drawstyle == 'steps-mid-y':
# need 3 lines between points: up/down -> right -> up/down
mid_y = ((coord2[1] - coord1[1]) / 2) + coord1[1]
intermediate1 = [coord1[0], mid_y]
intermediate2 = [coord2[0], mid_y]
line = [coord1, intermediate1, intermediate2, coord2]
else:
err_txt = "Invalid drawstyle '{}'. Must be one of {}."
raise ValueError(err_txt.format(drawstyle, self._drawstyles))
dc.DrawLines(line)
class PolySpline(PolyLine):
"""
Creates PolySpline object
:param points: The points that make up the spline
:type points: list of ``[x, y]`` values
:param **attr: keyword attributes
=========================== ============= ====================
Keyword and Default Description Type
=========================== ============= ====================
``colour='black'`` Line color :class:`wx.Colour`
``width=1`` Line width float
``style=wx.PENSTYLE_SOLID`` Line style :class:`wx.PenStyle`
``legend=''`` Legend string str
=========================== ============= ====================
.. warning::
All methods except ``__init__`` are private.
"""
_attributes = {'colour': 'black',
'width': 1,
'style': wx.PENSTYLE_SOLID,
'legend': ''}
def __init__(self, points, **attr):
PolyLine.__init__(self, points, **attr)
def draw(self, dc, printerScale, coord=None):
""" Draw the spline """
colour = self.attributes['colour']
width = self.attributes['width'] * printerScale * self._pointSize[0]
style = self.attributes['style']
if not isinstance(colour, wx.Colour):
colour = wx.Colour(colour)
pen = wx.Pen(colour, int(width), style)
pen.SetCap(wx.CAP_ROUND)
dc.SetPen(pen)
if coord is None:
if len(self.scaled) >= 3:
dc.DrawSpline(self.scaled)
else:
dc.DrawLines(coord) # draw legend line
class PolyMarker(PolyPoints):
"""
Creates a PolyMarker object.
:param points: The marker coordinates.
:type points: list of ``[x, y]`` values
:param **attr: keyword attributes
================================= ============= ====================
Keyword and Default Description Type
================================= ============= ====================
``marker='circle'`` see below str
``size=2`` Marker size float
``colour='black'`` Outline color :class:`wx.Colour`
``width=1`` Outline width float
``style=wx.PENSTYLE_SOLID`` Outline style :class:`wx.PenStyle`
``fillcolour=colour`` fill color :class:`wx.Colour`
``fillstyle=wx.BRUSHSTYLE_SOLID`` fill style :class:`wx.BrushStyle`
``legend=''`` Legend string str
================================= ============= ====================
=================== ==================================
Marker Description
=================== ==================================
``'circle'`` A circle of diameter ``size``
``'dot'`` A dot. Does not have a size.
``'square'`` A square with side length ``size``
``'triangle'`` An upward-pointed triangle
``'triangle_down'`` A downward-pointed triangle
``'cross'`` An "X" shape
``'plus'`` A "+" shape
=================== ==================================
.. warning::
All methods except ``__init__`` are private.
"""
_attributes = {'colour': 'black',
'width': 1,
'size': 2,
'fillcolour': None,
'fillstyle': wx.BRUSHSTYLE_SOLID,
'marker': 'circle',
'legend': ''}
def __init__(self, points, **attr):
PolyPoints.__init__(self, points, attr)
def draw(self, dc, printerScale, coord=None):
""" Draw the points """
colour = self.attributes['colour']
width = self.attributes['width'] * printerScale * self._pointSize[0]
size = self.attributes['size'] * printerScale * self._pointSize[0]
fillcolour = self.attributes['fillcolour']
fillstyle = self.attributes['fillstyle']
marker = self.attributes['marker']
if colour and not isinstance(colour, wx.Colour):
colour = wx.Colour(colour)
if fillcolour and not isinstance(fillcolour, wx.Colour):
fillcolour = wx.Colour(fillcolour)
dc.SetPen(wx.Pen(colour, int(width)))
if fillcolour:
dc.SetBrush(wx.Brush(fillcolour, fillstyle))
else:
dc.SetBrush(wx.Brush(colour, fillstyle))
if coord is None:
if len(self.scaled): # bugfix for Mac OS X
self._drawmarkers(dc, self.scaled, marker, size)
else:
self._drawmarkers(dc, coord, marker, size) # draw legend marker
def getSymExtent(self, printerScale):
"""Width and Height of Marker"""
s = 5 * self.attributes['size'] * printerScale * self._pointSize[0]
return (s, s)
def _drawmarkers(self, dc, coords, marker, size=1):
f = getattr(self, "_{}".format(marker))
f(dc, coords, size)
def _circle(self, dc, coords, size=1):
fact = 2.5 * size
wh = 5.0 * size
rect = np.zeros((len(coords), 4), np.float) + [0.0, 0.0, wh, wh]
rect[:, 0:2] = coords - [fact, fact]
dc.DrawEllipseList(rect.astype(np.int32))
def _dot(self, dc, coords, size=1):
coords = [(int(c[0]), int(c[1])) for c in coords]
dc.DrawPointList(coords)
def _square(self, dc, coords, size=1):
fact = 2.5 * size
wh = 5.0 * size
rect = np.zeros((len(coords), 4), np.float) + [0.0, 0.0, wh, wh]
rect[:, 0:2] = coords - [fact, fact]
dc.DrawRectangleList(rect.astype(np.int32))
def _triangle(self, dc, coords, size=1):
shape = [(-2.5 * size, 1.44 * size),
(2.5 * size, 1.44 * size), (0.0, -2.88 * size)]
poly = np.repeat(coords, 3, 0)
poly.shape = (len(coords), 3, 2)
poly += shape
dc.DrawPolygonList(poly.astype(np.int32))
def _triangle_down(self, dc, coords, size=1):
shape = [(-2.5 * size, -1.44 * size),
(2.5 * size, -1.44 * size), (0.0, 2.88 * size)]
poly = np.repeat(coords, 3, 0)
poly.shape = (len(coords), 3, 2)
poly += shape
dc.DrawPolygonList(poly.astype(np.int32))
def _cross(self, dc, coords, size=1):
fact = 2.5 * size
for f in [[-fact, -fact, fact, fact], [-fact, fact, fact, -fact]]:
lines = np.concatenate((coords, coords), axis=1) + f
dc.DrawLineList(lines.astype(np.int32))
def _plus(self, dc, coords, size=1):
fact = 2.5 * size
for f in [[-fact, 0, fact, 0], [0, -fact, 0, fact]]:
lines = np.concatenate((coords, coords), axis=1) + f
dc.DrawLineList(lines.astype(np.int32))
class PolyBarsBase(PolyPoints):
"""
Base class for PolyBars and PolyHistogram.
.. warning::
All methods are private.
"""
_attributes = {'edgecolour': 'black',
'edgewidth': 2,
'edgestyle': wx.PENSTYLE_SOLID,
'legend': '',
'fillcolour': 'red',
'fillstyle': wx.BRUSHSTYLE_SOLID,
'barwidth': 1.0
}
def __init__(self, points, attr):
"""
"""
PolyPoints.__init__(self, points, attr)
def _scaleAndShift(self, data, scale=(1, 1), shift=(0, 0)):
"""same as override method, but retuns a value."""
scaled = scale * data + shift
return scaled
def getSymExtent(self, printerScale):
"""Width and Height of Marker"""
h = self.attributes['edgewidth'] * printerScale * self._pointSize[0]
w = 5 * h
return (w, h)
def set_pen_and_brush(self, dc, printerScale):
pencolour = self.attributes['edgecolour']
penwidth = (self.attributes['edgewidth']
* printerScale * self._pointSize[0])
penstyle = self.attributes['edgestyle']
fillcolour = self.attributes['fillcolour']
fillstyle = self.attributes['fillstyle']
if not isinstance(pencolour, wx.Colour):
pencolour = wx.Colour(pencolour)
pen = wx.Pen(pencolour, int(penwidth), penstyle)
pen.SetCap(wx.CAP_BUTT)
if not isinstance(fillcolour, wx.Colour):
fillcolour = wx.Colour(fillcolour)
brush = wx.Brush(fillcolour, fillstyle)
dc.SetPen(pen)
dc.SetBrush(brush)
def scale_rect(self, rect):
# Scale the points to the plot area
scaled_rect = self._scaleAndShift(rect,
self.currentScale,
self.currentShift)
# Convert to (left, top, width, height) for drawing
wx_rect = [scaled_rect[0][0], # X (left)
scaled_rect[0][1], # Y (top)
scaled_rect[1][0] - scaled_rect[0][0], # Width
scaled_rect[1][1] - scaled_rect[0][1]] # Height
return wx_rect
def draw(self, dc, printerScale, coord=None):
pass
class PolyBars(PolyBarsBase):
"""
Creates a PolyBars object.
:param points: The data to plot.
:type points: sequence of ``(center, height)`` points
:param **attr: keyword attributes
================================= ============= =======================
Keyword and Default Description Type
================================= ============= =======================
``barwidth=1.0`` bar width float or list of floats
``edgecolour='black'`` edge color :class:`wx.Colour`
``edgewidth=1`` edge width float
``edgestyle=wx.PENSTYLE_SOLID`` edge style :class:`wx.PenStyle`
``fillcolour='red'`` fill color :class:`wx.Colour`
``fillstyle=wx.BRUSHSTYLE_SOLID`` fill style :class:`wx.BrushStyle`
``legend=''`` legend string str
================================= ============= =======================
.. important::
If ``barwidth`` is a list of floats:
+ each bar will have a separate width
+ ``len(barwidth)`` must equal ``len(points)``.
.. warning::
All methods except ``__init__`` are private.
"""
def __init__(self, points, **attr):
PolyBarsBase.__init__(self, points, attr)
def calc_rect(self, x, y, w):
""" Calculate the rectangle for plotting. """
return self.scale_rect([[x - w / 2, y], # left, top
[x + w / 2, 0]]) # right, bottom
def draw(self, dc, printerScale, coord=None):
""" Draw the bars """
self.set_pen_and_brush(dc, printerScale)
barwidth = self.attributes['barwidth']
if coord is None:
if isinstance(barwidth, (int, float)):
# use a single width for all bars
pts = ((x, y, barwidth) for x, y in self.points)
elif isinstance(barwidth, (list, tuple)):
# use a separate width for each bar
if len(barwidth) != len(self.points):
err_str = ("Barwidth ({} items) and Points ({} items) do "
"not have the same length!")
err_str = err_str.format(len(barwidth), len(self.points))
raise ValueError(err_str)
pts = ((x, y, w) for (x, y), w in zip(self.points, barwidth))
else:
# invalid attribute type
err_str = ("Invalid type for 'barwidth'. Expected float, "
"int, or list or tuple of (int or float). Got {}.")
raise TypeError(err_str.format(type(barwidth)))
rects = [self.calc_rect(x, y, w) for x, y, w in pts]
rects = [(int(r[0]), int(r[1]), int(r[2]), int(r[3])) for r in rects]
dc.DrawRectangleList(rects)
else:
dc.DrawLines(coord) # draw legend line
class PolyHistogram(PolyBarsBase):
"""
Creates a PolyHistogram object.
:param hist: The histogram data.
:type hist: sequence of ``y`` values that define the heights of the bars
:param binspec: The bin specification.
:type binspec: sequence of ``x`` values that define the edges of the bins
:param **attr: keyword attributes
================================= ============= =======================
Keyword and Default Description Type
================================= ============= =======================
``edgecolour='black'`` edge color :class:`wx.Colour`
``edgewidth=3`` edge width float
``edgestyle=wx.PENSTYLE_SOLID`` edge style :class:`wx.PenStyle`
``fillcolour='blue'`` fill color :class:`wx.Colour`
``fillstyle=wx.BRUSHSTYLE_SOLID`` fill style :class:`wx.BrushStyle`
``legend=''`` legend string str
================================= ============= =======================
.. tip::
Use ``np.histogram()`` to easily create your histogram parameters::
hist_data, binspec = np.histogram(data)
hist_plot = PolyHistogram(hist_data, binspec)
.. important::
``len(binspec)`` must equal ``len(hist) + 1``.
.. warning::
All methods except ``__init__`` are private.
"""
def __init__(self, hist, binspec, **attr):
if len(binspec) != len(hist) + 1:
raise ValueError("Len(binspec) must equal len(hist) + 1")
self.hist = hist
self.binspec = binspec
# define the bins and center x locations
self.bins = list(pairwise(self.binspec))
bar_center_x = (pair[0] + (pair[1] - pair[0])/2 for pair in self.bins)
points = list(zip(bar_center_x, self.hist))
PolyBarsBase.__init__(self, points, attr)
def calc_rect(self, y, low, high):
""" Calculate the rectangle for plotting. """
return self.scale_rect([[low, y], # left, top
[high, 0]]) # right, bottom
def draw(self, dc, printerScale, coord=None):
""" Draw the bars """
self.set_pen_and_brush(dc, printerScale)
if coord is None:
rects = [self.calc_rect(y, low, high)
for y, (low, high)
in zip(self.hist, self.bins)]
rects = [(int(r[0]), int(r[1]), int(r[2]), int(r[3]))
for r in rects]
dc.DrawRectangleList(rects)
else:
dc.DrawLines(coord) # draw legend line
class PolyBoxPlot(PolyPoints):
"""
Creates a PolyBoxPlot object.
:param data: Raw data to create a box plot from.
:type data: sequence of int or float
:param **attr: keyword attributes
================================= ============= =======================
Keyword and Default Description Type
================================= ============= =======================
``colour='black'`` edge color :class:`wx.Colour`
``width=1`` edge width float
``style=wx.PENSTYLE_SOLID`` edge style :class:`wx.PenStyle`
``legend=''`` legend string str
================================= ============= =======================
.. note::
``np.NaN`` and ``np.inf`` values are ignored.
.. admonition:: TODO
+ [ ] Figure out a better way to get multiple box plots side-by-side
(current method is a hack).
+ [ ] change the X axis to some labels.
+ [ ] Change getClosestPoint to only grab box plot items and outlers?
Currently grabs every data point.
+ [ ] Add more customization such as Pens/Brushes, outlier shapes/size,
and box width.
+ [ ] Figure out how I want to handle log-y: log data then calcBP? Or
should I calc the BP first then the plot it on a log scale?
"""
_attributes = {'colour': 'black',
'width': 1,
'style': wx.PENSTYLE_SOLID,
'legend': '',
}
def __init__(self, points, **attr):
# Set various attributes
self.box_width = 0.5
# Determine the X position and create a 1d dataset.
self.xpos = points[0, 0]
points = points[:, 1]
# Calculate the box plot points and the outliers
self._bpdata = self.calcBpData(points)
self._outliers = self.calcOutliers(points)
points = np.concatenate((self._bpdata, self._outliers))
points = np.array([(self.xpos, x) for x in points])
# Create a jitter for the outliers
self.jitter = (0.05 * np.random.random_sample(len(self._outliers))
+ self.xpos - 0.025)
# Init the parent class
PolyPoints.__init__(self, points, attr)
def _clean_data(self, data=None):
"""
Removes NaN and Inf from the data.
"""
if data is None:
data = self.points
# clean out NaN and infinity values.
data = data[~np.isnan(data)]
data = data[~np.isinf(data)]
return data
def boundingBox(self):
"""
Returns bounding box for the plot.
Override method.
"""
xpos = self.xpos
minXY = np.array([xpos - self.box_width / 2, self._bpdata.min * 0.95])
maxXY = np.array([xpos + self.box_width / 2, self._bpdata.max * 1.05])
return minXY, maxXY
def getClosestPoint(self, pntXY, pointScaled=True):
"""
Returns the index of closest point on the curve, pointXY,
scaledXY, distance x, y in user coords.
Override method.
if pointScaled == True, then based on screen coords
if pointScaled == False, then based on user coords
"""
xpos = self.xpos
# combine the outliers with the box plot data
data_to_use = np.concatenate((self._bpdata, self._outliers))
data_to_use = np.array([(xpos, x) for x in data_to_use])
if pointScaled:
# Use screen coords
p = self.scaled
pxy = self.currentScale * np.array(pntXY) + self.currentShift
else:
# Using user coords
p = self._points
pxy = np.array(pntXY)
# determine distnace for each point
d = np.sqrt(np.add.reduce((p - pxy) ** 2, 1)) # sqrt(dx^2+dy^2)
pntIndex = np.argmin(d)
dist = d[pntIndex]
return [pntIndex,
self.points[pntIndex],
self.scaled[pntIndex] / self._pointSize,
dist]
def getSymExtent(self, printerScale):
"""Width and Height of Marker"""
# TODO: does this need to be updated?
h = self.attributes['width'] * printerScale * self._pointSize[0]
w = 5 * h
return (w, h)
def calcBpData(self, data=None):
"""
Box plot points:
Median (50%)
75%
25%
low_whisker = lowest value that's >= (25% - (IQR * 1.5))
high_whisker = highest value that's <= 75% + (IQR * 1.5)
outliers are outside of 1.5 * IQR
Parameters
----------
data : array-like
The data to plot
Returns
-------
bpdata : collections.namedtuple
Descriptive statistics for data:
(min_data, low_whisker, q25, median, q75, high_whisker, max_data)
"""
data = self._clean_data(data)
min_data = float(np.min(data))
max_data = float(np.max(data))
q25 = float(np.percentile(data, 25))
q75 = float(np.percentile(data, 75))
iqr = q75 - q25
low_whisker = float(data[data >= q25 - 1.5 * iqr].min())
high_whisker = float(data[data <= q75 + 1.5 * iqr].max())
median = float(np.median(data))
BPData = namedtuple("bpdata", ("min", "low_whisker", "q25", "median",
"q75", "high_whisker", "max"))
bpdata = BPData(min_data, low_whisker, q25, median,
q75, high_whisker, max_data)
return bpdata
def calcOutliers(self, data=None):
"""
Calculates the outliers. Must be called after calcBpData.
"""
data = self._clean_data(data)
outliers = data
outlier_bool = np.logical_or(outliers > self._bpdata.high_whisker,
outliers < self._bpdata.low_whisker)
outliers = outliers[outlier_bool]
return outliers
def _scaleAndShift(self, data, scale=(1, 1), shift=(0, 0)):
"""same as override method, but retuns a value."""
scaled = scale * data + shift
return scaled
@TempStyle('pen')
def draw(self, dc, printerScale, coord=None):
"""
Draws a box plot on the DC.
Notes
-----
The following draw order is required:
1. First the whisker line
2. Then the IQR box
3. Lasly the median line.
This is because
+ The whiskers are drawn as single line rather than two lines
+ The median line must be visable over the box if the box has a fill.
Other than that, the draw order can be changed.
"""
self._draw_whisker(dc, printerScale)
self._draw_iqr_box(dc, printerScale)
self._draw_median(dc, printerScale) # median after box
self._draw_whisker_ends(dc, printerScale)
self._draw_outliers(dc, printerScale)
@TempStyle('pen')
def _draw_whisker(self, dc, printerScale):
"""Draws the whiskers as a single line"""
xpos = self.xpos
# We draw it as one line and then hide the middle part with
# the IQR rectangle
whisker_line = np.array([[xpos, self._bpdata.low_whisker],
[xpos, self._bpdata.high_whisker]])
whisker_line = self._scaleAndShift(whisker_line,
self.currentScale,
self.currentShift)
whisker_pen = wx.Pen(wx.BLACK, 2, wx.PENSTYLE_SOLID)
whisker_pen.SetCap(wx.CAP_BUTT)
dc.SetPen(whisker_pen)
dc.DrawLines(whisker_line)
@TempStyle('pen')
def _draw_iqr_box(self, dc, printerScale):
"""Draws the Inner Quartile Range box"""
xpos = self.xpos
box_w = self.box_width
iqr_box = [[xpos - box_w / 2, self._bpdata.q75], # left, top
[xpos + box_w / 2, self._bpdata.q25]] # right, bottom
# Scale it to the plot area
iqr_box = self._scaleAndShift(iqr_box,
self.currentScale,
self.currentShift)
# rectangles are drawn (left, top, width, height) so adjust
iqr_box = [int(iqr_box[0][0]), # X (left)
int(iqr_box[0][1]), # Y (top)
int(iqr_box[1][0] - iqr_box[0][0]), # Width
int(iqr_box[1][1] - iqr_box[0][1])] # Height
box_pen = wx.Pen(wx.BLACK, 3, wx.PENSTYLE_SOLID)
box_brush = wx.Brush(wx.GREEN, wx.BRUSHSTYLE_SOLID)
dc.SetPen(box_pen)
dc.SetBrush(box_brush)
dc.DrawRectangleList([iqr_box])
@TempStyle('pen')
def _draw_median(self, dc, printerScale, coord=None):
"""Draws the median line"""
xpos = self.xpos
median_line = np.array(
[[xpos - self.box_width / 2, self._bpdata.median],
[xpos + self.box_width / 2, self._bpdata.median]]
)
median_line = self._scaleAndShift(median_line,
self.currentScale,
self.currentShift)
median_pen = wx.Pen(wx.BLACK, 4, wx.PENSTYLE_SOLID)
median_pen.SetCap(wx.CAP_BUTT)
dc.SetPen(median_pen)
dc.DrawLines(median_line)
@TempStyle('pen')
def _draw_whisker_ends(self, dc, printerScale):
"""Draws the end caps of the whiskers"""
xpos = self.xpos
fence_top = np.array(
[[xpos - self.box_width * 0.2, self._bpdata.high_whisker],
[xpos + self.box_width * 0.2, self._bpdata.high_whisker]]
)
fence_top = self._scaleAndShift(fence_top,
self.currentScale,
self.currentShift)
fence_bottom = np.array(
[[xpos - self.box_width * 0.2, self._bpdata.low_whisker],
[xpos + self.box_width * 0.2, self._bpdata.low_whisker]]
)
fence_bottom = self._scaleAndShift(fence_bottom,
self.currentScale,
self.currentShift)
fence_pen = wx.Pen(wx.BLACK, 2, wx.PENSTYLE_SOLID)
fence_pen.SetCap(wx.CAP_BUTT)
dc.SetPen(fence_pen)
dc.DrawLines(fence_top)
dc.DrawLines(fence_bottom)
@TempStyle('pen')
def _draw_outliers(self, dc, printerScale):
"""Draws dots for the outliers"""
# Set the pen
outlier_pen = wx.Pen(wx.BLUE, 5, wx.PENSTYLE_SOLID)
dc.SetPen(outlier_pen)
outliers = self._outliers
# Scale the data for plotting
pt_data = np.array([self.jitter, outliers]).T
pt_data = self._scaleAndShift(pt_data,
self.currentScale,
self.currentShift)
# Draw the outliers
size = 0.5
fact = 2.5 * size
wh = 5.0 * size
rect = np.zeros((len(pt_data), 4), np.float) + [0.0, 0.0, wh, wh]
rect[:, 0:2] = pt_data - [fact, fact]
dc.DrawRectangleList(rect.astype(np.int32))
class PlotGraphics(object):
"""
Creates a PlotGraphics object.
:param objects: The Poly objects to plot.
:type objects: list of :class:`~wx.lib.plot.PolyPoints` objects
:param title: The title shown at the top of the graph.
:type title: str
:param xLabel: The x-axis label.
:type xLabel: str
:param yLabel: The y-axis label.
:type yLabel: str
.. warning::
All methods except ``__init__`` are private.
"""
def __init__(self, objects, title='', xLabel='', yLabel=''):
if type(objects) not in [list, tuple]:
raise TypeError("objects argument should be list or tuple")
self.objects = objects
self._title = title
self._xLabel = xLabel
self._yLabel = yLabel
self._pointSize = (1.0, 1.0)
@property
def logScale(self):
if len(self.objects) == 0:
return
return [obj.logScale for obj in self.objects]
@logScale.setter
def logScale(self, logscale):
# XXX: error checking done by PolyPoints class
# if not isinstance(logscale, tuple) and len(logscale) != 2:
# raise TypeError("logscale must be a 2-tuple of bools")
if len(self.objects) == 0:
return
for obj in self.objects:
obj.logScale = logscale
def setLogScale(self, logscale):
"""
Set the log scale boolean value.
.. deprecated:: Feb 27, 2016
Use the :attr:`~wx.lib.plot.polyobjects.PlotGraphics.logScale`
property instead.
"""
pendingDeprecation("self.logScale property")
self.logScale = logscale
@property
def absScale(self):
if len(self.objects) == 0:
return
return [obj.absScale for obj in self.objects]
@absScale.setter
def absScale(self, absscale):
# XXX: error checking done by PolyPoints class
# if not isinstance(absscale, tuple) and len(absscale) != 2:
# raise TypeError("absscale must be a 2-tuple of bools")
if len(self.objects) == 0:
return
for obj in self.objects:
obj.absScale = absscale
def boundingBox(self):
p1, p2 = self.objects[0].boundingBox()
for o in self.objects[1:]:
p1o, p2o = o.boundingBox()
p1 = np.minimum(p1, p1o)
p2 = np.maximum(p2, p2o)
return p1, p2
def scaleAndShift(self, scale=(1, 1), shift=(0, 0)):
for o in self.objects:
o.scaleAndShift(scale, shift)
def setPrinterScale(self, scale):
"""
Thickens up lines and markers only for printing
.. deprecated:: Feb 27, 2016
Use the :attr:`~wx.lib.plot.polyobjects.PlotGraphics.printerScale`
property instead.
"""
pendingDeprecation("self.printerScale property")
self.printerScale = scale
def setXLabel(self, xLabel=''):
"""
Set the X axis label on the graph
.. deprecated:: Feb 27, 2016
Use the :attr:`~wx.lib.plot.polyobjects.PlotGraphics.xLabel`
property instead.
"""
pendingDeprecation("self.xLabel property")
self.xLabel = xLabel
def setYLabel(self, yLabel=''):
"""
Set the Y axis label on the graph
.. deprecated:: Feb 27, 2016
Use the :attr:`~wx.lib.plot.polyobjects.PlotGraphics.yLabel`
property instead.
"""
pendingDeprecation("self.yLabel property")
self.yLabel = yLabel
def setTitle(self, title=''):
"""
Set the title at the top of graph
.. deprecated:: Feb 27, 2016
Use the :attr:`~wx.lib.plot.polyobjects.PlotGraphics.title`
property instead.
"""
pendingDeprecation("self.title property")
self.title = title
def getXLabel(self):
"""
Get X axis label string
.. deprecated:: Feb 27, 2016
Use the :attr:`~wx.lib.plot.polyobjects.PlotGraphics.xLabel`
property instead.
"""
pendingDeprecation("self.xLabel property")
return self.xLabel
def getYLabel(self):
"""
Get Y axis label string
.. deprecated:: Feb 27, 2016
Use the :attr:`~wx.lib.plot.polyobjects.PlotGraphics.yLabel`
property instead.
"""
pendingDeprecation("self.yLabel property")
return self.yLabel
def getTitle(self, title=''):
"""
Get the title at the top of graph
.. deprecated:: Feb 27, 2016
Use the :attr:`~wx.lib.plot.polyobjects.PlotGraphics.title`
property instead.
"""
pendingDeprecation("self.title property")
return self.title
@property
def printerScale(self):
return self._printerScale
@printerScale.setter
def printerScale(self, scale):
"""Thickens up lines and markers only for printing"""
self._printerScale = scale
@property
def xLabel(self):
"""Get the X axis label on the graph"""
return self._xLabel
@xLabel.setter
def xLabel(self, text):
self._xLabel = text
@property
def yLabel(self):
"""Get the Y axis label on the graph"""
return self._yLabel
@yLabel.setter
def yLabel(self, text):
self._yLabel = text
@property
def title(self):
"""Get the title at the top of graph"""
return self._title
@title.setter
def title(self, text):
self._title = text
def draw(self, dc):
for o in self.objects:
# t=_time.perf_counter() # profile info
o._pointSize = self._pointSize
o.draw(dc, self._printerScale)
# print(o, "time=", _time.perf_counter()-t)
def getSymExtent(self, printerScale):
"""Get max width and height of lines and markers symbols for legend"""
self.objects[0]._pointSize = self._pointSize
symExt = self.objects[0].getSymExtent(printerScale)
for o in self.objects[1:]:
o._pointSize = self._pointSize
oSymExt = o.getSymExtent(printerScale)
symExt = np.maximum(symExt, oSymExt)
return symExt
def getLegendNames(self):
"""Returns list of legend names"""
lst = [None] * len(self)
for i in range(len(self)):
lst[i] = self.objects[i].getLegend()
return lst
def __len__(self):
return len(self.objects)
def __getitem__(self, item):
return self.objects[item]
# -------------------------------------------------------------------------
# Used to layout the printer page
class PlotPrintout(wx.Printout):
"""Controls how the plot is made in printing and previewing"""
# Do not change method names in this class,
# we have to override wx.Printout methods here!
def __init__(self, graph):
"""graph is instance of plotCanvas to be printed or previewed"""
wx.Printout.__init__(self)
self.graph = graph
def HasPage(self, page):
if page == 1:
return True
else:
return False
def GetPageInfo(self):
return (1, 1, 1, 1) # disable page numbers
def OnPrintPage(self, page):
dc = self.GetDC() # allows using floats for certain functions
# print("PPI Printer",self.GetPPIPrinter())
# print("PPI Screen", self.GetPPIScreen())
# print("DC GetSize", dc.GetSize())
# print("GetPageSizePixels", self.GetPageSizePixels())
# Note PPIScreen does not give the correct number
# Calulate everything for printer and then scale for preview
PPIPrinter = self.GetPPIPrinter() # printer dots/inch (w,h)
# PPIScreen= self.GetPPIScreen() # screen dots/inch (w,h)
dcSize = dc.GetSize() # DC size
if self.graph._antiAliasingEnabled and not isinstance(dc, wx.GCDC):
try:
dc = wx.GCDC(dc)
except Exception:
pass
else:
if self.graph._hiResEnabled:
# high precision - each logical unit is 1/20 of a point
dc.SetMapMode(wx.MM_TWIPS)
pageSize = self.GetPageSizePixels() # page size in terms of pixcels
clientDcSize = self.graph.GetClientSize()
# find what the margins are (mm)
pgSetupData = self.graph.pageSetupData
margLeftSize, margTopSize = pgSetupData.GetMarginTopLeft()
margRightSize, margBottomSize = pgSetupData.GetMarginBottomRight()
# calculate offset and scale for dc
pixLeft = margLeftSize * PPIPrinter[0] / 25.4 # mm*(dots/in)/(mm/in)
pixRight = margRightSize * PPIPrinter[0] / 25.4
pixTop = margTopSize * PPIPrinter[1] / 25.4
pixBottom = margBottomSize * PPIPrinter[1] / 25.4
plotAreaW = pageSize[0] - (pixLeft + pixRight)
plotAreaH = pageSize[1] - (pixTop + pixBottom)
# ratio offset and scale to screen size if preview
if self.IsPreview():
ratioW = float(dcSize[0]) / pageSize[0]
ratioH = float(dcSize[1]) / pageSize[1]
pixLeft *= ratioW
pixTop *= ratioH
plotAreaW *= ratioW
plotAreaH *= ratioH
# rescale plot to page or preview plot area
self.graph._setSize(plotAreaW, plotAreaH)
# Set offset and scale
dc.SetDeviceOrigin(int(pixLeft), int(pixTop))
# Thicken up pens and increase marker size for printing
ratioW = float(plotAreaW) / clientDcSize[0]
ratioH = float(plotAreaH) / clientDcSize[1]
aveScale = (ratioW + ratioH) / 2
if self.graph._antiAliasingEnabled and not self.IsPreview():
scale = dc.GetUserScale()
dc.SetUserScale(scale[0] / self.graph._pointSize[0],
scale[1] / self.graph._pointSize[1])
self.graph._setPrinterScale(aveScale) # tickens up pens for printing
self.graph._printDraw(dc)
# rescale back to original
self.graph._setSize()
self.graph._setPrinterScale(1)
self.graph.Redraw() # to get point label scale and shift correct
return True