2
pygooglechart - A complete Python wrapper for the Google Chart API
4
http://pygooglechart.slowchop.com/
6
Copyright 2007-2009 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/>.
22
from __future__ import division
33
# Helper variables and functions
34
# -----------------------------------------------------------------------------
37
__author__ = 'Gerald Kaszuba'
39
reo_colour = re.compile('^([A-Fa-f0-9]{2,2}){3,4}$')
41
def _check_colour(colour):
42
if not reo_colour.match(colour):
43
raise InvalidParametersException('Colours need to be in ' \
44
'RRGGBB or RRGGBBAA format. One of your colours has %s' % \
48
def _reset_warnings():
49
"""Helper function to reset all warnings. Used by the unit tests."""
50
globals()['__warningregistry__'] = None
54
# -----------------------------------------------------------------------------
57
class PyGoogleChartException(Exception):
61
class DataOutOfRangeException(PyGoogleChartException):
65
class UnknownDataTypeException(PyGoogleChartException):
69
class NoDataGivenException(PyGoogleChartException):
73
class InvalidParametersException(PyGoogleChartException):
77
class BadContentTypeException(PyGoogleChartException):
81
class AbstractClassException(PyGoogleChartException):
85
class UnknownChartType(PyGoogleChartException):
88
class UnknownCountryCodeException(PyGoogleChartException):
92
# -----------------------------------------------------------------------------
97
def __init__(self, data):
98
if type(self) == Data:
99
raise AbstractClassException('This is an abstract class')
103
def float_scale_value(cls, value, range):
105
assert(upper > lower)
106
scaled = (value - lower) * (cls.max_value / (upper - lower))
110
def clip_value(cls, value):
111
return max(0, min(value, cls.max_value))
114
def int_scale_value(cls, value, range):
115
return int(round(cls.float_scale_value(value, range)))
118
def scale_value(cls, value, range):
119
scaled = cls.int_scale_value(value, range)
120
clipped = cls.clip_value(scaled)
121
Data.check_clip(scaled, clipped)
125
def check_clip(scaled, clipped):
126
if clipped != scaled:
127
warnings.warn('One or more of of your data points has been '
128
'clipped because it is out of range.')
131
class SimpleData(Data):
134
enc_map = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
138
for data in self.data:
143
elif value >= 0 and value <= self.max_value:
144
sub_data.append(SimpleData.enc_map[value])
146
raise DataOutOfRangeException('cannot encode value: %d'
148
encoded_data.append(''.join(sub_data))
149
return 'chd=s:' + ','.join(encoded_data)
152
class TextData(Data):
158
for data in self.data:
163
elif value >= 0 and value <= self.max_value:
164
sub_data.append("%.1f" % float(value))
166
raise DataOutOfRangeException()
167
encoded_data.append(','.join(sub_data))
168
return 'chd=t:' + '%7c'.join(encoded_data)
171
def scale_value(cls, value, range):
172
# use float values instead of integers because we don't need an encode
174
scaled = cls.float_scale_value(value, range)
175
clipped = cls.clip_value(scaled)
176
Data.check_clip(scaled, clipped)
180
class ExtendedData(Data):
184
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-.'
188
enc_size = len(ExtendedData.enc_map)
189
for data in self.data:
193
sub_data.append('__')
194
elif value >= 0 and value <= self.max_value:
195
first, second = divmod(int(value), enc_size)
196
sub_data.append('%s%s' % (
197
ExtendedData.enc_map[first],
198
ExtendedData.enc_map[second]))
200
raise DataOutOfRangeException( \
201
'Item #%i "%s" is out of range' % (data.index(value), \
203
encoded_data.append(''.join(sub_data))
204
return 'chd=e:' + ','.join(encoded_data)
208
# -----------------------------------------------------------------------------
217
TYPES = (BOTTOM, TOP, LEFT, RIGHT)
219
def __init__(self, axis_index, axis_type, **kw):
220
assert(axis_type in Axis.TYPES)
221
self.has_style = False
222
self.axis_index = axis_index
223
self.axis_type = axis_type
224
self.positions = None
226
def set_index(self, axis_index):
227
self.axis_index = axis_index
229
def set_positions(self, positions):
230
self.positions = positions
232
def set_style(self, colour, font_size=None, alignment=None):
233
_check_colour(colour)
235
self.font_size = font_size
236
self.alignment = alignment
237
self.has_style = True
239
def style_to_url(self):
241
bits.append(str(self.axis_index))
242
bits.append(self.colour)
243
if self.font_size is not None:
244
bits.append(str(self.font_size))
245
if self.alignment is not None:
246
bits.append(str(self.alignment))
247
return ','.join(bits)
249
def positions_to_url(self):
251
bits.append(str(self.axis_index))
252
bits += [str(a) for a in self.positions]
253
return ','.join(bits)
256
class LabelAxis(Axis):
258
def __init__(self, axis_index, axis_type, values, **kwargs):
259
Axis.__init__(self, axis_index, axis_type, **kwargs)
260
self.values = [str(a) for a in values]
263
return '%i:%%7c%s' % (self.axis_index, '%7c'.join(self.values))
266
class RangeAxis(Axis):
268
def __init__(self, axis_index, axis_type, low, high, **kwargs):
269
Axis.__init__(self, axis_index, axis_type, **kwargs)
274
return '%i,%s,%s' % (self.axis_index, self.low, self.high)
277
# -----------------------------------------------------------------------------
281
"""Abstract class for all chart types.
283
width are height specify the dimensions of the image. title sets the title
284
of the chart. legend requires a list that corresponds to datasets.
287
BASE_URL = 'http://chart.apis.google.com/chart?'
291
VALID_SOLID_FILL_TYPES = (BACKGROUND, CHART, ALPHA)
293
LINEAR_GRADIENT = 'lg'
294
LINEAR_STRIPES = 'ls'
296
def __init__(self, width, height, title=None, legend=None, colours=None,
297
auto_scale=True, x_range=None, y_range=None,
298
colours_within_series=None):
299
if type(self) == Chart:
300
raise AbstractClassException('This is an abstract class')
301
assert(isinstance(width, int))
302
assert(isinstance(height, int))
306
self.set_title(title)
307
self.set_title_style(None, None)
308
self.set_legend(legend)
309
self.set_legend_position(None)
310
self.set_colours(colours)
311
self.set_colours_within_series(colours_within_series)
314
self.auto_scale = auto_scale # Whether to automatically scale data
315
self.x_range = x_range # (min, max) x-axis range for scaling
316
self.y_range = y_range # (min, max) y-axis range for scaling
317
self.scaled_data_class = None
318
self.scaled_x_range = None
319
self.scaled_y_range = None
322
Chart.BACKGROUND: None,
327
Chart.BACKGROUND: None,
333
self.line_styles = {}
335
self.title_colour = None
336
self.title_font_size = None
339
# -------------------------------------------------------------------------
341
def get_url(self, data_class=None):
342
url_bits = self.get_url_bits(data_class=data_class)
343
return self.BASE_URL + '&'.join(url_bits)
345
def get_url_bits(self, data_class=None):
348
url_bits.append(self.type_to_url())
349
url_bits.append('chs=%ix%i' % (self.width, self.height))
350
url_bits.append(self.data_to_url(data_class=data_class))
353
url_bits.append('chtt=%s' % self.title)
354
if self.title_colour and self.title_font_size:
355
url_bits.append('chts=%s,%s' % (self.title_colour, \
356
self.title_font_size))
358
url_bits.append('chdl=%s' % '%7c'.join(self.legend))
359
if self.legend_position:
360
url_bits.append('chdlp=%s' % (self.legend_position))
362
url_bits.append('chco=%s' % ','.join(self.colours))
363
if self.colours_within_series:
364
url_bits.append('chco=%s' % '%7c'.join(self.colours_within_series))
365
ret = self.fill_to_url()
368
ret = self.axis_to_url()
372
url_bits.append(self.markers_to_url())
375
for index in xrange(max(self.line_styles) + 1):
376
if index in self.line_styles:
377
values = self.line_styles[index]
380
style.append(','.join(values))
381
url_bits.append('chls=%s' % '%7c'.join(style))
383
url_bits.append('chg=%s' % self.grid)
387
# -------------------------------------------------------------------------
389
def download(self, file_name):
390
opener = urllib2.urlopen(self.get_url())
392
if opener.headers['content-type'] != 'image/png':
393
raise BadContentTypeException('Server responded with a ' \
394
'content-type of %s' % opener.headers['content-type'])
396
open(file_name, 'wb').write(opener.read())
399
# -------------------------------------------------------------------------
401
def set_title(self, title):
403
self.title = urllib.quote(title)
407
def set_title_style(self, colour=None, font_size=None):
408
if not colour is None:
409
_check_colour(colour)
410
if not colour and not font_size:
412
self.title_colour = colour or '333333'
413
self.title_font_size = font_size or 13.5
415
def set_legend(self, legend):
416
"""legend needs to be a list, tuple or None"""
417
assert(isinstance(legend, list) or isinstance(legend, tuple) or
420
self.legend = [urllib.quote(a) for a in legend]
424
def set_legend_position(self, legend_position):
426
self.legend_position = urllib.quote(legend_position)
428
self.legend_position = None
431
# -------------------------------------------------------------------------
433
def set_colours(self, colours):
434
# colours needs to be a list, tuple or None
435
assert(isinstance(colours, list) or isinstance(colours, tuple) or
437
# make sure the colours are in the right format
441
self.colours = colours
443
def set_colours_within_series(self, colours):
444
# colours needs to be a list, tuple or None
445
assert(isinstance(colours, list) or isinstance(colours, tuple) or
447
# make sure the colours are in the right format
451
self.colours_within_series = colours
453
# Background/Chart colours
454
# -------------------------------------------------------------------------
456
def fill_solid(self, area, colour):
457
assert(area in Chart.VALID_SOLID_FILL_TYPES)
458
_check_colour(colour)
459
self.fill_area[area] = colour
460
self.fill_types[area] = Chart.SOLID
462
def _check_fill_linear(self, angle, *args):
463
assert(isinstance(args, list) or isinstance(args, tuple))
464
assert(angle >= 0 and angle <= 90)
465
assert(len(args) % 2 == 0)
466
args = list(args) # args is probably a tuple and we need to mutate
467
for a in xrange(int(len(args) / 2)):
469
offset = args[a * 2 + 1]
471
assert(offset >= 0 and offset <= 1)
472
args[a * 2 + 1] = str(args[a * 2 + 1])
475
def fill_linear_gradient(self, area, angle, *args):
476
assert(area in Chart.VALID_SOLID_FILL_TYPES)
477
args = self._check_fill_linear(angle, *args)
478
self.fill_types[area] = Chart.LINEAR_GRADIENT
479
self.fill_area[area] = ','.join([str(angle)] + args)
481
def fill_linear_stripes(self, area, angle, *args):
482
assert(area in Chart.VALID_SOLID_FILL_TYPES)
483
args = self._check_fill_linear(angle, *args)
484
self.fill_types[area] = Chart.LINEAR_STRIPES
485
self.fill_area[area] = ','.join([str(angle)] + args)
487
def fill_to_url(self):
489
for area in (Chart.BACKGROUND, Chart.CHART, Chart.ALPHA):
490
if self.fill_types[area]:
491
areas.append('%s,%s,%s' % (area, self.fill_types[area], \
492
self.fill_area[area]))
494
return 'chf=' + '%7c'.join(areas)
497
# -------------------------------------------------------------------------
499
def data_class_detection(self, data):
500
"""Determines the appropriate data encoding type to give satisfactory
501
resolution (http://code.google.com/apis/chart/#chart_data).
503
assert(isinstance(data, list) or isinstance(data, tuple))
504
if not isinstance(self, (LineChart, BarChart, ScatterChart)):
505
# From the link above:
506
# Simple encoding is suitable for all other types of chart
507
# regardless of size.
509
elif self.height < 100:
510
# The link above indicates that line and bar charts less
511
# than 300px in size can be suitably represented with the
512
# simple encoding. I've found that this isn't sufficient,
513
# e.g. examples/line-xy-circle.png. Let's try 100px.
518
def _filter_none(self, data):
519
return [r for r in data if r is not None]
521
def data_x_range(self):
522
"""Return a 2-tuple giving the minimum and maximum x-axis
526
lower = min([min(self._filter_none(s))
527
for type, s in self.annotated_data()
529
upper = max([max(self._filter_none(s))
530
for type, s in self.annotated_data()
532
return (lower, upper)
534
return None # no x-axis datasets
536
def data_y_range(self):
537
"""Return a 2-tuple giving the minimum and maximum y-axis
541
lower = min([min(self._filter_none(s))
542
for type, s in self.annotated_data()
544
upper = max([max(self._filter_none(s)) + 1
545
for type, s in self.annotated_data()
547
return (lower, upper)
549
return None # no y-axis datasets
551
def scaled_data(self, data_class, x_range=None, y_range=None):
552
"""Scale `self.data` as appropriate for the given data encoding
553
(data_class) and return it.
555
An optional `y_range` -- a 2-tuple (lower, upper) -- can be
556
given to specify the y-axis bounds. If not given, the range is
557
inferred from the data: (0, <max-value>) presuming no negative
558
values, or (<min-value>, <max-value>) if there are negative
559
values. `self.scaled_y_range` is set to the actual lower and
562
Ditto for `x_range`. Note that some chart types don't have x-axis
565
self.scaled_data_class = data_class
567
# Determine the x-axis range for scaling.
569
x_range = self.data_x_range()
570
if x_range and x_range[0] > 0:
571
x_range = (x_range[0], x_range[1])
572
self.scaled_x_range = x_range
574
# Determine the y-axis range for scaling.
576
y_range = self.data_y_range()
577
if y_range and y_range[0] > 0:
578
y_range = (y_range[0], y_range[1])
579
self.scaled_y_range = y_range
582
for type, dataset in self.annotated_data():
584
scale_range = x_range
586
scale_range = y_range
587
elif type == 'marker-size':
588
scale_range = (0, max(dataset))
592
scaled_dataset.append(None)
594
scaled_dataset.append(
595
data_class.scale_value(v, scale_range))
596
scaled_data.append(scaled_dataset)
599
def add_data(self, data):
600
self.data.append(data)
601
return len(self.data) - 1 # return the "index" of the data set
603
def data_to_url(self, data_class=None):
605
data_class = self.data_class_detection(self.data)
606
if not issubclass(data_class, Data):
607
raise UnknownDataTypeException()
609
data = self.scaled_data(data_class, self.x_range, self.y_range)
612
return repr(data_class(data))
614
def annotated_data(self):
615
for dataset in self.data:
619
# -------------------------------------------------------------------------
621
def set_axis_labels(self, axis_type, values):
622
assert(axis_type in Axis.TYPES)
623
values = [urllib.quote(str(a)) for a in values]
624
axis_index = len(self.axis)
625
axis = LabelAxis(axis_index, axis_type, values)
626
self.axis.append(axis)
629
def set_axis_range(self, axis_type, low, high):
630
assert(axis_type in Axis.TYPES)
631
axis_index = len(self.axis)
632
axis = RangeAxis(axis_index, axis_type, low, high)
633
self.axis.append(axis)
636
def set_axis_positions(self, axis_index, positions):
638
self.axis[axis_index].set_positions(positions)
640
raise InvalidParametersException('Axis index %i has not been ' \
643
def set_axis_style(self, axis_index, colour, font_size=None, \
646
self.axis[axis_index].set_style(colour, font_size, alignment)
648
raise InvalidParametersException('Axis index %i has not been ' \
651
def axis_to_url(self):
658
for axis in self.axis:
659
available_axis.append(axis.axis_type)
660
if isinstance(axis, RangeAxis):
661
range_axis.append(repr(axis))
662
if isinstance(axis, LabelAxis):
663
label_axis.append(repr(axis))
665
positions.append(axis.positions_to_url())
667
styles.append(axis.style_to_url())
668
if not available_axis:
671
url_bits.append('chxt=%s' % ','.join(available_axis))
673
url_bits.append('chxl=%s' % '%7c'.join(label_axis))
675
url_bits.append('chxr=%s' % '%7c'.join(range_axis))
677
url_bits.append('chxp=%s' % '%7c'.join(positions))
679
url_bits.append('chxs=%s' % '%7c'.join(styles))
680
return '&'.join(url_bits)
682
# Markers, Ranges and Fill area (chm)
683
# -------------------------------------------------------------------------
685
def markers_to_url(self):
686
return 'chm=%s' % '%7c'.join([','.join(a) for a in self.markers])
688
def add_marker(self, index, point, marker_type, colour, size, priority=0):
689
self.markers.append((marker_type, colour, str(index), str(point), \
690
str(size), str(priority)))
692
def add_horizontal_range(self, colour, start, stop):
693
self.markers.append(('r', colour, '0', str(start), str(stop)))
695
def add_data_line(self, colour, data_set, size, priority=0):
696
self.markers.append(('D', colour, str(data_set), '0', str(size), \
699
def add_marker_text(self, string, colour, data_set, data_point, size, \
701
self.markers.append((str(string), colour, str(data_set), \
702
str(data_point), str(size), str(priority)))
704
def add_vertical_range(self, colour, start, stop):
705
self.markers.append(('R', colour, '0', str(start), str(stop)))
707
def add_fill_range(self, colour, index_start, index_end):
708
self.markers.append(('b', colour, str(index_start), str(index_end), \
711
def add_fill_simple(self, colour):
712
self.markers.append(('B', colour, '1', '1', '1'))
715
# -------------------------------------------------------------------------
717
def set_line_style(self, index, thickness=1, line_segment=None, \
720
value.append(str(thickness))
722
value.append(str(line_segment))
723
value.append(str(blank_segment))
724
self.line_styles[index] = value
727
# -------------------------------------------------------------------------
729
def set_grid(self, x_step, y_step, line_segment=1, \
731
self.grid = '%s,%s,%s,%s' % (x_step, y_step, line_segment, \
735
class ScatterChart(Chart):
737
def type_to_url(self):
740
def annotated_data(self):
741
yield ('x', self.data[0])
742
yield ('y', self.data[1])
743
if len(self.data) > 2:
744
# The optional third dataset is relative sizing for point
746
yield ('marker-size', self.data[2])
749
class LineChart(Chart):
751
def __init__(self, *args, **kwargs):
752
if type(self) == LineChart:
753
raise AbstractClassException('This is an abstract class')
754
Chart.__init__(self, *args, **kwargs)
757
class SimpleLineChart(LineChart):
759
def type_to_url(self):
762
def annotated_data(self):
763
# All datasets are y-axis data.
764
for dataset in self.data:
768
class SparkLineChart(SimpleLineChart):
770
def type_to_url(self):
774
class XYLineChart(LineChart):
776
def type_to_url(self):
779
def annotated_data(self):
780
# Datasets alternate between x-axis, y-axis.
781
for i, dataset in enumerate(self.data):
788
class BarChart(Chart):
790
def __init__(self, *args, **kwargs):
791
if type(self) == BarChart:
792
raise AbstractClassException('This is an abstract class')
793
Chart.__init__(self, *args, **kwargs)
794
self.bar_width = None
797
def set_bar_width(self, bar_width):
798
self.bar_width = bar_width
800
def set_zero_line(self, index, zero_line):
801
self.zero_lines[index] = zero_line
803
def get_url_bits(self, data_class=None, skip_chbh=False):
804
url_bits = Chart.get_url_bits(self, data_class=data_class)
805
if not skip_chbh and self.bar_width is not None:
806
url_bits.append('chbh=%i' % self.bar_width)
809
for index in xrange(max(self.zero_lines) + 1):
810
if index in self.zero_lines:
811
zero_line.append(str(self.zero_lines[index]))
813
zero_line.append('0')
814
url_bits.append('chp=%s' % ','.join(zero_line))
818
class StackedHorizontalBarChart(BarChart):
820
def type_to_url(self):
824
class StackedVerticalBarChart(BarChart):
826
def type_to_url(self):
829
def annotated_data(self):
830
for dataset in self.data:
834
class GroupedBarChart(BarChart):
836
def __init__(self, *args, **kwargs):
837
if type(self) == GroupedBarChart:
838
raise AbstractClassException('This is an abstract class')
839
BarChart.__init__(self, *args, **kwargs)
840
self.bar_spacing = None
841
self.group_spacing = None
843
def set_bar_spacing(self, spacing):
844
"""Set spacing between bars in a group."""
845
self.bar_spacing = spacing
847
def set_group_spacing(self, spacing):
848
"""Set spacing between groups of bars."""
849
self.group_spacing = spacing
851
def get_url_bits(self, data_class=None):
852
# Skip 'BarChart.get_url_bits' and call Chart directly so the parent
853
# doesn't add "chbh" before we do.
854
url_bits = BarChart.get_url_bits(self, data_class=data_class,
856
if self.group_spacing is not None:
857
if self.bar_spacing is None:
858
raise InvalidParametersException('Bar spacing is required ' \
859
'to be set when setting group spacing')
860
if self.bar_width is None:
861
raise InvalidParametersException('Bar width is required to ' \
862
'be set when setting bar spacing')
863
url_bits.append('chbh=%i,%i,%i'
864
% (self.bar_width, self.bar_spacing, self.group_spacing))
865
elif self.bar_spacing is not None:
866
if self.bar_width is None:
867
raise InvalidParametersException('Bar width is required to ' \
868
'be set when setting bar spacing')
869
url_bits.append('chbh=%i,%i' % (self.bar_width, self.bar_spacing))
871
url_bits.append('chbh=%i' % self.bar_width)
875
class GroupedHorizontalBarChart(GroupedBarChart):
877
def type_to_url(self):
881
class GroupedVerticalBarChart(GroupedBarChart):
883
def type_to_url(self):
886
def annotated_data(self):
887
for dataset in self.data:
891
class PieChart(Chart):
893
def __init__(self, *args, **kwargs):
894
if type(self) == PieChart:
895
raise AbstractClassException('This is an abstract class')
896
Chart.__init__(self, *args, **kwargs)
899
warnings.warn('y_range is not used with %s.' % \
900
(self.__class__.__name__))
902
def set_pie_labels(self, labels):
903
self.pie_labels = [urllib.quote(a) for a in labels]
905
def get_url_bits(self, data_class=None):
906
url_bits = Chart.get_url_bits(self, data_class=data_class)
908
url_bits.append('chl=%s' % '%7c'.join(self.pie_labels))
911
def annotated_data(self):
912
# Datasets are all y-axis data. However, there should only be
913
# one dataset for pie charts.
914
for dataset in self.data:
917
def scaled_data(self, data_class, x_range=None, y_range=None):
919
x_range = [0, sum(self.data[0])]
920
return Chart.scaled_data(self, data_class, x_range, self.y_range)
923
class PieChart2D(PieChart):
925
def type_to_url(self):
929
class PieChart3D(PieChart):
931
def type_to_url(self):
935
class VennChart(Chart):
937
def type_to_url(self):
940
def annotated_data(self):
941
for dataset in self.data:
945
class RadarChart(Chart):
947
def type_to_url(self):
951
class SplineRadarChart(RadarChart):
953
def type_to_url(self):
957
class MapChart(Chart):
959
def __init__(self, *args, **kwargs):
960
Chart.__init__(self, *args, **kwargs)
961
self.geo_area = 'world'
963
self.__areas = ('africa', 'asia', 'europe', 'middle_east',
964
'south_america', 'usa', 'world')
966
'AD', 'AE', 'AF', 'AG', 'AI', 'AL', 'AM', 'AN', 'AO', 'AQ', 'AR',
967
'AS', 'AT', 'AU', 'AW', 'AX', 'AZ', 'BA', 'BB', 'BD', 'BE', 'BF',
968
'BG', 'BH', 'BI', 'BJ', 'BL', 'BM', 'BN', 'BO', 'BR', 'BS', 'BT',
969
'BV', 'BW', 'BY', 'BZ', 'CA', 'CC', 'CD', 'CF', 'CG', 'CH', 'CI',
970
'CK', 'CL', 'CM', 'CN', 'CO', 'CR', 'CU', 'CV', 'CX', 'CY', 'CZ',
971
'DE', 'DJ', 'DK', 'DM', 'DO', 'DZ', 'EC', 'EE', 'EG', 'EH', 'ER',
972
'ES', 'ET', 'FI', 'FJ', 'FK', 'FM', 'FO', 'FR', 'GA', 'GB', 'GD',
973
'GE', 'GF', 'GG', 'GH', 'GI', 'GL', 'GM', 'GN', 'GP', 'GQ', 'GR',
974
'GS', 'GT', 'GU', 'GW', 'GY', 'HK', 'HM', 'HN', 'HR', 'HT', 'HU',
975
'ID', 'IE', 'IL', 'IM', 'IN', 'IO', 'IQ', 'IR', 'IS', 'IT', 'JE',
976
'JM', 'JO', 'JP', 'KE', 'KG', 'KH', 'KI', 'KM', 'KN', 'KP', 'KR',
977
'KW', 'KY', 'KZ', 'LA', 'LB', 'LC', 'LI', 'LK', 'LR', 'LS', 'LT',
978
'LU', 'LV', 'LY', 'MA', 'MC', 'MD', 'ME', 'MF', 'MG', 'MH', 'MK',
979
'ML', 'MM', 'MN', 'MO', 'MP', 'MQ', 'MR', 'MS', 'MT', 'MU', 'MV',
980
'MW', 'MX', 'MY', 'MZ', 'NA', 'NC', 'NE', 'NF', 'NG', 'NI', 'NL',
981
'NO', 'NP', 'NR', 'NU', 'NZ', 'OM', 'PA', 'PE', 'PF', 'PG', 'PH',
982
'PK', 'PL', 'PM', 'PN', 'PR', 'PS', 'PT', 'PW', 'PY', 'QA', 'RE',
983
'RO', 'RS', 'RU', 'RW', 'SA', 'SB', 'SC', 'SD', 'SE', 'SG', 'SH',
984
'SI', 'SJ', 'SK', 'SL', 'SM', 'SN', 'SO', 'SR', 'ST', 'SV', 'SY',
985
'SZ', 'TC', 'TD', 'TF', 'TG', 'TH', 'TJ', 'TK', 'TL', 'TM', 'TN',
986
'TO', 'TR', 'TT', 'TV', 'TW', 'TZ', 'UA', 'UG', 'UM', 'US', 'UY',
987
'UZ', 'VA', 'VC', 'VE', 'VG', 'VI', 'VN', 'VU', 'WF', 'WS', 'YE',
988
'YT', 'ZA', 'ZM', 'ZW')
990
def type_to_url(self):
993
def set_codes(self, codes):
994
'''Set the country code map for the data.
995
Codes given in a list.
1006
if cc in self.__ccodes:
1009
raise UnknownCountryCodeException(cc)
1011
self.codes = codemap
1013
def set_geo_area(self, area):
1014
'''Sets the geo area for the map.
1025
if area in self.__areas:
1026
self.geo_area = area
1028
raise UnknownChartType('Unknown chart type for maps: %s' %area)
1030
def get_url_bits(self, data_class=None):
1031
url_bits = Chart.get_url_bits(self, data_class=data_class)
1032
url_bits.append('chtm=%s' % self.geo_area)
1034
url_bits.append('chld=%s' % ''.join(self.codes))
1037
def add_data_dict(self, datadict):
1038
'''Sets the data and country codes via a dictionary.
1040
i.e. {'DE': 50, 'GB': 30, 'AT': 70}
1043
self.set_codes(datadict.keys())
1044
self.add_data(datadict.values())
1047
class GoogleOMeterChart(PieChart):
1048
"""Inheriting from PieChart because of similar labeling"""
1050
def __init__(self, *args, **kwargs):
1051
PieChart.__init__(self, *args, **kwargs)
1052
if self.auto_scale and not self.x_range:
1053
warnings.warn('Please specify an x_range with GoogleOMeterChart, '
1054
'otherwise one arrow will always be at the max.')
1056
def type_to_url(self):
1060
class QRChart(Chart):
1062
def __init__(self, *args, **kwargs):
1063
Chart.__init__(self, *args, **kwargs)
1064
self.encoding = None
1065
self.ec_level = None
1068
def type_to_url(self):
1071
def data_to_url(self, data_class=None):
1073
raise NoDataGivenException()
1074
return 'chl=%s' % urllib.quote(self.data[0])
1076
def get_url_bits(self, data_class=None):
1077
url_bits = Chart.get_url_bits(self, data_class=data_class)
1079
url_bits.append('choe=%s' % self.encoding)
1081
url_bits.append('chld=%s%%7c%s' % (self.ec_level, self.margin))
1084
def set_encoding(self, encoding):
1085
self.encoding = encoding
1087
def set_ec(self, level, margin):
1088
self.ec_level = level
1089
self.margin = margin
1092
class ChartGrammar(object):
1098
def parse(self, grammar):
1099
self.grammar = grammar
1100
self.chart = self.create_chart_instance()
1102
for attr in self.grammar:
1103
if attr in ('w', 'h', 'type', 'auto_scale', 'x_range', 'y_range'):
1104
continue # These are already parsed in create_chart_instance
1105
attr_func = 'parse_' + attr
1106
if not hasattr(self, attr_func):
1107
warnings.warn('No parser for grammar attribute "%s"' % (attr))
1109
getattr(self, attr_func)(grammar[attr])
1113
def parse_data(self, data):
1114
self.chart.data = data
1117
def get_possible_chart_types():
1118
possible_charts = []
1119
for cls_name in globals().keys():
1120
if not cls_name.endswith('Chart'):
1122
cls = globals()[cls_name]
1123
# Check if it is an abstract class
1125
a = cls(1, 1, auto_scale=False)
1127
except AbstractClassException:
1130
possible_charts.append(cls_name[:-5])
1131
return possible_charts
1133
def create_chart_instance(self, grammar=None):
1135
grammar = self.grammar
1136
assert(isinstance(grammar, dict)) # grammar must be a dict
1137
assert('w' in grammar) # width is required
1138
assert('h' in grammar) # height is required
1139
assert('type' in grammar) # type is required
1140
chart_type = grammar['type']
1143
auto_scale = grammar.get('auto_scale', None)
1144
x_range = grammar.get('x_range', None)
1145
y_range = grammar.get('y_range', None)
1146
types = ChartGrammar.get_possible_chart_types()
1147
if chart_type not in types:
1148
raise UnknownChartType('%s is an unknown chart type. Possible '
1149
'chart types are %s' % (chart_type, ','.join(types)))
1150
return globals()[chart_type + 'Chart'](w, h, auto_scale=auto_scale,
1151
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/>.
30
# Helper variables and functions
31
# -----------------------------------------------------------------------------
33
reo_colour = re.compile('^([A-Fa-f0-9]{2,2}){3,4}$')
36
def _check_colour(colour):
37
if not reo_colour.match(colour):
38
raise InvalidParametersException('Colours need to be in ' \
39
'RRGGBB or RRGGBBAA format. One of your colours has %s' % \
43
# -----------------------------------------------------------------------------
46
class PyGoogleChartException(Exception):
50
class DataOutOfRangeException(PyGoogleChartException):
54
class UnknownDataTypeException(PyGoogleChartException):
58
class NoDataGivenException(PyGoogleChartException):
62
class InvalidParametersException(PyGoogleChartException):
66
class BadContentTypeException(PyGoogleChartException):
71
# -----------------------------------------------------------------------------
76
def __init__(self, data):
77
assert(type(self) != Data) # This is an abstract class
81
class SimpleData(Data):
82
enc_map = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
86
for data in self.data:
91
elif value >= 0 and value <= SimpleData.max_value:
92
sub_data.append(SimpleData.enc_map[value])
94
raise DataOutOfRangeException()
95
encoded_data.append(''.join(sub_data))
96
return 'chd=s:' + ','.join(encoded_data)
103
class TextData(Data):
107
for data in self.data:
112
elif value >= 0 and value <= TextData.max_value:
113
sub_data.append(str(float(value)))
115
raise DataOutOfRangeException()
116
encoded_data.append(','.join(sub_data))
117
return 'chd=t:' + '|'.join(encoded_data)
124
class ExtendedData(Data):
126
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-.'
130
enc_size = len(ExtendedData.enc_map)
131
for data in self.data:
135
sub_data.append('__')
136
elif value >= 0 and value <= ExtendedData.max_value:
137
first, second = divmod(int(value), enc_size)
138
sub_data.append('%s%s' % (
139
ExtendedData.enc_map[first],
140
ExtendedData.enc_map[second]))
142
raise DataOutOfRangeException( \
143
'Item #%i "%s" is out of range' % (data.index(value), \
145
encoded_data.append(''.join(sub_data))
146
return 'chd=e:' + ','.join(encoded_data)
153
# -----------------------------------------------------------------------------
161
TYPES = (BOTTOM, TOP, LEFT, RIGHT)
163
def __init__(self, axis_index, axis_type, **kw):
164
assert(axis_type in Axis.TYPES)
165
self.has_style = False
166
self.axis_index = axis_index
167
self.axis_type = axis_type
168
self.positions = None
170
def set_index(self, axis_index):
171
self.axis_index = axis_index
173
def set_positions(self, positions):
174
self.positions = positions
176
def set_style(self, colour, font_size=None, alignment=None):
177
_check_colour(colour)
179
self.font_size = font_size
180
self.alignment = alignment
181
self.has_style = True
183
def style_to_url(self):
185
bits.append(str(self.axis_index))
186
bits.append(self.colour)
187
if self.font_size is not None:
188
bits.append(str(self.font_size))
189
if self.alignment is not None:
190
bits.append(str(self.alignment))
191
return ','.join(bits)
193
def positions_to_url(self):
195
bits.append(str(self.axis_index))
196
bits += [str(a) for a in self.positions]
197
return ','.join(bits)
200
class LabelAxis(Axis):
202
def __init__(self, axis_index, axis_type, values, **kwargs):
203
Axis.__init__(self, axis_index, axis_type, **kwargs)
204
self.values = [str(a) for a in values]
207
return '%i:|%s' % (self.axis_index, '|'.join(self.values))
210
class RangeAxis(Axis):
212
def __init__(self, axis_index, axis_type, low, high, **kwargs):
213
Axis.__init__(self, axis_index, axis_type, **kwargs)
218
return '%i,%s,%s' % (self.axis_index, self.low, self.high)
221
# -----------------------------------------------------------------------------
225
"""Abstract class for all chart types.
227
width are height specify the dimensions of the image. title sets the title
228
of the chart. legend requires a list that corresponds to datasets.
231
BASE_URL = 'http://chart.apis.google.com/chart?'
235
LINEAR_GRADIENT = 'lg'
236
LINEAR_STRIPES = 'ls'
238
def __init__(self, width, height, title=None, legend=None, colours=None):
239
assert(type(self) != Chart) # This is an abstract class
240
assert(isinstance(width, int))
241
assert(isinstance(height, int))
245
self.set_title(title)
246
self.set_legend(legend)
247
self.set_colours(colours)
249
Chart.BACKGROUND: None,
253
Chart.BACKGROUND: None,
266
# -------------------------------------------------------------------------
269
url_bits = self.get_url_bits()
270
return self.BASE_URL + '&'.join(url_bits)
272
def get_url_bits(self):
275
url_bits.append(self.type_to_url())
276
url_bits.append('chs=%ix%i' % (self.width, self.height))
277
url_bits.append(self.data_to_url())
280
url_bits.append('chtt=%s' % self.title)
282
url_bits.append('chdl=%s' % '|'.join(self.legend))
284
url_bits.append('chco=%s' % ','.join(self.colours))
285
ret = self.fill_to_url()
288
ret = self.axis_to_url()
292
url_bits.append(self.markers_to_url())
296
# -------------------------------------------------------------------------
298
def download(self, file_name):
299
opener = urllib2.urlopen(self.get_url())
301
if opener.headers['content-type'] != 'image/png':
302
raise BadContentTypeException('Server responded with a ' \
303
'content-type of %s' % opener.headers['content-type'])
305
open(file_name, 'wb').write(urllib.urlopen(self.get_url()).read())
308
# -------------------------------------------------------------------------
310
def set_title(self, title):
312
self.title = urllib.quote(title)
316
def set_legend(self, legend):
317
# legend needs to be a list, tuple or None
318
assert(isinstance(legend, list) or isinstance(legend, tuple) or
321
self.legend = [urllib.quote(a) for a in legend]
326
# -------------------------------------------------------------------------
328
def set_colours(self, colours):
329
# colours needs to be a list, tuple or None
330
assert(isinstance(colours, list) or isinstance(colours, tuple) or
332
# make sure the colours are in the right format
336
self.colours = colours
338
# Background/Chart colours
339
# -------------------------------------------------------------------------
341
def fill_solid(self, area, colour):
342
assert(area in (Chart.BACKGROUND, Chart.CHART))
343
_check_colour(colour)
344
self.fill_area[area] = colour
345
self.fill_types[area] = Chart.SOLID
347
def _check_fill_linear(self, angle, *args):
348
assert(isinstance(args, list) or isinstance(args, tuple))
349
assert(angle >= 0 and angle <= 90)
350
assert(len(args) % 2 == 0)
351
args = list(args) # args is probably a tuple and we need to mutate
352
for a in xrange(len(args) / 2):
354
offset = args[a * 2 + 1]
356
assert(offset >= 0 and offset <= 1)
357
args[a * 2 + 1] = str(args[a * 2 + 1])
360
def fill_linear_gradient(self, area, angle, *args):
361
assert(area in (Chart.BACKGROUND, Chart.CHART))
362
args = self._check_fill_linear(angle, *args)
363
self.fill_types[area] = Chart.LINEAR_GRADIENT
364
self.fill_area[area] = ','.join([str(angle)] + args)
366
def fill_linear_stripes(self, area, angle, *args):
367
assert(area in (Chart.BACKGROUND, Chart.CHART))
368
args = self._check_fill_linear(angle, *args)
369
self.fill_types[area] = Chart.LINEAR_STRIPES
370
self.fill_area[area] = ','.join([str(angle)] + args)
372
def fill_to_url(self):
374
for area in (Chart.BACKGROUND, Chart.CHART):
375
if self.fill_types[area]:
376
areas.append('%s,%s,%s' % (area, self.fill_types[area], \
377
self.fill_area[area]))
379
return 'chf=' + '|'.join(areas)
382
# -------------------------------------------------------------------------
384
def data_class_detection(self, data):
386
Detects and returns the data type required based on the range of the
387
data given. The data given must be lists of numbers within a list.
389
assert(isinstance(data, list) or isinstance(data, tuple))
392
assert(isinstance(a, list) or isinstance(a, tuple))
393
if max_value is None or max(a) > max_value:
395
for data_class in (SimpleData, TextData, ExtendedData):
396
if max_value <= data_class.max_value():
398
raise DataOutOfRangeException()
400
def add_data(self, data):
401
self.data.append(data)
402
return len(self.data) - 1 # return the "index" of the data set
404
def data_to_url(self, data_class=None):
406
data_class = self.data_class_detection(self.data)
407
if not issubclass(data_class, Data):
408
raise UnknownDataTypeException()
409
return repr(data_class(self.data))
412
# -------------------------------------------------------------------------
414
def set_axis_labels(self, axis_type, values):
415
assert(axis_type in Axis.TYPES)
416
values = [ urllib.quote(a) for a in values ]
417
axis_index = len(self.axis)
418
axis = LabelAxis(axis_index, axis_type, values)
419
self.axis.append(axis)
422
def set_axis_range(self, axis_type, low, high):
423
assert(axis_type in Axis.TYPES)
424
axis_index = len(self.axis)
425
axis = RangeAxis(axis_index, axis_type, low, high)
426
self.axis.append(axis)
429
def set_axis_positions(self, axis_index, positions):
431
self.axis[axis_index].set_positions(positions)
433
raise InvalidParametersException('Axis index %i has not been ' \
436
def set_axis_style(self, axis_index, colour, font_size=None, \
439
self.axis[axis_index].set_style(colour, font_size, alignment)
441
raise InvalidParametersException('Axis index %i has not been ' \
444
def axis_to_url(self):
451
for axis in self.axis:
452
available_axis.append(axis.axis_type)
453
if isinstance(axis, RangeAxis):
454
range_axis.append(repr(axis))
455
if isinstance(axis, LabelAxis):
456
label_axis.append(repr(axis))
458
positions.append(axis.positions_to_url())
460
styles.append(axis.style_to_url())
461
if not available_axis:
464
url_bits.append('chxt=%s' % ','.join(available_axis))
466
url_bits.append('chxl=%s' % '|'.join(label_axis))
468
url_bits.append('chxr=%s' % '|'.join(range_axis))
470
url_bits.append('chxp=%s' % '|'.join(positions))
472
url_bits.append('chxs=%s' % '|'.join(styles))
473
return '&'.join(url_bits)
475
# Markers, Ranges and Fill area (chm)
476
# -------------------------------------------------------------------------
478
def markers_to_url(self):
479
return 'chm=%s' % '|'.join([','.join(a) for a in self.markers])
481
def add_marker(self, index, point, marker_type, colour, size):
482
self.markers.append((marker_type, colour, str(index), str(point), \
485
def add_horizontal_range(self, colour, start, stop):
486
self.markers.append(('r', colour, '1', str(start), str(stop)))
488
def add_vertical_range(self, colour, start, stop):
489
self.markers.append(('R', colour, '1', str(start), str(stop)))
491
def add_fill_range(self, colour, index_start, index_end):
492
self.markers.append(('b', colour, str(index_start), str(index_end), \
495
def add_fill_simple(self, colour):
496
self.markers.append(('B', colour, '1', '1', '1'))
499
class ScatterChart(Chart):
501
def __init__(self, *args, **kwargs):
502
Chart.__init__(self, *args, **kwargs)
504
def type_to_url(self):
508
class LineChart(Chart):
510
def __init__(self, *args, **kwargs):
511
assert(type(self) != LineChart) # This is an abstract class
512
Chart.__init__(self, *args, **kwargs)
513
self.line_styles = {}
516
def set_line_style(self, index, thickness=1, line_segment=None, \
519
value.append(str(thickness))
521
value.append(str(line_segment))
522
value.append(str(blank_segment))
523
self.line_styles[index] = value
525
def set_grid(self, x_step, y_step, line_segment=1, \
527
self.grid = '%s,%s,%s,%s' % (x_step, y_step, line_segment, \
530
def get_url_bits(self):
531
url_bits = Chart.get_url_bits(self)
534
# for index, values in self.line_style.items():
535
for index in xrange(max(self.line_styles) + 1):
536
if index in self.line_styles:
537
values = self.line_styles[index]
540
style.append(','.join(values))
541
url_bits.append('chls=%s' % '|'.join(style))
543
url_bits.append('chg=%s' % self.grid)
547
class SimpleLineChart(LineChart):
549
def type_to_url(self):
553
class XYLineChart(LineChart):
555
def type_to_url(self):
559
class BarChart(Chart):
561
def __init__(self, *args, **kwargs):
562
assert(type(self) != BarChart) # This is an abstract class
563
Chart.__init__(self, *args, **kwargs)
564
self.bar_width = None
566
def set_bar_width(self, bar_width):
567
self.bar_width = bar_width
569
def get_url_bits(self):
570
url_bits = Chart.get_url_bits(self)
571
url_bits.append('chbh=%i' % self.bar_width)
575
class StackedHorizontalBarChart(BarChart):
577
def type_to_url(self):
581
class StackedVerticalBarChart(BarChart):
583
def type_to_url(self):
587
class GroupedBarChart(BarChart):
589
def __init__(self, *args, **kwargs):
590
assert(type(self) != GroupedBarChart) # This is an abstract class
591
BarChart.__init__(self, *args, **kwargs)
592
self.bar_spacing = None
594
def set_bar_spacing(self, spacing):
595
self.bar_spacing = spacing
597
def get_url_bits(self):
598
# Skip 'BarChart.get_url_bits' and call Chart directly so the parent
599
# doesn't add "chbh" before we do.
600
url_bits = Chart.get_url_bits(self)
601
if self.bar_spacing is not None:
602
if self.bar_width is None:
603
raise InvalidParametersException('Bar width is required to ' \
604
'be set when setting spacing')
605
url_bits.append('chbh=%i,%i' % (self.bar_width, self.bar_spacing))
607
url_bits.append('chbh=%i' % self.bar_width)
611
class GroupedHorizontalBarChart(GroupedBarChart):
613
def type_to_url(self):
617
class GroupedVerticalBarChart(GroupedBarChart):
619
def type_to_url(self):
623
class PieChart(Chart):
625
def __init__(self, *args, **kwargs):
626
assert(type(self) != PieChart) # This is an abstract class
627
Chart.__init__(self, *args, **kwargs)
630
def set_pie_labels(self, labels):
631
self.pie_labels = labels
633
def get_url_bits(self):
634
url_bits = Chart.get_url_bits(self)
636
url_bits.append('chl=%s' % '|'.join(self.pie_labels))
640
class PieChart2D(PieChart):
642
def type_to_url(self):
646
class PieChart3D(PieChart):
648
def type_to_url(self):
652
class VennChart(Chart):
654
def type_to_url(self):
659
chart = GroupedVerticalBarChart(320, 200)
660
chart = PieChart2D(320, 200)
661
chart = ScatterChart(320, 200)
662
chart = SimpleLineChart(320, 200)
663
sine_data = [math.sin(float(a) / 10) * 2000 + 2000 for a in xrange(100)]
664
random_data = [a * random.random() * 30 for a in xrange(40)]
665
random_data2 = [random.random() * 4000 for a in xrange(10)]
666
# chart.set_bar_width(50)
667
# chart.set_bar_spacing(0)
668
chart.add_data(sine_data)
669
chart.add_data(random_data)
670
chart.add_data(random_data2)
671
# chart.set_line_style(1, thickness=2)
672
# chart.set_line_style(2, line_segment=10, blank_segment=5)
673
# chart.set_title('heloooo')
674
# chart.set_legend(('sine wave', 'random * x'))
675
# chart.set_colours(('ee2000', 'DDDDAA', 'fF03f2'))
676
# chart.fill_solid(Chart.BACKGROUND, '123456')
677
# chart.fill_linear_gradient(Chart.CHART, 20, '004070', 1, '300040', 0,
679
# chart.fill_linear_stripes(Chart.CHART, 20, '204070', .2, '300040', .2,
681
axis_left_index = chart.set_axis_range(Axis.LEFT, 0, 10)
682
axis_left_index = chart.set_axis_range(Axis.LEFT, 0, 10)
683
axis_left_index = chart.set_axis_range(Axis.LEFT, 0, 10)
684
axis_right_index = chart.set_axis_range(Axis.RIGHT, 5, 30)
685
axis_bottom_index = chart.set_axis_labels(Axis.BOTTOM, [1, 25, 95])
686
chart.set_axis_positions(axis_bottom_index, [1, 25, 95])
687
chart.set_axis_style(axis_bottom_index, '003050', 15)
689
# chart.set_pie_labels(('apples', 'oranges', 'bananas'))
691
# chart.set_grid(10, 10)
693
# for a in xrange(0, 100, 10):
694
# chart.add_marker(1, a, 'a', 'AACA20', 10)
696
chart.add_horizontal_range('00A020', .2, .5)
697
chart.add_vertical_range('00c030', .2, .4)
699
chart.add_fill_simple('303030A0')
701
chart.download('test.png')
703
url = chart.get_url()
706
data = urllib.urlopen(chart.get_url()).read()
707
open('meh.png', 'wb').write(data)
708
os.system('start meh.png')
711
if __name__ == '__main__':