This chapter discusses ways to extend the Rayon library with custom Plot and Scale classes. Using these facilities, it is possible for a user familiar with Python development to create entirely new visualizations that can be used instead of or alongside those that come with Rayon.
In Rayon, methods of a class that are intended to be overridden in a subclass have an underscore (_) character as the last character in their name – for instance, draw_. When overriding these methods, it is not necessary to call the superclass method in the subclass. (It is also frequently necessary to override the __init__ method. In this case, the superclass’s __init__ method should be called.)
In Python, it is impossible to protect methods from subclassing, and there are no standard ways to indicate methods that the superclass designer intended to be subclassed.
To mitigate this, method names in Rayon that end in an underscore (_) character are intended to be overridden with custom behavior. (The __init__ method may also be overridden, provided the overriding method calls the superclass __init__ appropriately.) The behavior of objects when methods are overridden that are not so named is outside the scope of the API.
The Plot classes in Rayon cover a number of common visualization use cases; when it is necessary to go beyond them, the best way to do this is by creating a new Plot class, which will integrate with the rest of Rayon and provide new capabilities for other users.
In this section, we create a scatterplot that uses polar coordinates instead of Cartesian coordinates. This class behaves identically to the ScatterPlot class, but uses r and theta axes for its spatial data instead of x and y:
import math
from rayon.plots import *
from rayon.markers import *
class PolarScatterPlot(plots.Plot):
axes = ('r', 'theta')
def draw_(self, ctx, width, height):
marker = markers.Dot()
for r, theta in self.get_scaled_points():
x = r * math.cos(theta)
y = r * math.sin(theta)
marker.draw(ctx,
x * width,
height - (y * height))
After some imports, we define our class and our axes, r and theta. We also define a draw_ method. This method takes three arguments: ctx is a Context object. width and height specify the dimensions of the area into which we will draw, in points or pixels (as appropriate to the output type).
The body of the draw_ method first creates a marker for drawing the points. Next, it uses the get_scaled_points method to get an iterator over the points in the Plot object. The points from this iterator have already been translated by the scales associated with the relevant axes; if we use degrees as our unit of angular measure, for instance, r should be a value between 0 and 360, inclusive.
In addition to Plot.get_scaled_points, there are two other methods available for iterating over the points in a plot. Plot.get_unscaled_points iterates over the raw, unscaled data. Plot.get_all_points iterates over both the raw and the scaled data. They would be used like this:
for un_r, un_theta in self.get_unscaled_points():
...
for scaled, unscaled in self.get_all_points():
r, theta = scaled
un_r, un_theta = unscaled
...
The scale for the theta axis can be a regular LinearScale object. The scale for the r axis could be a specially configured LinearScale object, but it might be easier on the user to provide a pre-configured class. Specifying default scales for the plot will document these requirements and simplify the use of our class.
With that in mind, we make the following adjustments:
from rayon.scales import *
...
class DegreeScale(LinearScale):
def __init__(self, in_lo, in_hi):
LinearScale.__init__(
self, in_lo, in_hi, 0, 360)
class PolarScatterPlot(plots.Plot):
...
default_scales = {
'r' : LinearScale,
'theta' : DegreeScale
}
...
First we make a simple subclass of LinearScale that sets its output range between 0 and 360. Then we define a default_scales attribute on our class. default_scales is a dictionary whose keys are axis labels. Each key has one of three possible values:
- A Scale class (not an object) that requires no arguments be passed to its __init__ method.
- A function that takes no arguments and produces either a Scale object or a scaling function.
- A non-callable value, which is equivalent to a constant scale (see Setting data and scales) returning that value.
To further illustrate the use of default scales, we shall allow the user to set the color the marker uses instead of using the default color. One way to do this is with an attribute passed into PolarScatterPlot.__init__:
class PolarScatterPlot(Plot):
...
def __init__(self, marker_color,
**kargs):
Plot.__init__(self, **kargs)
self.marker_color = marker_color
def draw_(ctx, width, height):
...
marker = markers.Dot(
color=marker_color)
However, a more flexible way is to add a marker_color axis, and allow the color to be driven by the data. If the user would rather use a constant value for marker color and not supply additional data, a constant scale can be used. In fact, the default scale can be constant, so the user doesn’t have to think about marker color unless it’s necessary. A PolarScatterPlot implemented in this way would look like this:
import math
from rayon.plots import *
from rayon.markers import *
class PolarScatterPlot(plots.Plot):
axes = ('r', 'theta', 'marker_color')
default_scales = {
'r' : DegreeScale,
'theta' : LinearScale,
'marker_color' : 'black'
}
def draw_(self, ctx, width, height):
for r, theta, mcolor \
in self.get_scaled_points():
marker = markers.Dot(color=mcolor)
x = r * math.cos(theta)
y = r * math.sin(theta)
marker.draw(ctx,
x * width,
height - (y * height))
To draw its visualization, the Plot object must utilize Rayon’s low-level drawing tools, the most important of which is the Context object. A Context object provides a consistent interface to whatever low-level library is being used for rendering – currently either Cairo or wxWidgets.
Rayon also provides several additional objects that utilize the Context object to perform common drawing tasks in a consistent way:
- Marker objects draw dots, circles, crosses and other marks at specified points on the canvas.
- The Labeler object behaves the same way, drawing text labels on the canvas at specified points.
- It is often desirable to draw a mark and a label two together, the LabeledMarker makes this easier.
- Line objects draw lines in various styles.
For simple transformations, functions can be used in a Plot object where Scale objects would be used. (See plots-functions-as-scales.) For more advanced transformations, it may be more convenient to create a custom Scale subclass.
The following simple scale could be used to classify numeric data into equally-spaced regions, emitting the text “low”, “medium” and “high” (say, for use as labels on a plot):
from __future__ import division
from rayon.scales import Scale
class LabelScale(Scale):
def __init__(self, in_lo, in_hi):
Scale.__init__(self)
self.in_lo = in_lo
self.in_hi = in_hi
def update_(self, new_data):
if len(new_data) > 0:
new_lo = min(new_data)
new_hi = max(new_data)
if new_lo < self.in_lo:
self.in_lo = new_lo
if new_hi > self.in_hi:
self.in_hi = new_hi
return self.in_lo, self.in_hi, "low", "high"
def convert_(self, x):
diff = self.in_hi - self.in_lo
if x > (((diff / 3) * 2) + self.in_lo):
return "high"
elif x > ((diff / 3) + self.in_lo):
return "medium"
else:
return "low"
A Scale subclass meant for regular use would probably do some additional work (to check that its input was between in_lo and in_hi, for instance). However, this object can be used as-is in a Plot object, and it will update its limits:
>>> s = LabelScale(1, 9)
>>> s.convert(3)
'low'
>>> s.convert(6)
'medium'
>>> s.convert(8)
'high'
>>> s.update_(range(19))
>>> s.convert(3)
'low'
>>> s.convert(6)
'low'
>>> s.convert(9)
'medium'
>>> s.convert(15)
'high'
The input range of the example object above can grow if it is associated with new data, but it will not shrink if data is dissociated from it. To make that happen, we must change __init__ and implement the Scale.reset_ method:
class LabelScale(Scale):
def __init__(self, in_lo, in_hi):
Scale.__init__(self)
self.in_lo = in_lo
self.in_hi = in_hi
self.orig_in_lo = in_lo
self.orig_in_hi = in_hi
...
def reset_(self):
self.in_lo = self.orig_in_lo
self.in_hi = self.orig_in_hi
return self.in_lo, self.in_hi, "low", "high"
...
The Scale.reset_ method changes the object’s limits to those it had when it was first created. Rayon can then reconstruct what the limits should be based on the data currently associated with the object.
Sometimes it is desirable to create a scale with fixed limits, rather than have it take them from the data. The Scale subclass should signal this to the superclass when the object is created by passing True as the value of the fixed argument. In this case, the subclass should also pass the limits of the scale, as they will not be derived from update_ or reset_
This is a fixed-limits version of LabelScale:
from __future__ import division
from rayon.scales import Scale
class LabelScale(Scale):
def __init__(self, in_lo, in_hi):
Scale.__init__(
self, fixed=True,
input_min=in_lo, input_max=in_hi,
output_min="low", output_max="hi")
self.in_lo = in_lo
self.in_hi = in_hi
def convert_(self, x):
diff = self.in_hi - self.in_lo
if x > (((diff / 3) * 2) + self.in_lo):
return "high"
elif x > ((diff / 3) + self.in_lo):
return "medium"
else:
return "low"
Our LabelScale object needs input that supports addition, subtraction, multiplication and division. To ensure that all the data we get supports this, we can implement the Scale.check_input_ method:
class LabelScale(Scale):
...
def check_input_(self, in_data):
for i in in_data:
try:
i + 1
i - 1
i * 2
if i != 0:
i / 2
except:
return False
return True
This method will be called when new data is associated with the Scale object, and can provide earlier and more accurate descriptions of what went wrong when errors occur. However, checks can become costly if in_data is large. For that reason, input checking is disabled by default. To enable it, we must change our __init__ method again:
class LabelScale(Scale):
def __init__(self, in_lo, in_hi,
check_input=False):
Scale.__init__(self, check_input)
...
We have disabled input checking by default in our class, as well, but users can enable it. The Scale objects that ship with Rayon behave this way.
A Scale object may wish to provide points along the scale which are optimal for placing tick marks, as described in scale-selecting-tick-positions. To do this, we implement the Scale.get_nice_tick_positions_ method:
class LabelScale(Scale):
...
def get_nice_tick_positions_(
self, num_ticks=3, inside=False):
mid = (self.in_lo +
((self.in_hi - self.in_lo) / 2))
if num_ticks == 1:
return mid
elif num_ticks == 2:
return self.in_lo, self.in_hi
else:
return (self.in_lo, mid, self.in_hi)
If possible, it is preferred if the Scale object tries to return the number of ticks the user requests in num_ticks, but it is not necessary and, in some scales, might not make sense. This class, for instance, provides only three outputs, so it makes little sense to return more than three values.
Some scales do not have a bounded input range. Consider a different version of our example above, one which uses in_lo and in_hi to determine the “medium” range, and labels everything outside that “low” or “high.” Our changed class looks like this:
from __future__ import division
from rayon.scales import Scale
class LabelScale(Scale):
def __init__(self, in_lo,
in_hi, check_input=False):
Scale.__init__(self, check_input)
self.in_lo = in_lo
self.in_hi = in_hi
self.orig_in_lo = in_lo
self.orig_in_hi = in_hi
def update_(self, new_data):
if len(new_data) > 0:
new_lo = min(new_data)
new_hi = max(new_data)
if new_lo < self.in_lo:
self.in_lo = new_lo
if new_hi > self.in_hi:
self.in_hi = new_hi
return (None, None "low", "high")
def reset_(self):
self.in_lo = self.orig_in_lo
self.in_hi = self.orig_in_hi
return (None, None, "low", "high")
def convert_(self, x):
if x <= self.in_lo:
return "low"
elif x >= self.in_hi:
return "high"
else:
return "medium"
def check_input_(self, in_data):
for i in in_data:
try:
i >= 1
i <= 1
except:
return False
return True
def get_nice_tick_positions_(
self, num_ticks=2, inside=False):
return self.in_lo, self.in_hi
We have changed convert_ to implement the new behavior. update_ and reset_ now return None for the upper and lower bounds of input, since this new scale is open-ended.
This class still has one problem: Scale.get_input_min and Scale.get_input_max will now return None. That doesn’t make sense, but no value makes sense for those methods; the scale no longer has upper or lower limits on its input.
To tell Rayon that our Scale object lacks input limits, we implement Scale.has_input_upper_bound_ and Scale.has_input_lower_bound_:
class LabelScale(Scale):
...
def has_input_upper_bound_(self):
return False
def has_input_lower_bound_(self):
return False
By default, these methods return True. When Scale.has_input_upper_bound_ returns False, Scale.get_input_max raises a RayonLimitException. When Scale.has_input_lower_bound_ returns False, Scale.get_input_min raises a RayonLimitException.
The Scale.has_output_upper_bound_ and Scale.has_output_lower_bound_ methods perform the same function for Scale.get_output_min and Scale.get_output_max.