blob: 74ed0e3d9fa7302cbdc39d8a8a21b2acabcd78bb [file] [log] [blame]
#!/usr/bin/env python
#
# Copyright 2008 Google Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Code common to all chart types."""
import copy
import warnings
from mapreduce.lib.graphy import formatters
from mapreduce.lib.graphy import util
class Marker(object):
"""Represents an abstract marker, without position. You can attach these to
a DataSeries.
Object attributes:
shape: One of the shape codes (Marker.arrow, Marker.diamond, etc.)
color: color (as hex string, f.ex. '0000ff' for blue)
size: size of the marker
"""
# TODO: Write an example using markers.
# Shapes:
arrow = 'a'
cross = 'c'
diamond = 'd'
circle = 'o'
square = 's'
x = 'x'
# Note: The Google Chart API also knows some other markers ('v', 'V', 'r',
# 'b') that I think would fit better into a grid API.
# TODO: Make such a grid API
def __init__(self, shape, color, size):
"""Construct a Marker. See class docstring for details on args."""
# TODO: Shapes 'r' and 'b' would be much easier to use if they had a
# special-purpose API (instead of trying to fake it with markers)
self.shape = shape
self.color = color
self.size = size
class _BasicStyle(object):
"""Basic style object. Used internally."""
def __init__(self, color):
self.color = color
class DataSeries(object):
"""Represents one data series for a chart (both data & presentation
information).
Object attributes:
points: List of numbers representing y-values (x-values are not specified
because the Google Chart API expects even x-value spacing).
label: String with the series' label in the legend. The chart will only
have a legend if at least one series has a label. If some series
do not have a label then they will have an empty description in
the legend. This is currently a limitation in the Google Chart
API.
style: A chart-type-specific style object. (LineStyle for LineChart,
BarsStyle for BarChart, etc.)
markers: List of (x, m) tuples where m is a Marker object and x is the
x-axis value to place it at.
The "fill" markers ('r' & 'b') are a little weird because they
aren't a point on a line. For these, you can fake it by
passing slightly weird data (I'd like a better API for them at
some point):
For 'b', you attach the marker to the starting series, and set x
to the index of the ending line. Size is ignored, I think.
For 'r', you can attach to any line, specify the starting
y-value for x and the ending y-value for size. Y, in this case,
is becase 0.0 (bottom) and 1.0 (top).
color: DEPRECATED
"""
# TODO: Should we require the points list to be non-empty ?
# TODO: Do markers belong here? They are really only used for LineCharts
def __init__(self, points, label=None, style=None, markers=None, color=None):
"""Construct a DataSeries. See class docstring for details on args."""
if label is not None and util._IsColor(label):
warnings.warn('Your code may be broken! Label is a hex triplet. Maybe '
'it is a color? The old argument order (color & style '
'before label) is deprecated.', DeprecationWarning,
stacklevel=2)
if color is not None:
warnings.warn('Passing color is deprecated. Pass a style object '
'instead.', DeprecationWarning, stacklevel=2)
# Attempt to fix it for them. If they also passed a style, honor it.
if style is None:
style = _BasicStyle(color)
if style is not None and isinstance(style, basestring):
warnings.warn('Your code is broken! Style is a string, not an object. '
'Maybe you are passing a color? Passing color is '
'deprecated; pass a style object instead.',
DeprecationWarning, stacklevel=2)
if style is None:
style = _BasicStyle(None)
self.data = points
self.style = style
self.markers = markers or []
self.label = label
def _GetColor(self):
warnings.warn('DataSeries.color is deprecated, use '
'DataSeries.style.color instead.', DeprecationWarning,
stacklevel=2)
return self.style.color
def _SetColor(self, color):
warnings.warn('DataSeries.color is deprecated, use '
'DataSeries.style.color instead.', DeprecationWarning,
stacklevel=2)
self.style.color = color
color = property(_GetColor, _SetColor)
class AxisPosition(object):
"""Represents all the available axis positions.
The available positions are as follows:
AxisPosition.TOP
AxisPosition.BOTTOM
AxisPosition.LEFT
AxisPosition.RIGHT
"""
LEFT = 'y'
RIGHT = 'r'
BOTTOM = 'x'
TOP = 't'
class Axis(object):
"""Represents one axis.
Object setings:
min: Minimum value for the bottom or left end of the axis
max: Max value.
labels: List of labels to show along the axis.
label_positions: List of positions to show the labels at. Uses the scale
set by min & max, so if you set min = 0 and max = 10, then
label positions [0, 5, 10] would be at the bottom,
middle, and top of the axis, respectively.
grid_spacing: Amount of space between gridlines (in min/max scale).
A value of 0 disables gridlines.
label_gridlines: If True, draw a line extending from each label
on the axis all the way across the chart.
"""
def __init__(self, axis_min=None, axis_max=None):
"""Construct a new Axis.
Args:
axis_min: smallest value on the axis
axis_max: largest value on the axis
"""
self.min = axis_min
self.max = axis_max
self.labels = []
self.label_positions = []
self.grid_spacing = 0
self.label_gridlines = False
# TODO: Add other chart types. Order of preference:
# - scatter plots
# - us/world maps
class BaseChart(object):
"""Base chart object with standard behavior for all other charts.
Object attributes:
data: List of DataSeries objects. Chart subtypes provide convenience
functions (like AddLine, AddBars, AddSegment) to add more series
later.
left/right/bottom/top: Axis objects for the 4 different axes.
formatters: A list of callables which will be used to format this chart for
display. TODO: Need better documentation for how these
work.
auto_scale, auto_color, auto_legend:
These aliases let users access the default formatters without poking
around in self.formatters. If the user removes them from
self.formatters then they will no longer be enabled, even though they'll
still be accessible through the aliases. Similarly, re-assigning the
aliases has no effect on the contents of self.formatters.
display: This variable is reserved for backends to populate with a display
object. The intention is that the display object would be used to
render this chart. The details of what gets put here depends on
the specific backend you are using.
"""
# Canonical ordering of position keys
_POSITION_CODES = 'yrxt'
# TODO: Add more inline args to __init__ (esp. labels).
# TODO: Support multiple series in the constructor, if given.
def __init__(self):
"""Construct a BaseChart object."""
self.data = []
self._axes = {}
for code in self._POSITION_CODES:
self._axes[code] = [Axis()]
self._legend_labels = [] # AutoLegend fills this out
self._show_legend = False # AutoLegend fills this out
# Aliases for default formatters
self.auto_color = formatters.AutoColor()
self.auto_scale = formatters.AutoScale()
self.auto_legend = formatters.AutoLegend
self.formatters = [self.auto_color, self.auto_scale, self.auto_legend]
# display is used to convert the chart into something displayable (like a
# url or img tag).
self.display = None
def AddFormatter(self, formatter):
"""Add a new formatter to the chart (convenience method)."""
self.formatters.append(formatter)
def AddSeries(self, points, color=None, style=None, markers=None,
label=None):
"""DEPRECATED
Add a new series of data to the chart; return the DataSeries object."""
warnings.warn('AddSeries is deprecated. Instead, call AddLine for '
'LineCharts, AddBars for BarCharts, AddSegment for '
'PieCharts ', DeprecationWarning, stacklevel=2)
series = DataSeries(points, color=color, style=style, markers=markers,
label=label)
self.data.append(series)
return series
def GetDependentAxes(self):
"""Return any dependent axes ('left' and 'right' by default for LineCharts,
although bar charts would use 'bottom' and 'top').
"""
return self._axes[AxisPosition.LEFT] + self._axes[AxisPosition.RIGHT]
def GetIndependentAxes(self):
"""Return any independent axes (normally top & bottom, although horizontal
bar charts use left & right by default).
"""
return self._axes[AxisPosition.TOP] + self._axes[AxisPosition.BOTTOM]
def GetDependentAxis(self):
"""Return this chart's main dependent axis (often 'left', but
horizontal bar-charts use 'bottom').
"""
return self.left
def GetIndependentAxis(self):
"""Return this chart's main independent axis (often 'bottom', but
horizontal bar-charts use 'left').
"""
return self.bottom
def _Clone(self):
"""Make a deep copy this chart.
Formatters & display will be missing from the copy, due to limitations in
deepcopy.
"""
orig_values = {}
# Things which deepcopy will likely choke on if it tries to copy.
uncopyables = ['formatters', 'display', 'auto_color', 'auto_scale',
'auto_legend']
for name in uncopyables:
orig_values[name] = getattr(self, name)
setattr(self, name, None)
clone = copy.deepcopy(self)
for name, orig_value in orig_values.iteritems():
setattr(self, name, orig_value)
return clone
def GetFormattedChart(self):
"""Get a copy of the chart with formatting applied."""
# Formatters need to mutate the chart, but we don't want to change it out
# from under the user. So, we work on a copy of the chart.
scratchpad = self._Clone()
for formatter in self.formatters:
formatter(scratchpad)
return scratchpad
def GetMinMaxValues(self):
"""Get the largest & smallest values in this chart, returned as
(min_value, max_value). Takes into account complciations like stacked data
series.
For example, with non-stacked series, a chart with [1, 2, 3] and [4, 5, 6]
would return (1, 6). If the same chart was stacking the data series, it
would return (5, 9).
"""
MinPoint = lambda data: min(x for x in data if x is not None)
MaxPoint = lambda data: max(x for x in data if x is not None)
mins = [MinPoint(series.data) for series in self.data if series.data]
maxes = [MaxPoint(series.data) for series in self.data if series.data]
if not mins or not maxes:
return None, None # No data, just bail.
return min(mins), max(maxes)
def AddAxis(self, position, axis):
"""Add an axis to this chart in the given position.
Args:
position: an AxisPosition object specifying the axis's position
axis: The axis to add, an Axis object
Returns:
the value of the axis parameter
"""
self._axes.setdefault(position, []).append(axis)
return axis
def GetAxis(self, position):
"""Get or create the first available axis in the given position.
This is a helper method for the left, right, top, and bottom properties.
If the specified axis does not exist, it will be created.
Args:
position: the position to search for
Returns:
The first axis in the given position
"""
# Not using setdefault here just in case, to avoid calling the Axis()
# constructor needlessly
if position in self._axes:
return self._axes[position][0]
else:
axis = Axis()
self._axes[position] = [axis]
return axis
def SetAxis(self, position, axis):
"""Set the first axis in the given position to the given value.
This is a helper method for the left, right, top, and bottom properties.
Args:
position: an AxisPosition object specifying the axis's position
axis: The axis to set, an Axis object
Returns:
the value of the axis parameter
"""
self._axes.setdefault(position, [None])[0] = axis
return axis
def _GetAxes(self):
"""Return a generator of (position_code, Axis) tuples for this chart's axes.
The axes will be sorted by position using the canonical ordering sequence,
_POSITION_CODES.
"""
for code in self._POSITION_CODES:
for axis in self._axes.get(code, []):
yield (code, axis)
def _GetBottom(self):
return self.GetAxis(AxisPosition.BOTTOM)
def _SetBottom(self, value):
self.SetAxis(AxisPosition.BOTTOM, value)
bottom = property(_GetBottom, _SetBottom,
doc="""Get or set the bottom axis""")
def _GetLeft(self):
return self.GetAxis(AxisPosition.LEFT)
def _SetLeft(self, value):
self.SetAxis(AxisPosition.LEFT, value)
left = property(_GetLeft, _SetLeft,
doc="""Get or set the left axis""")
def _GetRight(self):
return self.GetAxis(AxisPosition.RIGHT)
def _SetRight(self, value):
self.SetAxis(AxisPosition.RIGHT, value)
right = property(_GetRight, _SetRight,
doc="""Get or set the right axis""")
def _GetTop(self):
return self.GetAxis(AxisPosition.TOP)
def _SetTop(self, value):
self.SetAxis(AxisPosition.TOP, value)
top = property(_GetTop, _SetTop,
doc="""Get or set the top axis""")