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/>.
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 = {}
337
# -------------------------------------------------------------------------
339
def get_url(self, data_class=None):
340
url_bits = self.get_url_bits(data_class=data_class)
341
return self.BASE_URL + '&'.join(url_bits)
343
def get_url_bits(self, data_class=None):
346
url_bits.append(self.type_to_url())
347
url_bits.append('chs=%ix%i' % (self.width, self.height))
348
url_bits.append(self.data_to_url(data_class=data_class))
351
url_bits.append('chtt=%s' % self.title)
352
if self.title_colour and self.title_font_size:
353
url_bits.append('chts=%s,%s' % (self.title_colour, \
354
self.title_font_size))
356
url_bits.append('chdl=%s' % '%7c'.join(self.legend))
357
if self.legend_position:
358
url_bits.append('chdlp=%s' % (self.legend_position))
360
url_bits.append('chco=%s' % ','.join(self.colours))
361
if self.colours_within_series:
362
url_bits.append('chco=%s' % '%7c'.join(self.colours_within_series))
363
ret = self.fill_to_url()
366
ret = self.axis_to_url()
370
url_bits.append(self.markers_to_url())
373
for index in xrange(max(self.line_styles) + 1):
374
if index in self.line_styles:
375
values = self.line_styles[index]
378
style.append(','.join(values))
379
url_bits.append('chls=%s' % '%7c'.join(style))
381
url_bits.append('chg=%s' % self.grid)
385
# -------------------------------------------------------------------------
387
def download(self, file_name):
388
opener = urllib2.urlopen(self.get_url())
390
if opener.headers['content-type'] != 'image/png':
391
raise BadContentTypeException('Server responded with a ' \
392
'content-type of %s' % opener.headers['content-type'])
394
open(file_name, 'wb').write(opener.read())
397
# -------------------------------------------------------------------------
399
def set_title(self, title):
401
self.title = urllib.quote(title)
405
def set_title_style(self, colour, font_size):
406
if not colour is None:
407
_check_colour(colour)
408
self.title_colour = colour
409
self.title_font_size = font_size
411
def set_legend(self, legend):
412
"""legend needs to be a list, tuple or None"""
413
assert(isinstance(legend, list) or isinstance(legend, tuple) or
416
self.legend = [urllib.quote(a) for a in legend]
420
def set_legend_position(self, legend_position):
422
self.legend_position = urllib.quote(legend_position)
424
self.legend_position = None
427
# -------------------------------------------------------------------------
429
def set_colours(self, colours):
430
# colours needs to be a list, tuple or None
431
assert(isinstance(colours, list) or isinstance(colours, tuple) or
433
# make sure the colours are in the right format
437
self.colours = colours
439
def set_colours_within_series(self, colours):
440
# colours needs to be a list, tuple or None
441
assert(isinstance(colours, list) or isinstance(colours, tuple) or
443
# make sure the colours are in the right format
447
self.colours_within_series = colours
449
# Background/Chart colours
450
# -------------------------------------------------------------------------
452
def fill_solid(self, area, colour):
453
assert(area in Chart.VALID_SOLID_FILL_TYPES)
454
_check_colour(colour)
455
self.fill_area[area] = colour
456
self.fill_types[area] = Chart.SOLID
458
def _check_fill_linear(self, angle, *args):
459
assert(isinstance(args, list) or isinstance(args, tuple))
460
assert(angle >= 0 and angle <= 90)
461
assert(len(args) % 2 == 0)
462
args = list(args) # args is probably a tuple and we need to mutate
463
for a in xrange(int(len(args) / 2)):
465
offset = args[a * 2 + 1]
467
assert(offset >= 0 and offset <= 1)
468
args[a * 2 + 1] = str(args[a * 2 + 1])
471
def fill_linear_gradient(self, area, angle, *args):
472
assert(area in Chart.VALID_SOLID_FILL_TYPES)
473
args = self._check_fill_linear(angle, *args)
474
self.fill_types[area] = Chart.LINEAR_GRADIENT
475
self.fill_area[area] = ','.join([str(angle)] + args)
477
def fill_linear_stripes(self, area, angle, *args):
478
assert(area in Chart.VALID_SOLID_FILL_TYPES)
479
args = self._check_fill_linear(angle, *args)
480
self.fill_types[area] = Chart.LINEAR_STRIPES
481
self.fill_area[area] = ','.join([str(angle)] + args)
483
def fill_to_url(self):
485
for area in (Chart.BACKGROUND, Chart.CHART, Chart.ALPHA):
486
if self.fill_types[area]:
487
areas.append('%s,%s,%s' % (area, self.fill_types[area], \
488
self.fill_area[area]))
490
return 'chf=' + '%7c'.join(areas)
493
# -------------------------------------------------------------------------
495
def data_class_detection(self, data):
496
"""Determines the appropriate data encoding type to give satisfactory
497
resolution (http://code.google.com/apis/chart/#chart_data).
499
assert(isinstance(data, list) or isinstance(data, tuple))
500
if not isinstance(self, (LineChart, BarChart, ScatterChart)):
501
# From the link above:
502
# Simple encoding is suitable for all other types of chart
503
# regardless of size.
505
elif self.height < 100:
506
# The link above indicates that line and bar charts less
507
# than 300px in size can be suitably represented with the
508
# simple encoding. I've found that this isn't sufficient,
509
# e.g. examples/line-xy-circle.png. Let's try 100px.
514
def _filter_none(self, data):
515
return [r for r in data if r is not None]
517
def data_x_range(self):
518
"""Return a 2-tuple giving the minimum and maximum x-axis
522
lower = min([min(self._filter_none(s))
523
for type, s in self.annotated_data()
525
upper = max([max(self._filter_none(s))
526
for type, s in self.annotated_data()
528
return (lower, upper)
530
return None # no x-axis datasets
532
def data_y_range(self):
533
"""Return a 2-tuple giving the minimum and maximum y-axis
537
lower = min([min(self._filter_none(s))
538
for type, s in self.annotated_data()
540
upper = max([max(self._filter_none(s)) + 1
541
for type, s in self.annotated_data()
543
return (lower, upper)
545
return None # no y-axis datasets
547
def scaled_data(self, data_class, x_range=None, y_range=None):
548
"""Scale `self.data` as appropriate for the given data encoding
549
(data_class) and return it.
551
An optional `y_range` -- a 2-tuple (lower, upper) -- can be
552
given to specify the y-axis bounds. If not given, the range is
553
inferred from the data: (0, <max-value>) presuming no negative
554
values, or (<min-value>, <max-value>) if there are negative
555
values. `self.scaled_y_range` is set to the actual lower and
558
Ditto for `x_range`. Note that some chart types don't have x-axis
561
self.scaled_data_class = data_class
563
# Determine the x-axis range for scaling.
565
x_range = self.data_x_range()
566
if x_range and x_range[0] > 0:
567
x_range = (x_range[0], x_range[1])
568
self.scaled_x_range = x_range
570
# Determine the y-axis range for scaling.
572
y_range = self.data_y_range()
573
if y_range and y_range[0] > 0:
574
y_range = (y_range[0], y_range[1])
575
self.scaled_y_range = y_range
578
for type, dataset in self.annotated_data():
580
scale_range = x_range
582
scale_range = y_range
583
elif type == 'marker-size':
584
scale_range = (0, max(dataset))
588
scaled_dataset.append(None)
590
scaled_dataset.append(
591
data_class.scale_value(v, scale_range))
592
scaled_data.append(scaled_dataset)
595
def add_data(self, data):
596
self.data.append(data)
597
return len(self.data) - 1 # return the "index" of the data set
599
def data_to_url(self, data_class=None):
601
data_class = self.data_class_detection(self.data)
602
if not issubclass(data_class, Data):
603
raise UnknownDataTypeException()
605
data = self.scaled_data(data_class, self.x_range, self.y_range)
608
return repr(data_class(data))
610
def annotated_data(self):
611
for dataset in self.data:
615
# -------------------------------------------------------------------------
617
def set_axis_labels(self, axis_type, values):
618
assert(axis_type in Axis.TYPES)
619
values = [urllib.quote(str(a)) for a in values]
620
axis_index = len(self.axis)
621
axis = LabelAxis(axis_index, axis_type, values)
622
self.axis.append(axis)
625
def set_axis_range(self, axis_type, low, high):
626
assert(axis_type in Axis.TYPES)
627
axis_index = len(self.axis)
628
axis = RangeAxis(axis_index, axis_type, low, high)
629
self.axis.append(axis)
632
def set_axis_positions(self, axis_index, positions):
634
self.axis[axis_index].set_positions(positions)
636
raise InvalidParametersException('Axis index %i has not been ' \
639
def set_axis_style(self, axis_index, colour, font_size=None, \
642
self.axis[axis_index].set_style(colour, font_size, alignment)
644
raise InvalidParametersException('Axis index %i has not been ' \
647
def axis_to_url(self):
654
for axis in self.axis:
655
available_axis.append(axis.axis_type)
656
if isinstance(axis, RangeAxis):
657
range_axis.append(repr(axis))
658
if isinstance(axis, LabelAxis):
659
label_axis.append(repr(axis))
661
positions.append(axis.positions_to_url())
663
styles.append(axis.style_to_url())
664
if not available_axis:
667
url_bits.append('chxt=%s' % ','.join(available_axis))
669
url_bits.append('chxl=%s' % '%7c'.join(label_axis))
671
url_bits.append('chxr=%s' % '%7c'.join(range_axis))
673
url_bits.append('chxp=%s' % '%7c'.join(positions))
675
url_bits.append('chxs=%s' % '%7c'.join(styles))
676
return '&'.join(url_bits)
678
# Markers, Ranges and Fill area (chm)
679
# -------------------------------------------------------------------------
681
def markers_to_url(self):
682
return 'chm=%s' % '%7c'.join([','.join(a) for a in self.markers])
684
def add_marker(self, index, point, marker_type, colour, size, priority=0):
685
self.markers.append((marker_type, colour, str(index), str(point), \
686
str(size), str(priority)))
688
def add_horizontal_range(self, colour, start, stop):
689
self.markers.append(('r', colour, '0', str(start), str(stop)))
691
def add_data_line(self, colour, data_set, size, priority=0):
692
self.markers.append(('D', colour, str(data_set), '0', str(size), \
695
def add_marker_text(self, string, colour, data_set, data_point, size, \
697
self.markers.append((str(string), colour, str(data_set), \
698
str(data_point), str(size), str(priority)))
700
def add_vertical_range(self, colour, start, stop):
701
self.markers.append(('R', colour, '0', str(start), str(stop)))
703
def add_fill_range(self, colour, index_start, index_end):
704
self.markers.append(('b', colour, str(index_start), str(index_end), \
707
def add_fill_simple(self, colour):
708
self.markers.append(('B', colour, '1', '1', '1'))
711
# -------------------------------------------------------------------------
713
def set_line_style(self, index, thickness=1, line_segment=None, \
716
value.append(str(thickness))
718
value.append(str(line_segment))
719
value.append(str(blank_segment))
720
self.line_styles[index] = value
723
# -------------------------------------------------------------------------
725
def set_grid(self, x_step, y_step, line_segment=1, \
727
self.grid = '%s,%s,%s,%s' % (x_step, y_step, line_segment, \
731
class ScatterChart(Chart):
733
def type_to_url(self):
736
def annotated_data(self):
737
yield ('x', self.data[0])
738
yield ('y', self.data[1])
739
if len(self.data) > 2:
740
# The optional third dataset is relative sizing for point
742
yield ('marker-size', self.data[2])
745
class LineChart(Chart):
747
def __init__(self, *args, **kwargs):
748
if type(self) == LineChart:
749
raise AbstractClassException('This is an abstract class')
750
Chart.__init__(self, *args, **kwargs)
753
class SimpleLineChart(LineChart):
755
def type_to_url(self):
758
def annotated_data(self):
759
# All datasets are y-axis data.
760
for dataset in self.data:
764
class SparkLineChart(SimpleLineChart):
766
def type_to_url(self):
770
class XYLineChart(LineChart):
772
def type_to_url(self):
775
def annotated_data(self):
776
# Datasets alternate between x-axis, y-axis.
777
for i, dataset in enumerate(self.data):
784
class BarChart(Chart):
786
def __init__(self, *args, **kwargs):
787
if type(self) == BarChart:
788
raise AbstractClassException('This is an abstract class')
789
Chart.__init__(self, *args, **kwargs)
790
self.bar_width = None
793
def set_bar_width(self, bar_width):
794
self.bar_width = bar_width
796
def set_zero_line(self, index, zero_line):
797
self.zero_lines[index] = zero_line
799
def get_url_bits(self, data_class=None, skip_chbh=False):
800
url_bits = Chart.get_url_bits(self, data_class=data_class)
801
if not skip_chbh and self.bar_width is not None:
802
url_bits.append('chbh=%i' % self.bar_width)
805
for index in xrange(max(self.zero_lines) + 1):
806
if index in self.zero_lines:
807
zero_line.append(str(self.zero_lines[index]))
809
zero_line.append('0')
810
url_bits.append('chp=%s' % ','.join(zero_line))
814
class StackedHorizontalBarChart(BarChart):
816
def type_to_url(self):
820
class StackedVerticalBarChart(BarChart):
822
def type_to_url(self):
825
def annotated_data(self):
826
for dataset in self.data:
830
class GroupedBarChart(BarChart):
832
def __init__(self, *args, **kwargs):
833
if type(self) == GroupedBarChart:
834
raise AbstractClassException('This is an abstract class')
835
BarChart.__init__(self, *args, **kwargs)
836
self.bar_spacing = None
837
self.group_spacing = None
839
def set_bar_spacing(self, spacing):
840
"""Set spacing between bars in a group."""
841
self.bar_spacing = spacing
843
def set_group_spacing(self, spacing):
844
"""Set spacing between groups of bars."""
845
self.group_spacing = spacing
847
def get_url_bits(self, data_class=None):
848
# Skip 'BarChart.get_url_bits' and call Chart directly so the parent
849
# doesn't add "chbh" before we do.
850
url_bits = BarChart.get_url_bits(self, data_class=data_class,
852
if self.group_spacing is not None:
853
if self.bar_spacing is None:
854
raise InvalidParametersException('Bar spacing is required ' \
855
'to be set when setting group spacing')
856
if self.bar_width is None:
857
raise InvalidParametersException('Bar width is required to ' \
858
'be set when setting bar spacing')
859
url_bits.append('chbh=%i,%i,%i'
860
% (self.bar_width, self.bar_spacing, self.group_spacing))
861
elif self.bar_spacing is not None:
862
if self.bar_width is None:
863
raise InvalidParametersException('Bar width is required to ' \
864
'be set when setting bar spacing')
865
url_bits.append('chbh=%i,%i' % (self.bar_width, self.bar_spacing))
867
url_bits.append('chbh=%i' % self.bar_width)
871
class GroupedHorizontalBarChart(GroupedBarChart):
873
def type_to_url(self):
877
class GroupedVerticalBarChart(GroupedBarChart):
879
def type_to_url(self):
882
def annotated_data(self):
883
for dataset in self.data:
887
class PieChart(Chart):
889
def __init__(self, *args, **kwargs):
890
if type(self) == PieChart:
891
raise AbstractClassException('This is an abstract class')
892
Chart.__init__(self, *args, **kwargs)
895
warnings.warn('y_range is not used with %s.' % \
896
(self.__class__.__name__))
898
def set_pie_labels(self, labels):
899
self.pie_labels = [urllib.quote(a) for a in labels]
901
def get_url_bits(self, data_class=None):
902
url_bits = Chart.get_url_bits(self, data_class=data_class)
904
url_bits.append('chl=%s' % '%7c'.join(self.pie_labels))
907
def annotated_data(self):
908
# Datasets are all y-axis data. However, there should only be
909
# one dataset for pie charts.
910
for dataset in self.data:
913
def scaled_data(self, data_class, x_range=None, y_range=None):
915
x_range = [0, sum(self.data[0])]
916
return Chart.scaled_data(self, data_class, x_range, self.y_range)
919
class PieChart2D(PieChart):
921
def type_to_url(self):
925
class PieChart3D(PieChart):
927
def type_to_url(self):
931
class VennChart(Chart):
933
def type_to_url(self):
936
def annotated_data(self):
937
for dataset in self.data:
941
class RadarChart(Chart):
943
def type_to_url(self):
947
class SplineRadarChart(RadarChart):
949
def type_to_url(self):
953
class MapChart(Chart):
955
def __init__(self, *args, **kwargs):
956
Chart.__init__(self, *args, **kwargs)
957
self.geo_area = 'world'
959
self.__areas = ('africa', 'asia', 'europe', 'middle_east', 'south_america', 'usa', 'world')
960
self.__ccodes = ('AD', 'AE', 'AF', 'AG', 'AI', 'AL', 'AM', 'AN', 'AO', 'AQ', 'AR',
961
'AS', 'AT', 'AU', 'AW', 'AX', 'AZ', 'BA', 'BB', 'BD', 'BE', 'BF',
962
'BG', 'BH', 'BI', 'BJ', 'BL', 'BM', 'BN', 'BO', 'BR', 'BS', 'BT',
963
'BV', 'BW', 'BY', 'BZ', 'CA', 'CC', 'CD', 'CF', 'CG', 'CH', 'CI',
964
'CK', 'CL', 'CM', 'CN', 'CO', 'CR', 'CU', 'CV', 'CX', 'CY', 'CZ',
965
'DE', 'DJ', 'DK', 'DM', 'DO', 'DZ', 'EC', 'EE', 'EG', 'EH', 'ER',
966
'ES', 'ET', 'FI', 'FJ', 'FK', 'FM', 'FO', 'FR', 'GA', 'GB', 'GD',
967
'GE', 'GF', 'GG', 'GH', 'GI', 'GL', 'GM', 'GN', 'GP', 'GQ', 'GR',
968
'GS', 'GT', 'GU', 'GW', 'GY', 'HK', 'HM', 'HN', 'HR', 'HT', 'HU',
969
'ID', 'IE', 'IL', 'IM', 'IN', 'IO', 'IQ', 'IR', 'IS', 'IT', 'JE',
970
'JM', 'JO', 'JP', 'KE', 'KG', 'KH', 'KI', 'KM', 'KN', 'KP', 'KR',
971
'KW', 'KY', 'KZ', 'LA', 'LB', 'LC', 'LI', 'LK', 'LR', 'LS', 'LT',
972
'LU', 'LV', 'LY', 'MA', 'MC', 'MD', 'ME', 'MF', 'MG', 'MH', 'MK',
973
'ML', 'MM', 'MN', 'MO', 'MP', 'MQ', 'MR', 'MS', 'MT', 'MU', 'MV',
974
'MW', 'MX', 'MY', 'MZ', 'NA', 'NC', 'NE', 'NF', 'NG', 'NI', 'NL',
975
'NO', 'NP', 'NR', 'NU', 'NZ', 'OM', 'PA', 'PE', 'PF', 'PG', 'PH',
976
'PK', 'PL', 'PM', 'PN', 'PR', 'PS', 'PT', 'PW', 'PY', 'QA', 'RE',
977
'RO', 'RS', 'RU', 'RW', 'SA', 'SB', 'SC', 'SD', 'SE', 'SG', 'SH',
978
'SI', 'SJ', 'SK', 'SL', 'SM', 'SN', 'SO', 'SR', 'ST', 'SV', 'SY',
979
'SZ', 'TC', 'TD', 'TF', 'TG', 'TH', 'TJ', 'TK', 'TL', 'TM', 'TN',
980
'TO', 'TR', 'TT', 'TV', 'TW', 'TZ', 'UA', 'UG', 'UM', 'US', 'UY',
981
'UZ', 'VA', 'VC', 'VE', 'VG', 'VI', 'VN', 'VU', 'WF', 'WS', 'YE',
982
'YT', 'ZA', 'ZM', 'ZW')
984
def type_to_url(self):
987
def set_codes(self, codes):
988
'''Set the country code map for the data.
989
Codes given in a list.
1000
if cc in self.__ccodes:
1003
raise UnknownCountryCodeException(cc)
1005
self.codes = codemap
1007
def set_geo_area(self, area):
1008
'''Sets the geo area for the map.
1019
if area in self.__areas:
1020
self.geo_area = area
1022
raise UnknownChartType('Unknown chart type for maps: %s' %area)
1024
def get_url_bits(self, data_class=None):
1025
url_bits = Chart.get_url_bits(self, data_class=data_class)
1026
url_bits.append('chtm=%s' % self.geo_area)
1028
url_bits.append('chld=%s' % ''.join(self.codes))
1031
def add_data_dict(self, datadict):
1032
'''Sets the data and country codes via a dictionary.
1034
i.e. {'DE': 50, 'GB': 30, 'AT': 70}
1037
self.set_codes(datadict.keys())
1038
self.add_data(datadict.values())
1041
class GoogleOMeterChart(PieChart):
1042
"""Inheriting from PieChart because of similar labeling"""
1044
def __init__(self, *args, **kwargs):
1045
PieChart.__init__(self, *args, **kwargs)
1046
if self.auto_scale and not self.x_range:
1047
warnings.warn('Please specify an x_range with GoogleOMeterChart, '
1048
'otherwise one arrow will always be at the max.')
1050
def type_to_url(self):
1054
class QRChart(Chart):
1056
def __init__(self, *args, **kwargs):
1057
Chart.__init__(self, *args, **kwargs)
1058
self.encoding = None
1059
self.ec_level = None
1062
def type_to_url(self):
1065
def data_to_url(self, data_class=None):
1067
raise NoDataGivenException()
1068
return 'chl=%s' % urllib.quote(self.data[0])
1070
def get_url_bits(self, data_class=None):
1071
url_bits = Chart.get_url_bits(self, data_class=data_class)
1073
url_bits.append('choe=%s' % self.encoding)
1075
url_bits.append('chld=%s%%7c%s' % (self.ec_level, self.margin))
1078
def set_encoding(self, encoding):
1079
self.encoding = encoding
1081
def set_ec(self, level, margin):
1082
self.ec_level = level
1083
self.margin = margin
1086
class ChartGrammar(object):
1092
def parse(self, grammar):
1093
self.grammar = grammar
1094
self.chart = self.create_chart_instance()
1096
for attr in self.grammar:
1097
if attr in ('w', 'h', 'type', 'auto_scale', 'x_range', 'y_range'):
1098
continue # These are already parsed in create_chart_instance
1099
attr_func = 'parse_' + attr
1100
if not hasattr(self, attr_func):
1101
warnings.warn('No parser for grammar attribute "%s"' % (attr))
1103
getattr(self, attr_func)(grammar[attr])
1107
def parse_data(self, data):
1108
self.chart.data = data
1111
def get_possible_chart_types():
1112
possible_charts = []
1113
for cls_name in globals().keys():
1114
if not cls_name.endswith('Chart'):
1116
cls = globals()[cls_name]
1117
# Check if it is an abstract class
1119
a = cls(1, 1, auto_scale=False)
1121
except AbstractClassException:
1124
possible_charts.append(cls_name[:-5])
1125
return possible_charts
1127
def create_chart_instance(self, grammar=None):
1129
grammar = self.grammar
1130
assert(isinstance(grammar, dict)) # grammar must be a dict
1131
assert('w' in grammar) # width is required
1132
assert('h' in grammar) # height is required
1133
assert('type' in grammar) # type is required
1134
chart_type = grammar['type']
1137
auto_scale = grammar.get('auto_scale', None)
1138
x_range = grammar.get('x_range', None)
1139
y_range = grammar.get('y_range', None)
1140
types = ChartGrammar.get_possible_chart_types()
1141
if chart_type not in types:
1142
raise UnknownChartType('%s is an unknown chart type. Possible '
1143
'chart types are %s' % (chart_type, ','.join(types)))
1144
return globals()[chart_type + 'Chart'](w, h, auto_scale=auto_scale,
1145
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, **kw):
164
assert(axis in Axis.TYPES)
165
self.has_style = False
167
self.positions = None
169
def set_index(self, index):
172
def set_positions(self, positions):
173
self.positions = positions
175
def set_style(self, colour, font_size=None, alignment=None):
176
_check_colour(colour)
178
self.font_size = font_size
179
self.alignment = alignment
180
self.has_style = True
182
def style_to_url(self):
184
bits.append(str(self.index))
185
bits.append(self.colour)
186
if self.font_size is not None:
187
bits.append(str(self.font_size))
188
if self.alignment is not None:
189
bits.append(str(self.alignment))
190
return ','.join(bits)
192
def positions_to_url(self):
194
bits.append(str(self.index))
195
bits += [str(a) for a in self.positions]
196
return ','.join(bits)
199
class LabelAxis(Axis):
201
def __init__(self, axis, values, **kwargs):
202
Axis.__init__(self, axis, **kwargs)
203
self.values = [str(a) for a in values]
206
return '%i:|%s' % (self.index, '|'.join(self.values))
209
class RangeAxis(Axis):
211
def __init__(self, axis, low, high, **kwargs):
212
Axis.__init__(self, axis, **kwargs)
217
return '%i,%s,%s' % (self.index, self.low, self.high)
220
# -----------------------------------------------------------------------------
224
"""Abstract class for all chart types.
226
width are height specify the dimensions of the image. title sets the title
227
of the chart. legend requires a list that corresponds to datasets.
230
BASE_URL = 'http://chart.apis.google.com/chart?'
234
LINEAR_GRADIENT = 'lg'
235
LINEAR_STRIPES = 'ls'
237
def __init__(self, width, height, title=None, legend=None, colours=None):
238
assert(type(self) != Chart) # This is an abstract class
239
assert(isinstance(width, int))
240
assert(isinstance(height, int))
244
self.set_title(title)
245
self.set_legend(legend)
246
self.set_colours(colours)
248
Chart.BACKGROUND: None,
252
Chart.BACKGROUND: None,
264
# -------------------------------------------------------------------------
267
url_bits = self.get_url_bits()
268
return self.BASE_URL + '&'.join(url_bits)
270
def get_url_bits(self):
273
url_bits.append(self.type_to_url())
274
url_bits.append('chs=%ix%i' % (self.width, self.height))
275
url_bits.append(self.data_to_url())
278
url_bits.append('chtt=%s' % self.title)
280
url_bits.append('chdl=%s' % '|'.join(self.legend))
282
url_bits.append('chco=%s' % ','.join(self.colours))
283
ret = self.fill_to_url()
286
ret = self.axis_to_url()
290
url_bits.append(self.markers_to_url())
294
# -------------------------------------------------------------------------
296
def download(self, file_name):
297
opener = urllib2.urlopen(self.get_url())
299
if opener.headers['content-type'] != 'image/png':
300
raise BadContentTypeException('Server responded with a ' \
301
'content-type of %s' % opener.headers['content-type'])
303
open(file_name, 'wb').write(urllib.urlopen(self.get_url()).read())
306
# -------------------------------------------------------------------------
308
def set_title(self, title):
310
self.title = urllib.quote(title)
314
def set_legend(self, legend):
315
# legend needs to be a list, tuple or None
316
assert(isinstance(legend, list) or isinstance(legend, tuple) or
319
self.legend = [urllib.quote(a) for a in legend]
324
# -------------------------------------------------------------------------
326
def set_colours(self, colours):
327
# colours needs to be a list, tuple or None
328
assert(isinstance(colours, list) or isinstance(colours, tuple) or
330
# make sure the colours are in the right format
334
self.colours = colours
336
# Background/Chart colours
337
# -------------------------------------------------------------------------
339
def fill_solid(self, area, colour):
340
assert(area in (Chart.BACKGROUND, Chart.CHART))
341
_check_colour(colour)
342
self.fill_area[area] = colour
343
self.fill_types[area] = Chart.SOLID
345
def _check_fill_linear(self, angle, *args):
346
assert(isinstance(args, list) or isinstance(args, tuple))
347
assert(angle >= 0 and angle <= 90)
348
assert(len(args) % 2 == 0)
349
args = list(args) # args is probably a tuple and we need to mutate
350
for a in xrange(len(args) / 2):
352
offset = args[a * 2 + 1]
354
assert(offset >= 0 and offset <= 1)
355
args[a * 2 + 1] = str(args[a * 2 + 1])
358
def fill_linear_gradient(self, area, angle, *args):
359
assert(area in (Chart.BACKGROUND, Chart.CHART))
360
args = self._check_fill_linear(angle, *args)
361
self.fill_types[area] = Chart.LINEAR_GRADIENT
362
self.fill_area[area] = ','.join([str(angle)] + args)
364
def fill_linear_stripes(self, area, angle, *args):
365
assert(area in (Chart.BACKGROUND, Chart.CHART))
366
args = self._check_fill_linear(angle, *args)
367
self.fill_types[area] = Chart.LINEAR_STRIPES
368
self.fill_area[area] = ','.join([str(angle)] + args)
370
def fill_to_url(self):
372
for area in (Chart.BACKGROUND, Chart.CHART):
373
if self.fill_types[area]:
374
areas.append('%s,%s,%s' % (area, self.fill_types[area], \
375
self.fill_area[area]))
377
return 'chf=' + '|'.join(areas)
380
# -------------------------------------------------------------------------
382
def data_class_detection(self, data):
384
Detects and returns the data type required based on the range of the
385
data given. The data given must be lists of numbers within a list.
387
assert(isinstance(data, list) or isinstance(data, tuple))
390
assert(isinstance(a, list) or isinstance(a, tuple))
391
if max_value is None or max(a) > max_value:
393
for data_class in (SimpleData, TextData, ExtendedData):
394
if max_value <= data_class.max_value():
396
raise DataOutOfRangeException()
398
def add_data(self, data):
399
self.data.append(data)
400
return len(self.data) - 1 # return the "index" of the data set
402
def data_to_url(self, data_class=None):
404
data_class = self.data_class_detection(self.data)
405
if not issubclass(data_class, Data):
406
raise UnknownDataTypeException()
407
return repr(data_class(self.data))
410
# -------------------------------------------------------------------------
412
def set_axis_labels(self, axis, values):
413
assert(axis in Axis.TYPES)
414
self.axis[axis] = LabelAxis(axis, values)
416
def set_axis_range(self, axis, low, high):
417
assert(axis in Axis.TYPES)
418
self.axis[axis] = RangeAxis(axis, low, high)
420
def set_axis_positions(self, axis, positions):
421
assert(axis in Axis.TYPES)
422
if not self.axis[axis]:
423
raise InvalidParametersException('Please create an axis first')
424
self.axis[axis].set_positions(positions)
426
def set_axis_style(self, axis, colour, font_size=None, alignment=None):
427
assert(axis in Axis.TYPES)
428
if not self.axis[axis]:
429
raise InvalidParametersException('Please create an axis first')
430
self.axis[axis].set_style(colour, font_size, alignment)
432
def axis_to_url(self):
439
for position, axis in self.axis.items():
443
axis.set_index(index)
444
available_axis.append(position)
445
if isinstance(axis, RangeAxis):
446
range_axis.append(repr(axis))
447
if isinstance(axis, LabelAxis):
448
label_axis.append(repr(axis))
450
positions.append(axis.positions_to_url())
452
styles.append(axis.style_to_url())
453
if not available_axis:
456
url_bits.append('chxt=%s' % ','.join(available_axis))
458
url_bits.append('chxl=%s' % '|'.join(label_axis))
460
url_bits.append('chxr=%s' % '|'.join(range_axis))
462
url_bits.append('chxp=%s' % '|'.join(positions))
464
url_bits.append('chxs=%s' % '|'.join(styles))
465
return '&'.join(url_bits)
467
# Markers, Ranges and Fill area (chm)
468
# -------------------------------------------------------------------------
470
def markers_to_url(self):
471
return 'chm=%s' % '|'.join([','.join(a) for a in self.markers])
473
def add_marker(self, index, point, marker_type, colour, size):
474
self.markers.append((marker_type, colour, str(index), str(point), \
477
def add_horizontal_range(self, colour, start, stop):
478
self.markers.append(('r', colour, '1', str(start), str(stop)))
480
def add_vertical_range(self, colour, start, stop):
481
self.markers.append(('R', colour, '1', str(start), str(stop)))
483
def add_fill_range(self, colour, index_start, index_end):
484
self.markers.append(('b', colour, str(index_start), str(index_end), \
487
def add_fill_simple(self, colour):
488
self.markers.append(('B', colour, '1', '1', '1'))
491
class ScatterChart(Chart):
493
def __init__(self, *args, **kwargs):
494
Chart.__init__(self, *args, **kwargs)
496
def type_to_url(self):
500
class LineChart(Chart):
502
def __init__(self, *args, **kwargs):
503
assert(type(self) != LineChart) # This is an abstract class
504
Chart.__init__(self, *args, **kwargs)
505
self.line_styles = {}
508
def set_line_style(self, index, thickness=1, line_segment=None, \
511
value.append(str(thickness))
513
value.append(str(line_segment))
514
value.append(str(blank_segment))
515
self.line_styles[index] = value
517
def set_grid(self, x_step, y_step, line_segment=1, \
519
self.grid = '%s,%s,%s,%s' % (x_step, y_step, line_segment, \
522
def get_url_bits(self):
523
url_bits = Chart.get_url_bits(self)
526
# for index, values in self.line_style.items():
527
for index in xrange(max(self.line_styles) + 1):
528
if index in self.line_styles:
529
values = self.line_styles[index]
532
style.append(','.join(values))
533
url_bits.append('chls=%s' % '|'.join(style))
535
url_bits.append('chg=%s' % self.grid)
539
class SimpleLineChart(LineChart):
541
def type_to_url(self):
545
class XYLineChart(LineChart):
547
def type_to_url(self):
551
class BarChart(Chart):
553
def __init__(self, *args, **kwargs):
554
assert(type(self) != BarChart) # This is an abstract class
555
Chart.__init__(self, *args, **kwargs)
556
self.bar_width = None
558
def set_bar_width(self, bar_width):
559
self.bar_width = bar_width
561
def get_url_bits(self):
562
url_bits = Chart.get_url_bits(self)
563
url_bits.append('chbh=%i' % self.bar_width)
567
class StackedHorizontalBarChart(BarChart):
569
def type_to_url(self):
573
class StackedVerticalBarChart(BarChart):
575
def type_to_url(self):
579
class GroupedBarChart(BarChart):
581
def __init__(self, *args, **kwargs):
582
assert(type(self) != GroupedBarChart) # This is an abstract class
583
BarChart.__init__(self, *args, **kwargs)
584
self.bar_spacing = None
586
def set_bar_spacing(self, spacing):
587
self.bar_spacing = spacing
589
def get_url_bits(self):
590
# Skip 'BarChart.get_url_bits' and call Chart directly so the parent
591
# doesn't add "chbh" before we do.
592
url_bits = Chart.get_url_bits(self)
593
if self.bar_spacing is not None:
594
if self.bar_width is None:
595
raise InvalidParametersException('Bar width is required to ' \
596
'be set when setting spacing')
597
url_bits.append('chbh=%i,%i' % (self.bar_width, self.bar_spacing))
599
url_bits.append('chbh=%i' % self.bar_width)
603
class GroupedHorizontalBarChart(GroupedBarChart):
605
def type_to_url(self):
609
class GroupedVerticalBarChart(GroupedBarChart):
611
def type_to_url(self):
615
class PieChart(Chart):
617
def __init__(self, *args, **kwargs):
618
assert(type(self) != PieChart) # This is an abstract class
619
Chart.__init__(self, *args, **kwargs)
622
def set_pie_labels(self, labels):
623
self.pie_labels = labels
625
def get_url_bits(self):
626
url_bits = Chart.get_url_bits(self)
628
url_bits.append('chl=%s' % '|'.join(self.pie_labels))
632
class PieChart2D(PieChart):
634
def type_to_url(self):
638
class PieChart3D(PieChart):
640
def type_to_url(self):
644
class VennChart(Chart):
646
def type_to_url(self):
651
chart = GroupedVerticalBarChart(320, 200)
652
chart = PieChart2D(320, 200)
653
chart = ScatterChart(320, 200)
654
chart = SimpleLineChart(320, 200)
655
sine_data = [math.sin(float(a) / 10) * 2000 + 2000 for a in xrange(100)]
656
random_data = [a * random.random() * 30 for a in xrange(40)]
657
random_data2 = [random.random() * 4000 for a in xrange(10)]
658
# chart.set_bar_width(50)
659
# chart.set_bar_spacing(0)
660
chart.add_data(sine_data)
661
chart.add_data(random_data)
662
chart.add_data(random_data2)
663
# chart.set_line_style(1, thickness=2)
664
# chart.set_line_style(2, line_segment=10, blank_segment=5)
665
# chart.set_title('heloooo')
666
# chart.set_legend(('sine wave', 'random * x'))
667
# chart.set_colours(('ee2000', 'DDDDAA', 'fF03f2'))
668
# chart.fill_solid(Chart.BACKGROUND, '123456')
669
# chart.fill_linear_gradient(Chart.CHART, 20, '004070', 1, '300040', 0,
671
# chart.fill_linear_stripes(Chart.CHART, 20, '204070', .2, '300040', .2,
673
chart.set_axis_range(Axis.LEFT, 0, 10)
674
chart.set_axis_range(Axis.RIGHT, 5, 30)
675
chart.set_axis_labels(Axis.BOTTOM, [1, 25, 95])
676
chart.set_axis_positions(Axis.BOTTOM, [1, 25, 95])
677
chart.set_axis_style(Axis.BOTTOM, 'FFFFFF', 15)
679
# chart.set_pie_labels(('apples', 'oranges', 'bananas'))
681
# chart.set_grid(10, 10)
683
# for a in xrange(0, 100, 10):
684
# chart.add_marker(1, a, 'a', 'AACA20', 10)
686
chart.add_horizontal_range('00A020', .2, .5)
687
chart.add_vertical_range('00c030', .2, .4)
689
chart.add_fill_simple('303030A0')
691
chart.download('test.png')
693
url = chart.get_url()
696
data = urllib.urlopen(chart.get_url()).read()
697
open('meh.png', 'wb').write(data)
698
os.system('start meh.png')
701
if __name__ == '__main__':