2
pygooglechart - A complete Python wrapper for the Google Chart API
4
http://pygooglechart.slowchop.com/
6
Copyright 2007-2008 Gerald Kaszuba
8
This program is free software: you can redistribute it and/or modify
9
it under the terms of the GNU General Public License as published by
10
the Free Software Foundation, either version 3 of the License, or
11
(at your option) any later version.
13
This program is distributed in the hope that it will be useful,
14
but WITHOUT ANY WARRANTY; without even the implied warranty of
15
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16
GNU General Public License for more details.
18
You should have received a copy of the GNU General Public License
19
along with this program. If not, see <http://www.gnu.org/licenses/>.
32
# Helper variables and functions
33
# -----------------------------------------------------------------------------
36
__author__ = 'Gerald Kaszuba'
38
reo_colour = re.compile('^([A-Fa-f0-9]{2,2}){3,4}$')
40
def _check_colour(colour):
41
if not reo_colour.match(colour):
42
raise InvalidParametersException('Colours need to be in ' \
43
'RRGGBB or RRGGBBAA format. One of your colours has %s' % \
47
def _reset_warnings():
48
"""Helper function to reset all warnings. Used by the unit tests."""
49
globals()['__warningregistry__'] = None
53
# -----------------------------------------------------------------------------
56
class PyGoogleChartException(Exception):
60
class DataOutOfRangeException(PyGoogleChartException):
64
class UnknownDataTypeException(PyGoogleChartException):
68
class NoDataGivenException(PyGoogleChartException):
72
class InvalidParametersException(PyGoogleChartException):
76
class BadContentTypeException(PyGoogleChartException):
80
class AbstractClassException(PyGoogleChartException):
84
class UnknownChartType(PyGoogleChartException):
89
# -----------------------------------------------------------------------------
94
def __init__(self, data):
95
if type(self) == Data:
96
raise AbstractClassException('This is an abstract class')
100
def float_scale_value(cls, value, range):
102
assert(upper > lower)
103
scaled = (value - lower) * (float(cls.max_value) / (upper - lower))
107
def clip_value(cls, value):
108
return max(0, min(value, cls.max_value))
111
def int_scale_value(cls, value, range):
112
return int(round(cls.float_scale_value(value, range)))
115
def scale_value(cls, value, range):
116
scaled = cls.int_scale_value(value, range)
117
clipped = cls.clip_value(scaled)
118
Data.check_clip(scaled, clipped)
122
def check_clip(scaled, clipped):
123
if clipped != scaled:
124
warnings.warn('One or more of of your data points has been '
125
'clipped because it is out of range.')
128
class SimpleData(Data):
131
enc_map = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
135
for data in self.data:
140
elif value >= 0 and value <= self.max_value:
141
sub_data.append(SimpleData.enc_map[value])
143
raise DataOutOfRangeException('cannot encode value: %d'
145
encoded_data.append(''.join(sub_data))
146
return 'chd=s:' + ','.join(encoded_data)
149
class TextData(Data):
155
for data in self.data:
160
elif value >= 0 and value <= self.max_value:
161
sub_data.append("%.1f" % float(value))
163
raise DataOutOfRangeException()
164
encoded_data.append(','.join(sub_data))
165
return 'chd=t:' + '|'.join(encoded_data)
168
def scale_value(cls, value, range):
169
# use float values instead of integers because we don't need an encode
171
scaled = cls.float_scale_value(value, range)
172
clipped = cls.clip_value(scaled)
173
Data.check_clip(scaled, clipped)
177
class ExtendedData(Data):
181
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-.'
185
enc_size = len(ExtendedData.enc_map)
186
for data in self.data:
190
sub_data.append('__')
191
elif value >= 0 and value <= self.max_value:
192
first, second = divmod(int(value), enc_size)
193
sub_data.append('%s%s' % (
194
ExtendedData.enc_map[first],
195
ExtendedData.enc_map[second]))
197
raise DataOutOfRangeException( \
198
'Item #%i "%s" is out of range' % (data.index(value), \
200
encoded_data.append(''.join(sub_data))
201
return 'chd=e:' + ','.join(encoded_data)
205
# -----------------------------------------------------------------------------
214
TYPES = (BOTTOM, TOP, LEFT, RIGHT)
216
def __init__(self, axis_index, axis_type, **kw):
217
assert(axis_type in Axis.TYPES)
218
self.has_style = False
219
self.axis_index = axis_index
220
self.axis_type = axis_type
221
self.positions = None
223
def set_index(self, axis_index):
224
self.axis_index = axis_index
226
def set_positions(self, positions):
227
self.positions = positions
229
def set_style(self, colour, font_size=None, alignment=None):
230
_check_colour(colour)
232
self.font_size = font_size
233
self.alignment = alignment
234
self.has_style = True
236
def style_to_url(self):
238
bits.append(str(self.axis_index))
239
bits.append(self.colour)
240
if self.font_size is not None:
241
bits.append(str(self.font_size))
242
if self.alignment is not None:
243
bits.append(str(self.alignment))
244
return ','.join(bits)
246
def positions_to_url(self):
248
bits.append(str(self.axis_index))
249
bits += [str(a) for a in self.positions]
250
return ','.join(bits)
253
class LabelAxis(Axis):
255
def __init__(self, axis_index, axis_type, values, **kwargs):
256
Axis.__init__(self, axis_index, axis_type, **kwargs)
257
self.values = [str(a) for a in values]
260
return '%i:|%s' % (self.axis_index, '|'.join(self.values))
263
class RangeAxis(Axis):
265
def __init__(self, axis_index, axis_type, low, high, **kwargs):
266
Axis.__init__(self, axis_index, axis_type, **kwargs)
271
return '%i,%s,%s' % (self.axis_index, self.low, self.high)
274
# -----------------------------------------------------------------------------
278
"""Abstract class for all chart types.
280
width are height specify the dimensions of the image. title sets the title
281
of the chart. legend requires a list that corresponds to datasets.
284
BASE_URL = 'http://chart.apis.google.com/chart?'
288
VALID_SOLID_FILL_TYPES = (BACKGROUND, CHART, ALPHA)
290
LINEAR_GRADIENT = 'lg'
291
LINEAR_STRIPES = 'ls'
293
def __init__(self, width, height, title=None, legend=None, colours=None,
294
auto_scale=True, x_range=None, y_range=None,
295
colours_within_series=None):
296
if type(self) == Chart:
297
raise AbstractClassException('This is an abstract class')
298
assert(isinstance(width, int))
299
assert(isinstance(height, int))
303
self.set_title(title)
304
self.set_legend(legend)
305
self.set_legend_position(None)
306
self.set_colours(colours)
307
self.set_colours_within_series(colours_within_series)
310
self.auto_scale = auto_scale # Whether to automatically scale data
311
self.x_range = x_range # (min, max) x-axis range for scaling
312
self.y_range = y_range # (min, max) y-axis range for scaling
313
self.scaled_data_class = None
314
self.scaled_x_range = None
315
self.scaled_y_range = None
318
Chart.BACKGROUND: None,
323
Chart.BACKGROUND: None,
329
self.line_styles = {}
333
# -------------------------------------------------------------------------
335
def get_url(self, data_class=None):
336
url_bits = self.get_url_bits(data_class=data_class)
337
return self.BASE_URL + '&'.join(url_bits)
339
def get_url_bits(self, data_class=None):
342
url_bits.append(self.type_to_url())
343
url_bits.append('chs=%ix%i' % (self.width, self.height))
344
url_bits.append(self.data_to_url(data_class=data_class))
347
url_bits.append('chtt=%s' % self.title)
349
url_bits.append('chdl=%s' % '|'.join(self.legend))
350
if self.legend_position:
351
url_bits.append('chdlp=%s' % (self.legend_position))
353
url_bits.append('chco=%s' % ','.join(self.colours))
354
if self.colours_within_series:
355
url_bits.append('chco=%s' % '|'.join(self.colours_within_series))
356
ret = self.fill_to_url()
359
ret = self.axis_to_url()
363
url_bits.append(self.markers_to_url())
366
for index in xrange(max(self.line_styles) + 1):
367
if index in self.line_styles:
368
values = self.line_styles[index]
371
style.append(','.join(values))
372
url_bits.append('chls=%s' % '|'.join(style))
374
url_bits.append('chg=%s' % self.grid)
378
# -------------------------------------------------------------------------
380
def download(self, file_name):
381
opener = urllib2.urlopen(self.get_url())
383
if opener.headers['content-type'] != 'image/png':
384
raise BadContentTypeException('Server responded with a ' \
385
'content-type of %s' % opener.headers['content-type'])
387
open(file_name, 'wb').write(opener.read())
390
# -------------------------------------------------------------------------
392
def set_title(self, title):
394
self.title = urllib.quote(title)
398
def set_legend(self, legend):
399
"""legend needs to be a list, tuple or None"""
400
assert(isinstance(legend, list) or isinstance(legend, tuple) or
403
self.legend = [urllib.quote(a) for a in legend]
407
def set_legend_position(self, legend_position):
409
self.legend_position = urllib.quote(legend_position)
411
self.legend_position = None
414
# -------------------------------------------------------------------------
416
def set_colours(self, colours):
417
# colours needs to be a list, tuple or None
418
assert(isinstance(colours, list) or isinstance(colours, tuple) or
420
# make sure the colours are in the right format
424
self.colours = colours
426
def set_colours_within_series(self, colours):
427
# colours needs to be a list, tuple or None
428
assert(isinstance(colours, list) or isinstance(colours, tuple) or
430
# make sure the colours are in the right format
434
self.colours_within_series = colours
436
# Background/Chart colours
437
# -------------------------------------------------------------------------
439
def fill_solid(self, area, colour):
440
assert(area in Chart.VALID_SOLID_FILL_TYPES)
441
_check_colour(colour)
442
self.fill_area[area] = colour
443
self.fill_types[area] = Chart.SOLID
445
def _check_fill_linear(self, angle, *args):
446
assert(isinstance(args, list) or isinstance(args, tuple))
447
assert(angle >= 0 and angle <= 90)
448
assert(len(args) % 2 == 0)
449
args = list(args) # args is probably a tuple and we need to mutate
450
for a in xrange(len(args) / 2):
452
offset = args[a * 2 + 1]
454
assert(offset >= 0 and offset <= 1)
455
args[a * 2 + 1] = str(args[a * 2 + 1])
458
def fill_linear_gradient(self, area, angle, *args):
459
assert(area in Chart.VALID_SOLID_FILL_TYPES)
460
args = self._check_fill_linear(angle, *args)
461
self.fill_types[area] = Chart.LINEAR_GRADIENT
462
self.fill_area[area] = ','.join([str(angle)] + args)
464
def fill_linear_stripes(self, area, angle, *args):
465
assert(area in Chart.VALID_SOLID_FILL_TYPES)
466
args = self._check_fill_linear(angle, *args)
467
self.fill_types[area] = Chart.LINEAR_STRIPES
468
self.fill_area[area] = ','.join([str(angle)] + args)
470
def fill_to_url(self):
472
for area in (Chart.BACKGROUND, Chart.CHART, Chart.ALPHA):
473
if self.fill_types[area]:
474
areas.append('%s,%s,%s' % (area, self.fill_types[area], \
475
self.fill_area[area]))
477
return 'chf=' + '|'.join(areas)
480
# -------------------------------------------------------------------------
482
def data_class_detection(self, data):
483
"""Determines the appropriate data encoding type to give satisfactory
484
resolution (http://code.google.com/apis/chart/#chart_data).
486
assert(isinstance(data, list) or isinstance(data, tuple))
487
if not isinstance(self, (LineChart, BarChart, ScatterChart)):
488
# From the link above:
489
# Simple encoding is suitable for all other types of chart
490
# regardless of size.
492
elif self.height < 100:
493
# The link above indicates that line and bar charts less
494
# than 300px in size can be suitably represented with the
495
# simple encoding. I've found that this isn't sufficient,
496
# e.g. examples/line-xy-circle.png. Let's try 100px.
501
def _filter_none(self, data):
502
return [r for r in data if r is not None]
504
def data_x_range(self):
505
"""Return a 2-tuple giving the minimum and maximum x-axis
509
lower = min([min(self._filter_none(s))
510
for type, s in self.annotated_data()
512
upper = max([max(self._filter_none(s))
513
for type, s in self.annotated_data()
515
return (lower, upper)
517
return None # no x-axis datasets
519
def data_y_range(self):
520
"""Return a 2-tuple giving the minimum and maximum y-axis
524
lower = min([min(self._filter_none(s))
525
for type, s in self.annotated_data()
527
upper = max([max(self._filter_none(s)) + 1
528
for type, s in self.annotated_data()
530
return (lower, upper)
532
return None # no y-axis datasets
534
def scaled_data(self, data_class, x_range=None, y_range=None):
535
"""Scale `self.data` as appropriate for the given data encoding
536
(data_class) and return it.
538
An optional `y_range` -- a 2-tuple (lower, upper) -- can be
539
given to specify the y-axis bounds. If not given, the range is
540
inferred from the data: (0, <max-value>) presuming no negative
541
values, or (<min-value>, <max-value>) if there are negative
542
values. `self.scaled_y_range` is set to the actual lower and
545
Ditto for `x_range`. Note that some chart types don't have x-axis
548
self.scaled_data_class = data_class
550
# Determine the x-axis range for scaling.
552
x_range = self.data_x_range()
553
if x_range and x_range[0] > 0:
554
x_range = (x_range[0], x_range[1])
555
self.scaled_x_range = x_range
557
# Determine the y-axis range for scaling.
559
y_range = self.data_y_range()
560
if y_range and y_range[0] > 0:
561
y_range = (y_range[0], y_range[1])
562
self.scaled_y_range = y_range
565
for type, dataset in self.annotated_data():
567
scale_range = x_range
569
scale_range = y_range
570
elif type == 'marker-size':
571
scale_range = (0, max(dataset))
575
scaled_dataset.append(None)
577
scaled_dataset.append(
578
data_class.scale_value(v, scale_range))
579
scaled_data.append(scaled_dataset)
582
def add_data(self, data):
583
self.data.append(data)
584
return len(self.data) - 1 # return the "index" of the data set
586
def data_to_url(self, data_class=None):
588
data_class = self.data_class_detection(self.data)
589
if not issubclass(data_class, Data):
590
raise UnknownDataTypeException()
592
data = self.scaled_data(data_class, self.x_range, self.y_range)
595
return repr(data_class(data))
597
def annotated_data(self):
598
for dataset in self.data:
602
# -------------------------------------------------------------------------
604
def set_axis_labels(self, axis_type, values):
605
assert(axis_type in Axis.TYPES)
606
values = [urllib.quote(a) for a in values]
607
axis_index = len(self.axis)
608
axis = LabelAxis(axis_index, axis_type, values)
609
self.axis.append(axis)
612
def set_axis_range(self, axis_type, low, high):
613
assert(axis_type in Axis.TYPES)
614
axis_index = len(self.axis)
615
axis = RangeAxis(axis_index, axis_type, low, high)
616
self.axis.append(axis)
619
def set_axis_positions(self, axis_index, positions):
621
self.axis[axis_index].set_positions(positions)
623
raise InvalidParametersException('Axis index %i has not been ' \
626
def set_axis_style(self, axis_index, colour, font_size=None, \
629
self.axis[axis_index].set_style(colour, font_size, alignment)
631
raise InvalidParametersException('Axis index %i has not been ' \
634
def axis_to_url(self):
641
for axis in self.axis:
642
available_axis.append(axis.axis_type)
643
if isinstance(axis, RangeAxis):
644
range_axis.append(repr(axis))
645
if isinstance(axis, LabelAxis):
646
label_axis.append(repr(axis))
648
positions.append(axis.positions_to_url())
650
styles.append(axis.style_to_url())
651
if not available_axis:
654
url_bits.append('chxt=%s' % ','.join(available_axis))
656
url_bits.append('chxl=%s' % '|'.join(label_axis))
658
url_bits.append('chxr=%s' % '|'.join(range_axis))
660
url_bits.append('chxp=%s' % '|'.join(positions))
662
url_bits.append('chxs=%s' % '|'.join(styles))
663
return '&'.join(url_bits)
665
# Markers, Ranges and Fill area (chm)
666
# -------------------------------------------------------------------------
668
def markers_to_url(self):
669
return 'chm=%s' % '|'.join([','.join(a) for a in self.markers])
671
def add_marker(self, index, point, marker_type, colour, size, priority=0):
672
self.markers.append((marker_type, colour, str(index), str(point), \
673
str(size), str(priority)))
675
def add_horizontal_range(self, colour, start, stop):
676
self.markers.append(('r', colour, '0', str(start), str(stop)))
678
def add_data_line(self, colour, data_set, size, priority=0):
679
self.markers.append(('D', colour, str(data_set), '0', str(size), str(priority)))
681
def add_marker_text(self, string, colour, data_set, data_point, size, priority=0):
682
self.markers.append((str(string), colour, str(data_set), str(data_point), str(size), str(priority)))
684
def add_vertical_range(self, colour, start, stop):
685
self.markers.append(('R', colour, '0', str(start), str(stop)))
687
def add_fill_range(self, colour, index_start, index_end):
688
self.markers.append(('b', colour, str(index_start), str(index_end), \
691
def add_fill_simple(self, colour):
692
self.markers.append(('B', colour, '1', '1', '1'))
695
# -------------------------------------------------------------------------
697
def set_line_style(self, index, thickness=1, line_segment=None, \
700
value.append(str(thickness))
702
value.append(str(line_segment))
703
value.append(str(blank_segment))
704
self.line_styles[index] = value
707
# -------------------------------------------------------------------------
709
def set_grid(self, x_step, y_step, line_segment=1, \
711
self.grid = '%s,%s,%s,%s' % (x_step, y_step, line_segment, \
715
class ScatterChart(Chart):
717
def type_to_url(self):
720
def annotated_data(self):
721
yield ('x', self.data[0])
722
yield ('y', self.data[1])
723
if len(self.data) > 2:
724
# The optional third dataset is relative sizing for point
726
yield ('marker-size', self.data[2])
729
class LineChart(Chart):
731
def __init__(self, *args, **kwargs):
732
if type(self) == LineChart:
733
raise AbstractClassException('This is an abstract class')
734
Chart.__init__(self, *args, **kwargs)
737
class SimpleLineChart(LineChart):
739
def type_to_url(self):
742
def annotated_data(self):
743
# All datasets are y-axis data.
744
for dataset in self.data:
748
class SparkLineChart(SimpleLineChart):
750
def type_to_url(self):
754
class XYLineChart(LineChart):
756
def type_to_url(self):
759
def annotated_data(self):
760
# Datasets alternate between x-axis, y-axis.
761
for i, dataset in enumerate(self.data):
768
class BarChart(Chart):
770
def __init__(self, *args, **kwargs):
771
if type(self) == BarChart:
772
raise AbstractClassException('This is an abstract class')
773
Chart.__init__(self, *args, **kwargs)
774
self.bar_width = None
777
def set_bar_width(self, bar_width):
778
self.bar_width = bar_width
780
def set_zero_line(self, index, zero_line):
781
self.zero_lines[index] = zero_line
783
def get_url_bits(self, data_class=None, skip_chbh=False):
784
url_bits = Chart.get_url_bits(self, data_class=data_class)
785
if not skip_chbh and self.bar_width is not None:
786
url_bits.append('chbh=%i' % self.bar_width)
789
for index in xrange(max(self.zero_lines) + 1):
790
if index in self.zero_lines:
791
zero_line.append(str(self.zero_lines[index]))
793
zero_line.append('0')
794
url_bits.append('chp=%s' % ','.join(zero_line))
798
class StackedHorizontalBarChart(BarChart):
800
def type_to_url(self):
804
class StackedVerticalBarChart(BarChart):
806
def type_to_url(self):
809
def annotated_data(self):
810
for dataset in self.data:
814
class GroupedBarChart(BarChart):
816
def __init__(self, *args, **kwargs):
817
if type(self) == GroupedBarChart:
818
raise AbstractClassException('This is an abstract class')
819
BarChart.__init__(self, *args, **kwargs)
820
self.bar_spacing = None
821
self.group_spacing = None
823
def set_bar_spacing(self, spacing):
824
"""Set spacing between bars in a group."""
825
self.bar_spacing = spacing
827
def set_group_spacing(self, spacing):
828
"""Set spacing between groups of bars."""
829
self.group_spacing = spacing
831
def get_url_bits(self, data_class=None):
832
# Skip 'BarChart.get_url_bits' and call Chart directly so the parent
833
# doesn't add "chbh" before we do.
834
url_bits = BarChart.get_url_bits(self, data_class=data_class,
836
if self.group_spacing is not None:
837
if self.bar_spacing is None:
838
raise InvalidParametersException('Bar spacing is required ' \
839
'to be set when setting group spacing')
840
if self.bar_width is None:
841
raise InvalidParametersException('Bar width is required to ' \
842
'be set when setting bar spacing')
843
url_bits.append('chbh=%i,%i,%i'
844
% (self.bar_width, self.bar_spacing, self.group_spacing))
845
elif self.bar_spacing is not None:
846
if self.bar_width is None:
847
raise InvalidParametersException('Bar width is required to ' \
848
'be set when setting bar spacing')
849
url_bits.append('chbh=%i,%i' % (self.bar_width, self.bar_spacing))
851
url_bits.append('chbh=%i' % self.bar_width)
855
class GroupedHorizontalBarChart(GroupedBarChart):
857
def type_to_url(self):
861
class GroupedVerticalBarChart(GroupedBarChart):
863
def type_to_url(self):
866
def annotated_data(self):
867
for dataset in self.data:
871
class PieChart(Chart):
873
def __init__(self, *args, **kwargs):
874
if type(self) == PieChart:
875
raise AbstractClassException('This is an abstract class')
876
Chart.__init__(self, *args, **kwargs)
879
warnings.warn('y_range is not used with %s.' % \
880
(self.__class__.__name__))
882
def set_pie_labels(self, labels):
883
self.pie_labels = [urllib.quote(a) for a in labels]
885
def get_url_bits(self, data_class=None):
886
url_bits = Chart.get_url_bits(self, data_class=data_class)
888
url_bits.append('chl=%s' % '|'.join(self.pie_labels))
891
def annotated_data(self):
892
# Datasets are all y-axis data. However, there should only be
893
# one dataset for pie charts.
894
for dataset in self.data:
898
class PieChart2D(PieChart):
900
def type_to_url(self):
904
class PieChart3D(PieChart):
906
def type_to_url(self):
910
class VennChart(Chart):
912
def type_to_url(self):
915
def annotated_data(self):
916
for dataset in self.data:
920
class RadarChart(Chart):
922
def type_to_url(self):
926
class SplineRadarChart(RadarChart):
928
def type_to_url(self):
932
class MapChart(Chart):
934
def __init__(self, *args, **kwargs):
935
Chart.__init__(self, *args, **kwargs)
936
self.geo_area = 'world'
939
def type_to_url(self):
942
def set_codes(self, codes):
945
def get_url_bits(self, data_class=None):
946
url_bits = Chart.get_url_bits(self, data_class=data_class)
947
url_bits.append('chtm=%s' % self.geo_area)
949
url_bits.append('chld=%s' % ''.join(self.codes))
953
class GoogleOMeterChart(PieChart):
954
"""Inheriting from PieChart because of similar labeling"""
956
def __init__(self, *args, **kwargs):
957
PieChart.__init__(self, *args, **kwargs)
958
if self.auto_scale and not self.x_range:
959
warnings.warn('Please specify an x_range with GoogleOMeterChart, '
960
'otherwise one arrow will always be at the max.')
962
def type_to_url(self):
966
class QRChart(Chart):
968
def __init__(self, *args, **kwargs):
969
Chart.__init__(self, *args, **kwargs)
974
def type_to_url(self):
977
def data_to_url(self, data_class=None):
979
raise NoDataGivenException()
980
return 'chl=%s' % urllib.quote(self.data[0])
982
def get_url_bits(self, data_class=None):
983
url_bits = Chart.get_url_bits(self, data_class=data_class)
985
url_bits.append('choe=%s' % self.encoding)
987
url_bits.append('chld=%s|%s' % (self.ec_level, self.margin))
990
def set_encoding(self, encoding):
991
self.encoding = encoding
993
def set_ec(self, level, margin):
994
self.ec_level = level
998
class ChartGrammar(object):
1004
def parse(self, grammar):
1005
self.grammar = grammar
1006
self.chart = self.create_chart_instance()
1008
for attr in self.grammar:
1009
if attr in ('w', 'h', 'type', 'auto_scale', 'x_range', 'y_range'):
1010
continue # These are already parsed in create_chart_instance
1011
attr_func = 'parse_' + attr
1012
if not hasattr(self, attr_func):
1013
warnings.warn('No parser for grammar attribute "%s"' % (attr))
1015
getattr(self, attr_func)(grammar[attr])
1019
def parse_data(self, data):
1020
self.chart.data = data
1023
def get_possible_chart_types():
1024
possible_charts = []
1025
for cls_name in globals().keys():
1026
if not cls_name.endswith('Chart'):
1028
cls = globals()[cls_name]
1029
# Check if it is an abstract class
1031
a = cls(1, 1, auto_scale=False)
1033
except AbstractClassException:
1036
possible_charts.append(cls_name[:-5])
1037
return possible_charts
1039
def create_chart_instance(self, grammar=None):
1041
grammar = self.grammar
1042
assert(isinstance(grammar, dict)) # grammar must be a dict
1043
assert('w' in grammar) # width is required
1044
assert('h' in grammar) # height is required
1045
assert('type' in grammar) # type is required
1046
chart_type = grammar['type']
1049
auto_scale = grammar.get('auto_scale', None)
1050
x_range = grammar.get('x_range', None)
1051
y_range = grammar.get('y_range', None)
1052
types = ChartGrammar.get_possible_chart_types()
1053
if chart_type not in types:
1054
raise UnknownChartType('%s is an unknown chart type. Possible '
1055
'chart types are %s' % (chart_type, ','.join(types)))
1056
return globals()[chart_type + 'Chart'](w, h, auto_scale=auto_scale,
1057
x_range=x_range, y_range=y_range)
2
PyGoogleChart - A complete Python wrapper for the Google Chart API
4
http://pygooglechart.slowchop.com/
6
Copyright 2007 Gerald Kaszuba
8
This program is free software: you can redistribute it and/or modify
9
it under the terms of the GNU General Public License as published by
10
the Free Software Foundation, either version 3 of the License, or
11
(at your option) any later version.
13
This program is distributed in the hope that it will be useful,
14
but WITHOUT ANY WARRANTY; without even the implied warranty of
15
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16
GNU General Public License for more details.
18
You should have received a copy of the GNU General Public License
19
along with this program. If not, see <http://www.gnu.org/licenses/>.
29
# Helper variables and functions
30
# -----------------------------------------------------------------------------
32
reo_colour = re.compile('^([A-Fa-f0-9]{2,2}){3,4}$')
34
def _check_colour(colour):
35
if not reo_colour.match(colour):
36
raise InvalidParametersException('Colours need to be in ' \
37
'RRGGBB or RRGGBBAA format. One of your colours has %s' % \
41
# -----------------------------------------------------------------------------
43
class PyGoogleChartException(Exception):
46
class DataOutOfRangeException(PyGoogleChartException):
49
class UnknownDataTypeException(PyGoogleChartException):
52
class NoDataGivenException(PyGoogleChartException):
55
class InvalidParametersException(PyGoogleChartException):
59
# -----------------------------------------------------------------------------
62
def __init__(self, data):
63
assert(type(self) != Data) # This is an abstract class
66
class SimpleData(Data):
67
enc_map = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
70
for data in self.data:
75
elif value >= 0 and value <= SimpleData.max_value:
76
sub_data.append(SimpleData.enc_map[value])
78
raise DataOutOfRangeException()
79
encoded_data.append(''.join(sub_data))
80
return 'chd=s:' + ','.join(encoded_data)
88
for data in self.data:
93
elif value >= 0 and value <= TextData.max_value:
94
sub_data.append(str(float(value)))
96
raise DataOutOfRangeException()
97
encoded_data.append(','.join(sub_data))
98
return 'chd=t:' + '|'.join(encoded_data)
103
class ExtendedData(Data):
105
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-.'
108
enc_size = len(ExtendedData.enc_map)
109
for data in self.data:
113
sub_data.append('__')
114
elif value >= 0 and value <= ExtendedData.max_value:
115
first, second = divmod(int(value), enc_size)
116
sub_data.append('%s%s' % (
117
ExtendedData.enc_map[first],
118
ExtendedData.enc_map[second]))
120
raise DataOutOfRangeException( \
121
'Item #%i "%s" is out of range' % (data.index(value), \
123
encoded_data.append(''.join(sub_data))
124
return 'chd=e:' + ','.join(encoded_data)
130
# -----------------------------------------------------------------------------
137
TYPES = (BOTTOM, TOP, LEFT, RIGHT)
138
def __init__(self, axis, **kw):
139
assert(axis in Axis.TYPES)
140
self.has_style = False
142
self.positions = None
143
def set_index(self, index):
145
def set_positions(self, positions):
146
self.positions = positions
147
def set_style(self, colour, font_size=None, alignment=None):
148
_check_colour(colour)
150
self.font_size = font_size
151
self.alignment = alignment
152
self.has_style = True
153
def style_to_url(self):
155
bits.append(str(self.index))
156
bits.append(self.colour)
157
if self.font_size is not None:
158
bits.append(str(self.font_size))
159
if self.alignment is not None:
160
bits.append(str(self.alignment))
161
return ','.join(bits)
162
def positions_to_url(self):
164
bits.append(str(self.index))
165
bits += [ str(a) for a in self.positions ]
166
return ','.join(bits)
168
class LabelAxis(Axis):
169
def __init__(self, axis, values, **kwargs):
170
Axis.__init__(self, axis, **kwargs)
171
self.values = [ str(a) for a in values ]
173
return '%i:|%s' % (self.index, '|'.join(self.values))
175
class RangeAxis(Axis):
176
def __init__(self, axis, low, high, **kwargs):
177
Axis.__init__(self, axis, **kwargs)
181
return '%i,%s,%s' % (self.index, self.low, self.high)
184
# -----------------------------------------------------------------------------
188
BASE_URL = 'http://chart.apis.google.com/chart?'
192
LINEAR_GRADIENT = 'lg'
193
LINEAR_STRIPES = 'ls'
195
def __init__(self, width, height, title=None, legend=None, colours=None):
196
assert(type(self) != Chart) # This is an abstract class
197
assert(isinstance(width, int))
198
assert(isinstance(height, int))
202
self.set_title(title)
203
self.set_legend(legend)
204
self.set_colours(colours)
206
Chart.BACKGROUND: None,
210
Chart.BACKGROUND: None,
222
# -------------------------------------------------------------------------
225
url_bits = self.get_url_bits()
226
return self.BASE_URL + '&'.join(url_bits)
228
def get_url_bits(self):
231
url_bits.append(self.type_to_url())
232
url_bits.append('chs=%ix%i' % (self.width, self.height))
233
url_bits.append(self.data_to_url())
236
url_bits.append('chtt=%s' % self.title)
238
url_bits.append('chdl=%s' % '|'.join(self.legend))
240
url_bits.append('chco=%s' % ','.join(self.colours))
241
ret = self.fill_to_url()
244
ret = self.axis_to_url()
248
url_bits.append(self.markers_to_url())
252
# -------------------------------------------------------------------------
253
def download(self, file_name):
254
open(file_name, 'wb').write(urllib.urlopen(self.get_url()).read())
257
# -------------------------------------------------------------------------
259
def set_title(self, title):
261
self.title = urllib.quote(title)
265
def set_legend(self, legend):
266
# legend needs to be a list, tuple or None
267
assert(isinstance(legend, list) or isinstance(legend, tuple) or
270
self.legend = [ urllib.quote(a) for a in legend ]
275
# -------------------------------------------------------------------------
277
def set_colours(self, colours):
278
# colours needs to be a list, tuple or None
279
assert(isinstance(colours, list) or isinstance(colours, tuple) or
281
# make sure the colours are in the right format
285
self.colours = colours
287
# Background/Chart colours
288
# -------------------------------------------------------------------------
290
def fill_solid(self, area, colour):
291
assert(area in (Chart.BACKGROUND, Chart.CHART))
292
_check_colour(colour)
293
self.fill_area[area] = colour
294
self.fill_types[area] = Chart.SOLID
296
def _check_fill_linear(self, angle, *args):
297
assert(isinstance(args, list) or isinstance(args, tuple))
298
assert(angle >= 0 and angle <= 90)
299
assert(len(args) % 2 == 0)
300
args = list(args) # args is probably a tuple and we need to mutate
301
for a in xrange(len(args) / 2):
303
offset = args[a * 2 + 1]
305
assert(offset >= 0 and offset <= 1)
306
args[a * 2 + 1] = str(args[a * 2 + 1])
309
def fill_linear_gradient(self, area, angle, *args):
310
assert(area in (Chart.BACKGROUND, Chart.CHART))
311
args = self._check_fill_linear(angle, *args)
312
self.fill_types[area] = Chart.LINEAR_GRADIENT
313
self.fill_area[area] = ','.join([str(angle)] + args)
315
def fill_linear_stripes(self, area, angle, *args):
316
assert(area in (Chart.BACKGROUND, Chart.CHART))
317
args = self._check_fill_linear(angle, *args)
318
self.fill_types[area] = Chart.LINEAR_STRIPES
319
self.fill_area[area] = ','.join([str(angle)] + args)
321
def fill_to_url(self):
323
for area in (Chart.BACKGROUND, Chart.CHART):
324
if self.fill_types[area]:
325
areas.append('%s,%s,%s' % (area, self.fill_types[area], \
326
self.fill_area[area]))
328
return 'chf=' + '|'.join(areas)
331
# -------------------------------------------------------------------------
333
def data_class_detection(self, data):
335
Detects and returns the data type required based on the range of the
336
data given. The data given must be lists of numbers within a list.
338
assert(isinstance(data, list) or isinstance(data, tuple))
341
assert(isinstance(a, list) or isinstance(a, tuple))
342
if max_value is None or max(a) > max_value:
344
for data_class in (SimpleData, TextData, ExtendedData):
345
if max_value <= data_class.max_value():
347
raise DataOutOfRangeException()
349
def add_data(self, data):
350
self.data.append(data)
352
def data_to_url(self, data_class=None):
354
data_class = self.data_class_detection(self.data)
355
if not issubclass(data_class, Data):
356
raise UnknownDataTypeException()
357
return repr(data_class(self.data))
360
# -------------------------------------------------------------------------
362
def set_axis_labels(self, axis, values):
363
assert(axis in Axis.TYPES)
364
self.axis[axis] = LabelAxis(axis, values)
366
def set_axis_range(self, axis, low, high):
367
assert(axis in Axis.TYPES)
368
self.axis[axis] = RangeAxis(axis, low, high)
370
def set_axis_positions(self, axis, positions):
371
assert(axis in Axis.TYPES)
372
if not self.axis[axis]:
373
raise InvalidParametersException('Please create an axis first')
374
self.axis[axis].set_positions(positions)
376
def set_axis_style(self, axis, colour, font_size=None, alignment=None):
377
assert(axis in Axis.TYPES)
378
if not self.axis[axis]:
379
raise InvalidParametersException('Please create an axis first')
380
self.axis[axis].set_style(colour, font_size, alignment)
382
def axis_to_url(self):
389
for position, axis in self.axis.items():
393
axis.set_index(index)
394
available_axis.append(position)
395
if isinstance(axis, RangeAxis):
396
range_axis.append(repr(axis))
397
if isinstance(axis, LabelAxis):
398
label_axis.append(repr(axis))
400
positions.append(axis.positions_to_url())
402
styles.append(axis.style_to_url())
403
if not available_axis:
406
url_bits.append('chxt=%s' % ','.join(available_axis))
408
url_bits.append('chxl=%s' % '|'.join(label_axis))
410
url_bits.append('chxr=%s' % '|'.join(range_axis))
412
url_bits.append('chxp=%s' % '|'.join(positions))
414
url_bits.append('chxs=%s' % '|'.join(styles))
415
return '&'.join(url_bits)
417
# Markers, Ranges and Fill area (chm)
418
# -------------------------------------------------------------------------
420
def markers_to_url(self):
421
return 'chm=%s' % '|'.join([ ','.join(a) for a in self.markers ])
423
def add_marker(self, index, point, marker_type, colour, size):
424
self.markers.append((marker_type, colour, str(index), str(point), \
427
def add_horizontal_range(self, colour, start, stop):
428
self.markers.append(('r', colour, '1', str(start), str(stop)))
430
def add_vertical_range(self, colour, start, stop):
431
self.markers.append(('R', colour, '1', str(start), str(stop)))
433
def add_fill_range(self, colour, index_start, index_end):
434
self.markers.append(('b', colour, str(index_start), str(index_end), \
437
def add_fill_simple(self, colour):
438
self.markers.append(('B', colour, '1', '1', '1'))
440
class ScatterChart(Chart):
441
def __init__(self, *args, **kwargs):
442
Chart.__init__(self, *args, **kwargs)
443
def type_to_url(self):
447
class LineChart(Chart):
448
def __init__(self, *args, **kwargs):
449
Chart.__init__(self, *args, **kwargs)
450
self.line_styles = {}
452
def set_line_style(self, index, thickness=1, line_segment=None, \
455
value.append(str(thickness))
457
value.append(str(line_segment))
458
value.append(str(blank_segment))
459
self.line_styles[index] = value
460
def set_grid(self, x_step, y_step, line_segment=1, \
462
self.grid = '%s,%s,%s,%s' % (x_step, y_step, line_segment, \
464
def get_url_bits(self):
465
url_bits = Chart.get_url_bits(self)
468
# for index, values in self.line_style.items():
469
for index in xrange(max(self.line_styles) + 1):
470
if index in self.line_styles:
471
values = self.line_styles[index]
474
style.append(','.join(values))
475
url_bits.append('chls=%s' % '|'.join(style))
477
url_bits.append('chg=%s' % self.grid)
480
class SimpleLineChart(LineChart):
481
def type_to_url(self):
484
class XYLineChart(LineChart):
485
def type_to_url(self):
488
class BarChart(Chart):
489
def __init__(self, *args, **kwargs):
490
assert(type(self) != BarChart) # This is an abstract class
491
Chart.__init__(self, *args, **kwargs)
492
self.bar_width = None
493
def set_bar_width(self, bar_width):
494
self.bar_width = bar_width
495
def get_url_bits(self):
496
url_bits = Chart.get_url_bits(self)
497
url_bits.append('chbh=%i' % self.bar_width)
500
class StackedHorizontalBarChart(BarChart):
501
def type_to_url(self):
504
class StackedVerticalBarChart(BarChart):
505
def type_to_url(self):
508
class GroupedBarChart(BarChart):
509
def __init__(self, *args, **kwargs):
510
assert(type(self) != GroupedBarChart) # This is an abstract class
511
BarChart.__init__(self, *args, **kwargs)
512
self.bar_spacing = None
513
def set_bar_spacing(self, spacing):
514
self.bar_spacing = spacing
515
def get_url_bits(self):
516
# Skip 'BarChart.get_url_bits' and call Chart directly so the parent
517
# doesn't add "chbh" before we do.
518
url_bits = Chart.get_url_bits(self)
519
if self.bar_spacing is not None:
520
if self.bar_width is None:
521
raise InvalidParametersException('Bar width is required to ' \
522
'be set when setting spacing')
523
url_bits.append('chbh=%i,%i' % (self.bar_width, self.bar_spacing))
525
url_bits.append('chbh=%i' % self.bar_width)
528
class GroupedHorizontalBarChart(GroupedBarChart):
529
def type_to_url(self):
532
class GroupedVerticalBarChart(GroupedBarChart):
533
def type_to_url(self):
536
class PieChart(Chart):
537
def __init__(self, *args, **kwargs):
538
assert(type(self) != PieChart) # This is an abstract class
539
Chart.__init__(self, *args, **kwargs)
541
def set_pie_labels(self, labels):
542
self.pie_labels = labels
543
def get_url_bits(self):
544
url_bits = Chart.get_url_bits(self)
546
url_bits.append('chl=%s' % '|'.join(self.pie_labels))
549
class PieChart2D(PieChart):
550
def type_to_url(self):
553
class PieChart3D(PieChart):
554
def type_to_url(self):
557
class VennChart(Chart):
558
def type_to_url(self):
562
chart = GroupedVerticalBarChart(320, 200)
563
chart = PieChart2D(320, 200)
564
chart = ScatterChart(320, 200)
565
chart = SimpleLineChart(320, 200)
566
sine_data = [ math.sin(float(a) / 10) * 2000 + 2000 for a in xrange(100) ]
567
random_data = [ a * random.random() * 30 for a in xrange(40) ]
568
random_data2 = [ random.random() * 4000 for a in xrange(10) ]
569
# chart.set_bar_width(50)
570
# chart.set_bar_spacing(0)
571
chart.add_data(sine_data)
572
chart.add_data(random_data)
573
chart.add_data(random_data2)
574
# chart.set_line_style(1, thickness=2)
575
# chart.set_line_style(2, line_segment=10, blank_segment=5)
576
# chart.set_title('heloooo')
577
# chart.set_legend(('sine wave', 'random * x'))
578
# chart.set_colours(('ee2000', 'DDDDAA', 'fF03f2'))
579
# chart.fill_solid(Chart.BACKGROUND, '123456')
580
# chart.fill_linear_gradient(Chart.CHART, 20, '004070', 1, '300040', 0,
582
# chart.fill_linear_stripes(Chart.CHART, 20, '204070', .2, '300040', .2,
584
chart.set_axis_range(Axis.LEFT, 0, 10)
585
chart.set_axis_range(Axis.RIGHT, 5, 30)
586
chart.set_axis_labels(Axis.BOTTOM, [1, 25, 95])
587
chart.set_axis_positions(Axis.BOTTOM, [1, 25, 95])
588
chart.set_axis_style(Axis.BOTTOM, 'FFFFFF', 15)
590
# chart.set_pie_labels(('apples', 'oranges', 'bananas'))
592
# chart.set_grid(10, 10)
594
# for a in xrange(0, 100, 10):
595
# chart.add_marker(1, a, 'a', 'AACA20', 10)
597
chart.add_horizontal_range('00A020', .2, .5)
598
chart.add_vertical_range('00c030', .2, .4)
600
chart.add_fill_simple('303030A0')
603
chart = SimpleLineChart(320, 200)
604
data = [ 1, 5, 30, 10, 25 ]
606
chart.set_title('Hello World!')
607
chart.set_axis_range(Axis.LEFT, 0, 10)
608
print chart.get_url()
609
chart.download('hello.png')
611
url = chart.get_url()
614
data = urllib.urlopen(chart.get_url()).read()
615
open('meh.png', 'wb').write(data)
616
os.system('start meh.png')
618
if __name__ == '__main__':