summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--stats/pygooglechart.py1066
-rw-r--r--stats/stats.py50
2 files changed, 1116 insertions, 0 deletions
diff --git a/stats/pygooglechart.py b/stats/pygooglechart.py
new file mode 100644
index 0000000..0c17973
--- /dev/null
+++ b/stats/pygooglechart.py
@@ -0,0 +1,1066 @@
+"""
+pygooglechart - A complete Python wrapper for the Google Chart API
+
+http://pygooglechart.slowchop.com/
+
+Copyright 2007-2008 Gerald Kaszuba
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+"""
+
+import os
+import urllib
+import urllib2
+import math
+import random
+import re
+import warnings
+import copy
+
+# Helper variables and functions
+# -----------------------------------------------------------------------------
+
+__version__ = '0.2.1'
+__author__ = 'Gerald Kaszuba'
+
+reo_colour = re.compile('^([A-Fa-f0-9]{2,2}){3,4}$')
+
+def _check_colour(colour):
+ if not reo_colour.match(colour):
+ raise InvalidParametersException('Colours need to be in ' \
+ 'RRGGBB or RRGGBBAA format. One of your colours has %s' % \
+ colour)
+
+
+def _reset_warnings():
+ """Helper function to reset all warnings. Used by the unit tests."""
+ globals()['__warningregistry__'] = None
+
+
+# Exception Classes
+# -----------------------------------------------------------------------------
+
+
+class PyGoogleChartException(Exception):
+ pass
+
+
+class DataOutOfRangeException(PyGoogleChartException):
+ pass
+
+
+class UnknownDataTypeException(PyGoogleChartException):
+ pass
+
+
+class NoDataGivenException(PyGoogleChartException):
+ pass
+
+
+class InvalidParametersException(PyGoogleChartException):
+ pass
+
+
+class BadContentTypeException(PyGoogleChartException):
+ pass
+
+
+class AbstractClassException(PyGoogleChartException):
+ pass
+
+
+class UnknownChartType(PyGoogleChartException):
+ pass
+
+
+# Data Classes
+# -----------------------------------------------------------------------------
+
+
+class Data(object):
+
+ def __init__(self, data):
+ if type(self) == Data:
+ raise AbstractClassException('This is an abstract class')
+ self.data = data
+
+ @classmethod
+ def float_scale_value(cls, value, range):
+ lower, upper = range
+ assert(upper > lower)
+ scaled = (value - lower) * (float(cls.max_value) / (upper - lower))
+ return scaled
+
+ @classmethod
+ def clip_value(cls, value):
+ return max(0, min(value, cls.max_value))
+
+ @classmethod
+ def int_scale_value(cls, value, range):
+ return int(round(cls.float_scale_value(value, range)))
+
+ @classmethod
+ def scale_value(cls, value, range):
+ scaled = cls.int_scale_value(value, range)
+ clipped = cls.clip_value(scaled)
+ Data.check_clip(scaled, clipped)
+ return clipped
+
+ @staticmethod
+ def check_clip(scaled, clipped):
+ if clipped != scaled:
+ warnings.warn('One or more of of your data points has been '
+ 'clipped because it is out of range.')
+
+
+class SimpleData(Data):
+
+ max_value = 61
+ enc_map = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
+
+ def __repr__(self):
+ encoded_data = []
+ for data in self.data:
+ sub_data = []
+ for value in data:
+ if value is None:
+ sub_data.append('_')
+ elif value >= 0 and value <= self.max_value:
+ sub_data.append(SimpleData.enc_map[value])
+ else:
+ raise DataOutOfRangeException('cannot encode value: %d'
+ % value)
+ encoded_data.append(''.join(sub_data))
+ return 'chd=s:' + ','.join(encoded_data)
+
+
+class TextData(Data):
+
+ max_value = 100
+
+ def __repr__(self):
+ encoded_data = []
+ for data in self.data:
+ sub_data = []
+ for value in data:
+ if value is None:
+ sub_data.append(-1)
+ elif value >= 0 and value <= self.max_value:
+ sub_data.append("%.1f" % float(value))
+ else:
+ raise DataOutOfRangeException()
+ encoded_data.append(','.join(sub_data))
+ return 'chd=t:' + '|'.join(encoded_data)
+
+ @classmethod
+ def scale_value(cls, value, range):
+ # use float values instead of integers because we don't need an encode
+ # map index
+ scaled = cls.float_scale_value(value, range)
+ clipped = cls.clip_value(scaled)
+ Data.check_clip(scaled, clipped)
+ return clipped
+
+
+class ExtendedData(Data):
+
+ max_value = 4095
+ enc_map = \
+ 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-.'
+
+ def __repr__(self):
+ encoded_data = []
+ enc_size = len(ExtendedData.enc_map)
+ for data in self.data:
+ sub_data = []
+ for value in data:
+ if value is None:
+ sub_data.append('__')
+ elif value >= 0 and value <= self.max_value:
+ first, second = divmod(int(value), enc_size)
+ sub_data.append('%s%s' % (
+ ExtendedData.enc_map[first],
+ ExtendedData.enc_map[second]))
+ else:
+ raise DataOutOfRangeException( \
+ 'Item #%i "%s" is out of range' % (data.index(value), \
+ value))
+ encoded_data.append(''.join(sub_data))
+ return 'chd=e:' + ','.join(encoded_data)
+
+
+# Axis Classes
+# -----------------------------------------------------------------------------
+
+
+class Axis(object):
+
+ BOTTOM = 'x'
+ TOP = 't'
+ LEFT = 'y'
+ RIGHT = 'r'
+ TYPES = (BOTTOM, TOP, LEFT, RIGHT)
+
+ def __init__(self, axis_index, axis_type, **kw):
+ assert(axis_type in Axis.TYPES)
+ self.has_style = False
+ self.axis_index = axis_index
+ self.axis_type = axis_type
+ self.positions = None
+
+ def set_index(self, axis_index):
+ self.axis_index = axis_index
+
+ def set_positions(self, positions):
+ self.positions = positions
+
+ def set_style(self, colour, font_size=None, alignment=None):
+ _check_colour(colour)
+ self.colour = colour
+ self.font_size = font_size
+ self.alignment = alignment
+ self.has_style = True
+
+ def style_to_url(self):
+ bits = []
+ bits.append(str(self.axis_index))
+ bits.append(self.colour)
+ if self.font_size is not None:
+ bits.append(str(self.font_size))
+ if self.alignment is not None:
+ bits.append(str(self.alignment))
+ return ','.join(bits)
+
+ def positions_to_url(self):
+ bits = []
+ bits.append(str(self.axis_index))
+ bits += [str(a) for a in self.positions]
+ return ','.join(bits)
+
+
+class LabelAxis(Axis):
+
+ def __init__(self, axis_index, axis_type, values, **kwargs):
+ Axis.__init__(self, axis_index, axis_type, **kwargs)
+ self.values = [str(a) for a in values]
+
+ def __repr__(self):
+ return '%i:|%s' % (self.axis_index, '|'.join(self.values))
+
+
+class RangeAxis(Axis):
+
+ def __init__(self, axis_index, axis_type, low, high, **kwargs):
+ Axis.__init__(self, axis_index, axis_type, **kwargs)
+ self.low = low
+ self.high = high
+
+ def __repr__(self):
+ return '%i,%s,%s' % (self.axis_index, self.low, self.high)
+
+# Chart Classes
+# -----------------------------------------------------------------------------
+
+
+class Chart(object):
+ """Abstract class for all chart types.
+
+ width are height specify the dimensions of the image. title sets the title
+ of the chart. legend requires a list that corresponds to datasets.
+ """
+
+ BASE_URL = 'http://chart.apis.google.com/chart?'
+ BACKGROUND = 'bg'
+ CHART = 'c'
+ ALPHA = 'a'
+ VALID_SOLID_FILL_TYPES = (BACKGROUND, CHART, ALPHA)
+ SOLID = 's'
+ LINEAR_GRADIENT = 'lg'
+ LINEAR_STRIPES = 'ls'
+
+ def __init__(self, width, height, title=None, legend=None, colours=None,
+ auto_scale=True, x_range=None, y_range=None,
+ colours_within_series=None):
+ if type(self) == Chart:
+ raise AbstractClassException('This is an abstract class')
+ assert(isinstance(width, int))
+ assert(isinstance(height, int))
+ self.width = width
+ self.height = height
+ self.data = []
+ self.set_title(title)
+ self.set_legend(legend)
+ self.set_legend_position(None)
+ self.set_colours(colours)
+ self.set_colours_within_series(colours_within_series)
+
+ # Data for scaling.
+ self.auto_scale = auto_scale # Whether to automatically scale data
+ self.x_range = x_range # (min, max) x-axis range for scaling
+ self.y_range = y_range # (min, max) y-axis range for scaling
+ self.scaled_data_class = None
+ self.scaled_x_range = None
+ self.scaled_y_range = None
+
+ self.fill_types = {
+ Chart.BACKGROUND: None,
+ Chart.CHART: None,
+ Chart.ALPHA: None,
+ }
+ self.fill_area = {
+ Chart.BACKGROUND: None,
+ Chart.CHART: None,
+ Chart.ALPHA: None,
+ }
+ self.axis = []
+ self.markers = []
+ self.line_styles = {}
+ self.grid = None
+
+ # URL generation
+ # -------------------------------------------------------------------------
+
+ def get_url(self, data_class=None):
+ url_bits = self.get_url_bits(data_class=data_class)
+ return self.BASE_URL + '&'.join(url_bits)
+
+ def get_url_bits(self, data_class=None):
+ url_bits = []
+ # required arguments
+ url_bits.append(self.type_to_url())
+ url_bits.append('chs=%ix%i' % (self.width, self.height))
+ url_bits.append(self.data_to_url(data_class=data_class))
+ # optional arguments
+ if self.title:
+ url_bits.append('chtt=%s' % self.title)
+ if self.legend:
+ url_bits.append('chdl=%s' % '|'.join(self.legend))
+ if self.legend_position:
+ url_bits.append('chdlp=%s' % (self.legend_position))
+ if self.colours:
+ url_bits.append('chco=%s' % ','.join(self.colours))
+ if self.colours_within_series:
+ url_bits.append('chco=%s' % '|'.join(self.colours_within_series))
+ ret = self.fill_to_url()
+ if ret:
+ url_bits.append(ret)
+ ret = self.axis_to_url()
+ if ret:
+ url_bits.append(ret)
+ if self.markers:
+ url_bits.append(self.markers_to_url())
+ if self.line_styles:
+ style = []
+ for index in xrange(max(self.line_styles) + 1):
+ if index in self.line_styles:
+ values = self.line_styles[index]
+ else:
+ values = ('1', )
+ style.append(','.join(values))
+ url_bits.append('chls=%s' % '|'.join(style))
+ if self.grid:
+ url_bits.append('chg=%s' % self.grid)
+ return url_bits
+
+ # Downloading
+ # -------------------------------------------------------------------------
+
+ def download(self, file_name):
+ opener = urllib2.urlopen(self.get_url())
+
+ if opener.headers['content-type'] != 'image/png':
+ raise BadContentTypeException('Server responded with a ' \
+ 'content-type of %s' % opener.headers['content-type'])
+
+ open(file_name, 'wb').write(opener.read())
+
+ # Simple settings
+ # -------------------------------------------------------------------------
+
+ def set_title(self, title):
+ if title:
+ self.title = urllib.quote(title)
+ else:
+ self.title = None
+
+ def set_legend(self, legend):
+ """legend needs to be a list, tuple or None"""
+ assert(isinstance(legend, list) or isinstance(legend, tuple) or
+ legend is None)
+ if legend:
+ self.legend = [urllib.quote(a) for a in legend]
+ else:
+ self.legend = None
+
+ def set_legend_position(self, legend_position):
+ if legend_position:
+ self.legend_position = urllib.quote(legend_position)
+ else:
+ self.legend_position = None
+
+ # Chart colours
+ # -------------------------------------------------------------------------
+
+ def set_colours(self, colours):
+ # colours needs to be a list, tuple or None
+ assert(isinstance(colours, list) or isinstance(colours, tuple) or
+ colours is None)
+ # make sure the colours are in the right format
+ if colours:
+ for col in colours:
+ _check_colour(col)
+ self.colours = colours
+
+ def set_colours_within_series(self, colours):
+ # colours needs to be a list, tuple or None
+ assert(isinstance(colours, list) or isinstance(colours, tuple) or
+ colours is None)
+ # make sure the colours are in the right format
+ if colours:
+ for col in colours:
+ _check_colour(col)
+ self.colours_within_series = colours
+
+ # Background/Chart colours
+ # -------------------------------------------------------------------------
+
+ def fill_solid(self, area, colour):
+ assert(area in Chart.VALID_SOLID_FILL_TYPES)
+ _check_colour(colour)
+ self.fill_area[area] = colour
+ self.fill_types[area] = Chart.SOLID
+
+ def _check_fill_linear(self, angle, *args):
+ assert(isinstance(args, list) or isinstance(args, tuple))
+ assert(angle >= 0 and angle <= 90)
+ assert(len(args) % 2 == 0)
+ args = list(args) # args is probably a tuple and we need to mutate
+ for a in xrange(len(args) / 2):
+ col = args[a * 2]
+ offset = args[a * 2 + 1]
+ _check_colour(col)
+ assert(offset >= 0 and offset <= 1)
+ args[a * 2 + 1] = str(args[a * 2 + 1])
+ return args
+
+ def fill_linear_gradient(self, area, angle, *args):
+ assert(area in Chart.VALID_SOLID_FILL_TYPES)
+ args = self._check_fill_linear(angle, *args)
+ self.fill_types[area] = Chart.LINEAR_GRADIENT
+ self.fill_area[area] = ','.join([str(angle)] + args)
+
+ def fill_linear_stripes(self, area, angle, *args):
+ assert(area in Chart.VALID_SOLID_FILL_TYPES)
+ args = self._check_fill_linear(angle, *args)
+ self.fill_types[area] = Chart.LINEAR_STRIPES
+ self.fill_area[area] = ','.join([str(angle)] + args)
+
+ def fill_to_url(self):
+ areas = []
+ for area in (Chart.BACKGROUND, Chart.CHART, Chart.ALPHA):
+ if self.fill_types[area]:
+ areas.append('%s,%s,%s' % (area, self.fill_types[area], \
+ self.fill_area[area]))
+ if areas:
+ return 'chf=' + '|'.join(areas)
+
+ # Data
+ # -------------------------------------------------------------------------
+
+ def data_class_detection(self, data):
+ """Determines the appropriate data encoding type to give satisfactory
+ resolution (http://code.google.com/apis/chart/#chart_data).
+ """
+ assert(isinstance(data, list) or isinstance(data, tuple))
+ if not isinstance(self, (LineChart, BarChart, ScatterChart)):
+ # From the link above:
+ # Simple encoding is suitable for all other types of chart
+ # regardless of size.
+ return SimpleData
+ elif self.height < 100:
+ # The link above indicates that line and bar charts less
+ # than 300px in size can be suitably represented with the
+ # simple encoding. I've found that this isn't sufficient,
+ # e.g. examples/line-xy-circle.png. Let's try 100px.
+ return SimpleData
+ else:
+ return ExtendedData
+
+ def _filter_none(self, data):
+ return [r for r in data if r is not None]
+
+ def data_x_range(self):
+ """Return a 2-tuple giving the minimum and maximum x-axis
+ data range.
+ """
+ try:
+ lower = min([min(self._filter_none(s))
+ for type, s in self.annotated_data()
+ if type == 'x'])
+ upper = max([max(self._filter_none(s))
+ for type, s in self.annotated_data()
+ if type == 'x'])
+ return (lower, upper)
+ except ValueError:
+ return None # no x-axis datasets
+
+ def data_y_range(self):
+ """Return a 2-tuple giving the minimum and maximum y-axis
+ data range.
+ """
+ try:
+ lower = min([min(self._filter_none(s))
+ for type, s in self.annotated_data()
+ if type == 'y'])
+ upper = max([max(self._filter_none(s)) + 1
+ for type, s in self.annotated_data()
+ if type == 'y'])
+ return (lower, upper)
+ except ValueError:
+ return None # no y-axis datasets
+
+ def scaled_data(self, data_class, x_range=None, y_range=None):
+ """Scale `self.data` as appropriate for the given data encoding
+ (data_class) and return it.
+
+ An optional `y_range` -- a 2-tuple (lower, upper) -- can be
+ given to specify the y-axis bounds. If not given, the range is
+ inferred from the data: (0, <max-value>) presuming no negative
+ values, or (<min-value>, <max-value>) if there are negative
+ values. `self.scaled_y_range` is set to the actual lower and
+ upper scaling range.
+
+ Ditto for `x_range`. Note that some chart types don't have x-axis
+ data.
+ """
+ self.scaled_data_class = data_class
+
+ # Determine the x-axis range for scaling.
+ if x_range is None:
+ x_range = self.data_x_range()
+ if x_range and x_range[0] > 0:
+ x_range = (x_range[0], x_range[1])
+ self.scaled_x_range = x_range
+
+ # Determine the y-axis range for scaling.
+ if y_range is None:
+ y_range = self.data_y_range()
+ if y_range and y_range[0] > 0:
+ y_range = (y_range[0], y_range[1])
+ self.scaled_y_range = y_range
+
+ scaled_data = []
+ for type, dataset in self.annotated_data():
+ if type == 'x':
+ scale_range = x_range
+ elif type == 'y':
+ scale_range = y_range
+ elif type == 'marker-size':
+ scale_range = (0, max(dataset))
+ scaled_dataset = []
+ for v in dataset:
+ if v is None:
+ scaled_dataset.append(None)
+ else:
+ scaled_dataset.append(
+ data_class.scale_value(v, scale_range))
+ scaled_data.append(scaled_dataset)
+ return scaled_data
+
+ def add_data(self, data):
+ self.data.append(data)
+ return len(self.data) - 1 # return the "index" of the data set
+
+ def data_to_url(self, data_class=None):
+ if not data_class:
+ data_class = self.data_class_detection(self.data)
+ if not issubclass(data_class, Data):
+ raise UnknownDataTypeException()
+ if self.auto_scale:
+ data = self.scaled_data(data_class, self.x_range, self.y_range)
+ else:
+ data = self.data
+ return repr(data_class(data))
+
+ def annotated_data(self):
+ for dataset in self.data:
+ yield ('x', dataset)
+
+ # Axis Labels
+ # -------------------------------------------------------------------------
+
+ def set_axis_labels(self, axis_type, values):
+ assert(axis_type in Axis.TYPES)
+ values = [urllib.quote(str(a)) for a in values]
+ axis_index = len(self.axis)
+ axis = LabelAxis(axis_index, axis_type, values)
+ self.axis.append(axis)
+ return axis_index
+
+ def set_axis_range(self, axis_type, low, high):
+ assert(axis_type in Axis.TYPES)
+ axis_index = len(self.axis)
+ axis = RangeAxis(axis_index, axis_type, low, high)
+ self.axis.append(axis)
+ return axis_index
+
+ def set_axis_positions(self, axis_index, positions):
+ try:
+ self.axis[axis_index].set_positions(positions)
+ except IndexError:
+ raise InvalidParametersException('Axis index %i has not been ' \
+ 'created' % axis)
+
+ def set_axis_style(self, axis_index, colour, font_size=None, \
+ alignment=None):
+ try:
+ self.axis[axis_index].set_style(colour, font_size, alignment)
+ except IndexError:
+ raise InvalidParametersException('Axis index %i has not been ' \
+ 'created' % axis)
+
+ def axis_to_url(self):
+ available_axis = []
+ label_axis = []
+ range_axis = []
+ positions = []
+ styles = []
+ index = -1
+ for axis in self.axis:
+ available_axis.append(axis.axis_type)
+ if isinstance(axis, RangeAxis):
+ range_axis.append(repr(axis))
+ if isinstance(axis, LabelAxis):
+ label_axis.append(repr(axis))
+ if axis.positions:
+ positions.append(axis.positions_to_url())
+ if axis.has_style:
+ styles.append(axis.style_to_url())
+ if not available_axis:
+ return
+ url_bits = []
+ url_bits.append('chxt=%s' % ','.join(available_axis))
+ if label_axis:
+ url_bits.append('chxl=%s' % '|'.join(label_axis))
+ if range_axis:
+ url_bits.append('chxr=%s' % '|'.join(range_axis))
+ if positions:
+ url_bits.append('chxp=%s' % '|'.join(positions))
+ if styles:
+ url_bits.append('chxs=%s' % '|'.join(styles))
+ return '&'.join(url_bits)
+
+ # Markers, Ranges and Fill area (chm)
+ # -------------------------------------------------------------------------
+
+ def markers_to_url(self):
+ return 'chm=%s' % '|'.join([','.join(a) for a in self.markers])
+
+ def add_marker(self, index, point, marker_type, colour, size, priority=0):
+ self.markers.append((marker_type, colour, str(index), str(point), \
+ str(size), str(priority)))
+
+ def add_horizontal_range(self, colour, start, stop):
+ self.markers.append(('r', colour, '0', str(start), str(stop)))
+
+ def add_data_line(self, colour, data_set, size, priority=0):
+ self.markers.append(('D', colour, str(data_set), '0', str(size), str(priority)))
+
+ def add_marker_text(self, string, colour, data_set, data_point, size, priority=0):
+ self.markers.append((str(string), colour, str(data_set), str(data_point), str(size), str(priority)))
+
+ def add_vertical_range(self, colour, start, stop):
+ self.markers.append(('R', colour, '0', str(start), str(stop)))
+
+ def add_fill_range(self, colour, index_start, index_end):
+ self.markers.append(('b', colour, str(index_start), str(index_end), \
+ '1'))
+
+ def add_fill_simple(self, colour):
+ self.markers.append(('B', colour, '1', '1', '1'))
+
+ # Line styles
+ # -------------------------------------------------------------------------
+
+ def set_line_style(self, index, thickness=1, line_segment=None, \
+ blank_segment=None):
+ value = []
+ value.append(str(thickness))
+ if line_segment:
+ value.append(str(line_segment))
+ value.append(str(blank_segment))
+ self.line_styles[index] = value
+
+ # Grid
+ # -------------------------------------------------------------------------
+
+ def set_grid(self, x_step, y_step, line_segment=1, \
+ blank_segment=0):
+ self.grid = '%s,%s,%s,%s' % (x_step, y_step, line_segment, \
+ blank_segment)
+
+
+class ScatterChart(Chart):
+
+ def type_to_url(self):
+ return 'cht=s'
+
+ def annotated_data(self):
+ yield ('x', self.data[0])
+ yield ('y', self.data[1])
+ if len(self.data) > 2:
+ # The optional third dataset is relative sizing for point
+ # markers.
+ yield ('marker-size', self.data[2])
+
+
+class LineChart(Chart):
+
+ def __init__(self, *args, **kwargs):
+ if type(self) == LineChart:
+ raise AbstractClassException('This is an abstract class')
+ Chart.__init__(self, *args, **kwargs)
+
+
+class SimpleLineChart(LineChart):
+
+ def type_to_url(self):
+ return 'cht=lc'
+
+ def annotated_data(self):
+ # All datasets are y-axis data.
+ for dataset in self.data:
+ yield ('y', dataset)
+
+
+class SparkLineChart(SimpleLineChart):
+
+ def type_to_url(self):
+ return 'cht=ls'
+
+
+class XYLineChart(LineChart):
+
+ def type_to_url(self):
+ return 'cht=lxy'
+
+ def annotated_data(self):
+ # Datasets alternate between x-axis, y-axis.
+ for i, dataset in enumerate(self.data):
+ if i % 2 == 0:
+ yield ('x', dataset)
+ else:
+ yield ('y', dataset)
+
+
+class BarChart(Chart):
+
+ def __init__(self, *args, **kwargs):
+ if type(self) == BarChart:
+ raise AbstractClassException('This is an abstract class')
+ Chart.__init__(self, *args, **kwargs)
+ self.bar_width = None
+ self.zero_lines = {}
+
+ def set_bar_width(self, bar_width):
+ self.bar_width = bar_width
+
+ def set_zero_line(self, index, zero_line):
+ self.zero_lines[index] = zero_line
+
+ def get_url_bits(self, data_class=None, skip_chbh=False):
+ url_bits = Chart.get_url_bits(self, data_class=data_class)
+ if not skip_chbh and self.bar_width is not None:
+ url_bits.append('chbh=%i' % self.bar_width)
+ zero_line = []
+ if self.zero_lines:
+ for index in xrange(max(self.zero_lines) + 1):
+ if index in self.zero_lines:
+ zero_line.append(str(self.zero_lines[index]))
+ else:
+ zero_line.append('0')
+ url_bits.append('chp=%s' % ','.join(zero_line))
+ return url_bits
+
+
+class StackedHorizontalBarChart(BarChart):
+
+ def type_to_url(self):
+ return 'cht=bhs'
+
+
+class StackedVerticalBarChart(BarChart):
+
+ def type_to_url(self):
+ return 'cht=bvs'
+
+ def annotated_data(self):
+ for dataset in self.data:
+ yield ('y', dataset)
+
+
+class GroupedBarChart(BarChart):
+
+ def __init__(self, *args, **kwargs):
+ if type(self) == GroupedBarChart:
+ raise AbstractClassException('This is an abstract class')
+ BarChart.__init__(self, *args, **kwargs)
+ self.bar_spacing = None
+ self.group_spacing = None
+
+ def set_bar_spacing(self, spacing):
+ """Set spacing between bars in a group."""
+ self.bar_spacing = spacing
+
+ def set_group_spacing(self, spacing):
+ """Set spacing between groups of bars."""
+ self.group_spacing = spacing
+
+ def get_url_bits(self, data_class=None):
+ # Skip 'BarChart.get_url_bits' and call Chart directly so the parent
+ # doesn't add "chbh" before we do.
+ url_bits = BarChart.get_url_bits(self, data_class=data_class,
+ skip_chbh=True)
+ if self.group_spacing is not None:
+ if self.bar_spacing is None:
+ raise InvalidParametersException('Bar spacing is required ' \
+ 'to be set when setting group spacing')
+ if self.bar_width is None:
+ raise InvalidParametersException('Bar width is required to ' \
+ 'be set when setting bar spacing')
+ url_bits.append('chbh=%i,%i,%i'
+ % (self.bar_width, self.bar_spacing, self.group_spacing))
+ elif self.bar_spacing is not None:
+ if self.bar_width is None:
+ raise InvalidParametersException('Bar width is required to ' \
+ 'be set when setting bar spacing')
+ url_bits.append('chbh=%i,%i' % (self.bar_width, self.bar_spacing))
+ elif self.bar_width:
+ url_bits.append('chbh=%i' % self.bar_width)
+ return url_bits
+
+
+class GroupedHorizontalBarChart(GroupedBarChart):
+
+ def type_to_url(self):
+ return 'cht=bhg'
+
+
+class GroupedVerticalBarChart(GroupedBarChart):
+
+ def type_to_url(self):
+ return 'cht=bvg'
+
+ def annotated_data(self):
+ for dataset in self.data:
+ yield ('y', dataset)
+
+
+class PieChart(Chart):
+
+ def __init__(self, *args, **kwargs):
+ if type(self) == PieChart:
+ raise AbstractClassException('This is an abstract class')
+ Chart.__init__(self, *args, **kwargs)
+ self.pie_labels = []
+ if self.y_range:
+ warnings.warn('y_range is not used with %s.' % \
+ (self.__class__.__name__))
+
+ def set_pie_labels(self, labels):
+ self.pie_labels = [urllib.quote(a) for a in labels]
+
+ def get_url_bits(self, data_class=None):
+ url_bits = Chart.get_url_bits(self, data_class=data_class)
+ if self.pie_labels:
+ url_bits.append('chl=%s' % '|'.join(self.pie_labels))
+ return url_bits
+
+ def annotated_data(self):
+ # Datasets are all y-axis data. However, there should only be
+ # one dataset for pie charts.
+ for dataset in self.data:
+ yield ('x', dataset)
+
+ def scaled_data(self, data_class, x_range=None, y_range=None):
+ if not x_range:
+ x_range = [0, sum(self.data[0])]
+ return Chart.scaled_data(self, data_class, x_range, self.y_range)
+
+
+class PieChart2D(PieChart):
+
+ def type_to_url(self):
+ return 'cht=p'
+
+
+class PieChart3D(PieChart):
+
+ def type_to_url(self):
+ return 'cht=p3'
+
+
+class VennChart(Chart):
+
+ def type_to_url(self):
+ return 'cht=v'
+
+ def annotated_data(self):
+ for dataset in self.data:
+ yield ('y', dataset)
+
+
+class RadarChart(Chart):
+
+ def type_to_url(self):
+ return 'cht=r'
+
+
+class SplineRadarChart(RadarChart):
+
+ def type_to_url(self):
+ return 'cht=rs'
+
+
+class MapChart(Chart):
+
+ def __init__(self, *args, **kwargs):
+ Chart.__init__(self, *args, **kwargs)
+ self.geo_area = 'world'
+ self.codes = []
+
+ def type_to_url(self):
+ return 'cht=t'
+
+ def set_codes(self, codes):
+ self.codes = codes
+
+ def get_url_bits(self, data_class=None):
+ url_bits = Chart.get_url_bits(self, data_class=data_class)
+ url_bits.append('chtm=%s' % self.geo_area)
+ if self.codes:
+ url_bits.append('chld=%s' % ''.join(self.codes))
+ return url_bits
+
+
+class GoogleOMeterChart(PieChart):
+ """Inheriting from PieChart because of similar labeling"""
+
+ def __init__(self, *args, **kwargs):
+ PieChart.__init__(self, *args, **kwargs)
+ if self.auto_scale and not self.x_range:
+ warnings.warn('Please specify an x_range with GoogleOMeterChart, '
+ 'otherwise one arrow will always be at the max.')
+
+ def type_to_url(self):
+ return 'cht=gom'
+
+
+class QRChart(Chart):
+
+ def __init__(self, *args, **kwargs):
+ Chart.__init__(self, *args, **kwargs)
+ self.encoding = None
+ self.ec_level = None
+ self.margin = None
+
+ def type_to_url(self):
+ return 'cht=qr'
+
+ def data_to_url(self, data_class=None):
+ if not self.data:
+ raise NoDataGivenException()
+ return 'chl=%s' % urllib.quote(self.data[0])
+
+ def get_url_bits(self, data_class=None):
+ url_bits = Chart.get_url_bits(self, data_class=data_class)
+ if self.encoding:
+ url_bits.append('choe=%s' % self.encoding)
+ if self.ec_level:
+ url_bits.append('chld=%s|%s' % (self.ec_level, self.margin))
+ return url_bits
+
+ def set_encoding(self, encoding):
+ self.encoding = encoding
+
+ def set_ec(self, level, margin):
+ self.ec_level = level
+ self.margin = margin
+
+
+class ChartGrammar(object):
+
+ def __init__(self):
+ self.grammar = None
+ self.chart = None
+
+ def parse(self, grammar):
+ self.grammar = grammar
+ self.chart = self.create_chart_instance()
+
+ for attr in self.grammar:
+ if attr in ('w', 'h', 'type', 'auto_scale', 'x_range', 'y_range'):
+ continue # These are already parsed in create_chart_instance
+ attr_func = 'parse_' + attr
+ if not hasattr(self, attr_func):
+ warnings.warn('No parser for grammar attribute "%s"' % (attr))
+ continue
+ getattr(self, attr_func)(grammar[attr])
+
+ return self.chart
+
+ def parse_data(self, data):
+ self.chart.data = data
+
+ @staticmethod
+ def get_possible_chart_types():
+ possible_charts = []
+ for cls_name in globals().keys():
+ if not cls_name.endswith('Chart'):
+ continue
+ cls = globals()[cls_name]
+ # Check if it is an abstract class
+ try:
+ a = cls(1, 1, auto_scale=False)
+ del a
+ except AbstractClassException:
+ continue
+ # Strip off "Class"
+ possible_charts.append(cls_name[:-5])
+ return possible_charts
+
+ def create_chart_instance(self, grammar=None):
+ if not grammar:
+ grammar = self.grammar
+ assert(isinstance(grammar, dict)) # grammar must be a dict
+ assert('w' in grammar) # width is required
+ assert('h' in grammar) # height is required
+ assert('type' in grammar) # type is required
+ chart_type = grammar['type']
+ w = grammar['w']
+ h = grammar['h']
+ auto_scale = grammar.get('auto_scale', None)
+ x_range = grammar.get('x_range', None)
+ y_range = grammar.get('y_range', None)
+ types = ChartGrammar.get_possible_chart_types()
+ if chart_type not in types:
+ raise UnknownChartType('%s is an unknown chart type. Possible '
+ 'chart types are %s' % (chart_type, ','.join(types)))
+ return globals()[chart_type + 'Chart'](w, h, auto_scale=auto_scale,
+ x_range=x_range, y_range=y_range)
+
+ def download(self):
+ pass
+
diff --git a/stats/stats.py b/stats/stats.py
new file mode 100644
index 0000000..6eada02
--- /dev/null
+++ b/stats/stats.py
@@ -0,0 +1,50 @@
+#!/usr/bin/env python
+
+from pygooglechart import SimpleLineChart
+from collections import defaultdict
+
+WIDTH = 600
+HEIGHT = 200
+
+def main():
+ data = {}
+ order = []
+ snakes = []
+ for line in open('../results.csv'):
+ game_id, name, length, life = line[:-1].split(',')
+ game_id = int(game_id)
+ length = int(length)
+ life = float(life)
+
+ if name not in data:
+ snakes.append(name)
+ data[name] = {}
+
+ if game_id not in order:
+ order.append(game_id)
+
+ data[name][game_id] = (length, life)
+
+ length_chart = SimpleLineChart(WIDTH, HEIGHT)
+ time_chart = SimpleLineChart(WIDTH, HEIGHT)
+
+ for name in snakes:
+ time_series = []
+ length_series = []
+
+ for game_id in order:
+ length, time = data[name].get(game_id, (None, None))
+ time_series.append(time)
+ length_series.append(length)
+
+ time_chart.add_data(time_series)
+ length_chart.add_data(length_series)
+
+ length_chart.download('length_chart.png')
+ time_chart.download('time_chart.png')
+
+ print 'Chart update!'
+
+if __name__ == '__main__':
+ main()
+