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
# -----------------------------------------------------------------------------
35
reo_colour = re.compile('^([A-Fa-f0-9]{2,2}){3,4}$')
38
def _check_colour(colour):
39
if not reo_colour.match(colour):
40
raise InvalidParametersException('Colours need to be in ' \
41
'RRGGBB or RRGGBBAA format. One of your colours has %s' % \
45
# -----------------------------------------------------------------------------
48
class PyGoogleChartException(Exception):
52
class DataOutOfRangeException(PyGoogleChartException):
56
class UnknownDataTypeException(PyGoogleChartException):
60
class NoDataGivenException(PyGoogleChartException):
64
class InvalidParametersException(PyGoogleChartException):
68
class BadContentTypeException(PyGoogleChartException):
73
# -----------------------------------------------------------------------------
78
def __init__(self, data):
79
assert(type(self) != Data) # This is an abstract class
83
class SimpleData(Data):
84
enc_map = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
88
for data in self.data:
93
elif value >= 0 and value <= SimpleData.max_value:
94
sub_data.append(SimpleData.enc_map[value])
96
raise DataOutOfRangeException()
97
encoded_data.append(''.join(sub_data))
98
return 'chd=s:' + ','.join(encoded_data)
105
class TextData(Data):
109
for data in self.data:
114
elif value >= 0 and value <= TextData.max_value:
115
sub_data.append(str(float(value)))
117
raise DataOutOfRangeException()
118
encoded_data.append(','.join(sub_data))
119
return 'chd=t:' + '|'.join(encoded_data)
126
class ExtendedData(Data):
128
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-.'
132
enc_size = len(ExtendedData.enc_map)
133
for data in self.data:
137
sub_data.append('__')
138
elif value >= 0 and value <= ExtendedData.max_value:
139
first, second = divmod(int(value), enc_size)
140
sub_data.append('%s%s' % (
141
ExtendedData.enc_map[first],
142
ExtendedData.enc_map[second]))
144
raise DataOutOfRangeException( \
145
'Item #%i "%s" is out of range' % (data.index(value), \
147
encoded_data.append(''.join(sub_data))
148
return 'chd=e:' + ','.join(encoded_data)
155
# -----------------------------------------------------------------------------
163
TYPES = (BOTTOM, TOP, LEFT, RIGHT)
165
def __init__(self, axis_index, axis_type, **kw):
166
assert(axis_type in Axis.TYPES)
167
self.has_style = False
168
self.axis_index = axis_index
169
self.axis_type = axis_type
170
self.positions = None
172
def set_index(self, axis_index):
173
self.axis_index = axis_index
175
def set_positions(self, positions):
176
self.positions = positions
178
def set_style(self, colour, font_size=None, alignment=None):
179
_check_colour(colour)
181
self.font_size = font_size
182
self.alignment = alignment
183
self.has_style = True
185
def style_to_url(self):
187
bits.append(str(self.axis_index))
188
bits.append(self.colour)
189
if self.font_size is not None:
190
bits.append(str(self.font_size))
191
if self.alignment is not None:
192
bits.append(str(self.alignment))
193
return ','.join(bits)
195
def positions_to_url(self):
197
bits.append(str(self.axis_index))
198
bits += [str(a) for a in self.positions]
199
return ','.join(bits)
202
class LabelAxis(Axis):
204
def __init__(self, axis_index, axis_type, values, **kwargs):
205
Axis.__init__(self, axis_index, axis_type, **kwargs)
206
self.values = [str(a) for a in values]
209
return '%i:|%s' % (self.axis_index, '|'.join(self.values))
212
class RangeAxis(Axis):
214
def __init__(self, axis_index, axis_type, low, high, **kwargs):
215
Axis.__init__(self, axis_index, axis_type, **kwargs)
220
return '%i,%s,%s' % (self.axis_index, self.low, self.high)
223
# -----------------------------------------------------------------------------
227
"""Abstract class for all chart types.
229
width are height specify the dimensions of the image. title sets the title
230
of the chart. legend requires a list that corresponds to datasets.
233
BASE_URL = 'http://chart.apis.google.com/chart?'
237
LINEAR_GRADIENT = 'lg'
238
LINEAR_STRIPES = 'ls'
240
def __init__(self, width, height, title=None, legend=None, colours=None):
241
assert(type(self) != Chart) # This is an abstract class
242
assert(isinstance(width, int))
243
assert(isinstance(height, int))
247
self.set_title(title)
248
self.set_legend(legend)
249
self.set_colours(colours)
251
Chart.BACKGROUND: None,
255
Chart.BACKGROUND: None,
268
# -------------------------------------------------------------------------
271
url_bits = self.get_url_bits()
272
return self.BASE_URL + '&'.join(url_bits)
274
def get_url_bits(self):
277
url_bits.append(self.type_to_url())
278
url_bits.append('chs=%ix%i' % (self.width, self.height))
279
url_bits.append(self.data_to_url())
282
url_bits.append('chtt=%s' % self.title)
284
url_bits.append('chdl=%s' % '|'.join(self.legend))
286
url_bits.append('chco=%s' % ','.join(self.colours))
287
ret = self.fill_to_url()
290
ret = self.axis_to_url()
294
url_bits.append(self.markers_to_url())
298
# -------------------------------------------------------------------------
300
def download(self, file_name):
301
opener = urllib2.urlopen(self.get_url())
303
if opener.headers['content-type'] != 'image/png':
304
raise BadContentTypeException('Server responded with a ' \
305
'content-type of %s' % opener.headers['content-type'])
307
open(file_name, 'wb').write(urllib.urlopen(self.get_url()).read())
310
# -------------------------------------------------------------------------
312
def set_title(self, title):
314
self.title = urllib.quote(title)
318
def set_legend(self, legend):
319
# legend needs to be a list, tuple or None
320
assert(isinstance(legend, list) or isinstance(legend, tuple) or
323
self.legend = [urllib.quote(a) for a in legend]
328
# -------------------------------------------------------------------------
330
def set_colours(self, colours):
331
# colours needs to be a list, tuple or None
332
assert(isinstance(colours, list) or isinstance(colours, tuple) or
334
# make sure the colours are in the right format
338
self.colours = colours
340
# Background/Chart colours
341
# -------------------------------------------------------------------------
343
def fill_solid(self, area, colour):
344
assert(area in (Chart.BACKGROUND, Chart.CHART))
345
_check_colour(colour)
346
self.fill_area[area] = colour
347
self.fill_types[area] = Chart.SOLID
349
def _check_fill_linear(self, angle, *args):
350
assert(isinstance(args, list) or isinstance(args, tuple))
351
assert(angle >= 0 and angle <= 90)
352
assert(len(args) % 2 == 0)
353
args = list(args) # args is probably a tuple and we need to mutate
354
for a in xrange(len(args) / 2):
356
offset = args[a * 2 + 1]
358
assert(offset >= 0 and offset <= 1)
359
args[a * 2 + 1] = str(args[a * 2 + 1])
362
def fill_linear_gradient(self, area, angle, *args):
363
assert(area in (Chart.BACKGROUND, Chart.CHART))
364
args = self._check_fill_linear(angle, *args)
365
self.fill_types[area] = Chart.LINEAR_GRADIENT
366
self.fill_area[area] = ','.join([str(angle)] + args)
368
def fill_linear_stripes(self, area, angle, *args):
369
assert(area in (Chart.BACKGROUND, Chart.CHART))
370
args = self._check_fill_linear(angle, *args)
371
self.fill_types[area] = Chart.LINEAR_STRIPES
372
self.fill_area[area] = ','.join([str(angle)] + args)
374
def fill_to_url(self):
376
for area in (Chart.BACKGROUND, Chart.CHART):
377
if self.fill_types[area]:
378
areas.append('%s,%s,%s' % (area, self.fill_types[area], \
379
self.fill_area[area]))
381
return 'chf=' + '|'.join(areas)
384
# -------------------------------------------------------------------------
386
def data_class_detection(self, data):
388
Detects and returns the data type required based on the range of the
389
data given. The data given must be lists of numbers within a list.
391
assert(isinstance(data, list) or isinstance(data, tuple))
394
assert(isinstance(a, list) or isinstance(a, tuple))
395
if max_value is None or max(a) > max_value:
397
for data_class in (SimpleData, TextData, ExtendedData):
398
if max_value <= data_class.max_value():
400
raise DataOutOfRangeException()
402
def add_data(self, data):
403
self.data.append(data)
404
return len(self.data) - 1 # return the "index" of the data set
406
def data_to_url(self, data_class=None):
408
data_class = self.data_class_detection(self.data)
409
if not issubclass(data_class, Data):
410
raise UnknownDataTypeException()
411
return repr(data_class(self.data))
414
# -------------------------------------------------------------------------
416
def set_axis_labels(self, axis_type, values):
417
assert(axis_type in Axis.TYPES)
418
values = [ urllib.quote(a) for a in values ]
419
axis_index = len(self.axis)
420
axis = LabelAxis(axis_index, axis_type, values)
421
self.axis.append(axis)
424
def set_axis_range(self, axis_type, low, high):
425
assert(axis_type in Axis.TYPES)
426
axis_index = len(self.axis)
427
axis = RangeAxis(axis_index, axis_type, low, high)
428
self.axis.append(axis)
431
def set_axis_positions(self, axis_index, positions):
433
self.axis[axis_index].set_positions(positions)
435
raise InvalidParametersException('Axis index %i has not been ' \
438
def set_axis_style(self, axis_index, colour, font_size=None, \
441
self.axis[axis_index].set_style(colour, font_size, alignment)
443
raise InvalidParametersException('Axis index %i has not been ' \
446
def axis_to_url(self):
453
for axis in self.axis:
454
available_axis.append(axis.axis_type)
455
if isinstance(axis, RangeAxis):
456
range_axis.append(repr(axis))
457
if isinstance(axis, LabelAxis):
458
label_axis.append(repr(axis))
460
positions.append(axis.positions_to_url())
462
styles.append(axis.style_to_url())
463
if not available_axis:
466
url_bits.append('chxt=%s' % ','.join(available_axis))
468
url_bits.append('chxl=%s' % '|'.join(label_axis))
470
url_bits.append('chxr=%s' % '|'.join(range_axis))
472
url_bits.append('chxp=%s' % '|'.join(positions))
474
url_bits.append('chxs=%s' % '|'.join(styles))
475
return '&'.join(url_bits)
477
# Markers, Ranges and Fill area (chm)
478
# -------------------------------------------------------------------------
480
def markers_to_url(self):
481
return 'chm=%s' % '|'.join([','.join(a) for a in self.markers])
483
def add_marker(self, index, point, marker_type, colour, size):
484
self.markers.append((marker_type, colour, str(index), str(point), \
487
def add_horizontal_range(self, colour, start, stop):
488
self.markers.append(('r', colour, '1', str(start), str(stop)))
490
def add_vertical_range(self, colour, start, stop):
491
self.markers.append(('R', colour, '1', str(start), str(stop)))
493
def add_fill_range(self, colour, index_start, index_end):
494
self.markers.append(('b', colour, str(index_start), str(index_end), \
497
def add_fill_simple(self, colour):
498
self.markers.append(('B', colour, '1', '1', '1'))
501
class ScatterChart(Chart):
503
def __init__(self, *args, **kwargs):
504
Chart.__init__(self, *args, **kwargs)
506
def type_to_url(self):
510
class LineChart(Chart):
512
def __init__(self, *args, **kwargs):
513
assert(type(self) != LineChart) # This is an abstract class
514
Chart.__init__(self, *args, **kwargs)
515
self.line_styles = {}
518
def set_line_style(self, index, thickness=1, line_segment=None, \
521
value.append(str(thickness))
523
value.append(str(line_segment))
524
value.append(str(blank_segment))
525
self.line_styles[index] = value
527
def set_grid(self, x_step, y_step, line_segment=1, \
529
self.grid = '%s,%s,%s,%s' % (x_step, y_step, line_segment, \
532
def get_url_bits(self):
533
url_bits = Chart.get_url_bits(self)
536
# for index, values in self.line_style.items():
537
for index in xrange(max(self.line_styles) + 1):
538
if index in self.line_styles:
539
values = self.line_styles[index]
542
style.append(','.join(values))
543
url_bits.append('chls=%s' % '|'.join(style))
545
url_bits.append('chg=%s' % self.grid)
549
class SimpleLineChart(LineChart):
551
def type_to_url(self):
555
class XYLineChart(LineChart):
557
def type_to_url(self):
561
class BarChart(Chart):
563
def __init__(self, *args, **kwargs):
564
assert(type(self) != BarChart) # This is an abstract class
565
Chart.__init__(self, *args, **kwargs)
566
self.bar_width = None
568
def set_bar_width(self, bar_width):
569
self.bar_width = bar_width
571
def get_url_bits(self):
572
url_bits = Chart.get_url_bits(self)
573
url_bits.append('chbh=%i' % self.bar_width)
577
class StackedHorizontalBarChart(BarChart):
579
def type_to_url(self):
583
class StackedVerticalBarChart(BarChart):
585
def type_to_url(self):
589
class GroupedBarChart(BarChart):
591
def __init__(self, *args, **kwargs):
592
assert(type(self) != GroupedBarChart) # This is an abstract class
593
BarChart.__init__(self, *args, **kwargs)
594
self.bar_spacing = None
596
def set_bar_spacing(self, spacing):
597
self.bar_spacing = spacing
599
def get_url_bits(self):
600
# Skip 'BarChart.get_url_bits' and call Chart directly so the parent
601
# doesn't add "chbh" before we do.
602
url_bits = Chart.get_url_bits(self)
603
if self.bar_spacing is not None:
604
if self.bar_width is None:
605
raise InvalidParametersException('Bar width is required to ' \
606
'be set when setting spacing')
607
url_bits.append('chbh=%i,%i' % (self.bar_width, self.bar_spacing))
609
url_bits.append('chbh=%i' % self.bar_width)
613
class GroupedHorizontalBarChart(GroupedBarChart):
615
def type_to_url(self):
619
class GroupedVerticalBarChart(GroupedBarChart):
621
def type_to_url(self):
625
class PieChart(Chart):
627
def __init__(self, *args, **kwargs):
628
assert(type(self) != PieChart) # This is an abstract class
629
Chart.__init__(self, *args, **kwargs)
632
def set_pie_labels(self, labels):
633
self.pie_labels = [urllib.quote(a) for a in labels]
635
def get_url_bits(self):
636
url_bits = Chart.get_url_bits(self)
638
url_bits.append('chl=%s' % '|'.join(self.pie_labels))
642
class PieChart2D(PieChart):
644
def type_to_url(self):
648
class PieChart3D(PieChart):
650
def type_to_url(self):
654
class VennChart(Chart):
656
def type_to_url(self):
661
chart = GroupedVerticalBarChart(320, 200)
662
chart = PieChart2D(320, 200)
663
chart = ScatterChart(320, 200)
664
chart = SimpleLineChart(320, 200)
665
sine_data = [math.sin(float(a) / 10) * 2000 + 2000 for a in xrange(100)]
666
random_data = [a * random.random() * 30 for a in xrange(40)]
667
random_data2 = [random.random() * 4000 for a in xrange(10)]
668
# chart.set_bar_width(50)
669
# chart.set_bar_spacing(0)
670
chart.add_data(sine_data)
671
chart.add_data(random_data)
672
chart.add_data(random_data2)
673
# chart.set_line_style(1, thickness=2)
674
# chart.set_line_style(2, line_segment=10, blank_segment=5)
675
# chart.set_title('heloooo')
676
# chart.set_legend(('sine wave', 'random * x'))
677
# chart.set_colours(('ee2000', 'DDDDAA', 'fF03f2'))
678
# chart.fill_solid(Chart.BACKGROUND, '123456')
679
# chart.fill_linear_gradient(Chart.CHART, 20, '004070', 1, '300040', 0,
681
# chart.fill_linear_stripes(Chart.CHART, 20, '204070', .2, '300040', .2,
683
axis_left_index = chart.set_axis_range(Axis.LEFT, 0, 10)
684
axis_left_index = chart.set_axis_range(Axis.LEFT, 0, 10)
685
axis_left_index = chart.set_axis_range(Axis.LEFT, 0, 10)
686
axis_right_index = chart.set_axis_range(Axis.RIGHT, 5, 30)
687
axis_bottom_index = chart.set_axis_labels(Axis.BOTTOM, [1, 25, 95])
688
chart.set_axis_positions(axis_bottom_index, [1, 25, 95])
689
chart.set_axis_style(axis_bottom_index, '003050', 15)
691
# chart.set_pie_labels(('apples', 'oranges', 'bananas'))
693
# chart.set_grid(10, 10)
695
# for a in xrange(0, 100, 10):
696
# chart.add_marker(1, a, 'a', 'AACA20', 10)
698
chart.add_horizontal_range('00A020', .2, .5)
699
chart.add_vertical_range('00c030', .2, .4)
701
chart.add_fill_simple('303030A0')
703
chart.download('test.png')
705
url = chart.get_url()
708
data = urllib.urlopen(chart.get_url()).read()
709
open('meh.png', 'wb').write(data)
710
os.system('start meh.png')
713
if __name__ == '__main__':
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
340
return self.BASE_URL + '?' + self.get_url_extension(data_class)
342
def get_url_extension(self, data_class = None):
343
# -------------------------------------------------------------------------
345
def get_url(self, data_class=None):
346
url_bits = self.get_url_bits(data_class=data_class)
347
return '&'.join(url_bits)
349
def get_url_bits(self, data_class=None):
352
url_bits.append(self.type_to_url())
353
url_bits.append('chs=%ix%i' % (self.width, self.height))
354
url_bits.append(self.data_to_url(data_class=data_class))
357
url_bits.append('chtt=%s' % self.title)
358
if self.title_colour and self.title_font_size:
359
url_bits.append('chts=%s,%s' % (self.title_colour, \
360
self.title_font_size))
362
url_bits.append('chdl=%s' % '%7c'.join(self.legend))
363
if self.legend_position:
364
url_bits.append('chdlp=%s' % (self.legend_position))
366
url_bits.append('chco=%s' % ','.join(self.colours))
367
if self.colours_within_series:
368
url_bits.append('chco=%s' % '%7c'.join(self.colours_within_series))
369
ret = self.fill_to_url()
372
ret = self.axis_to_url()
376
url_bits.append(self.markers_to_url())
379
for index in xrange(max(self.line_styles) + 1):
380
if index in self.line_styles:
381
values = self.line_styles[index]
384
style.append(','.join(values))
385
url_bits.append('chls=%s' % '%7c'.join(style))
387
url_bits.append('chg=%s' % self.grid)
391
# -------------------------------------------------------------------------
393
def download(self, file_name, use_post = False):
395
opener = urllib2.urlopen(self.BASE_URL, self.get_url_extension())
397
opener = urllib2.urlopen(self.get_url())
399
if opener.headers['content-type'] != 'image/png':
400
raise BadContentTypeException('Server responded with a ' \
401
'content-type of %s' % opener.headers['content-type'])
403
open(file_name, 'wb').write(opener.read())
406
# -------------------------------------------------------------------------
408
def set_title(self, title):
410
self.title = urllib.quote(title)
414
def set_title_style(self, colour=None, font_size=None):
415
if not colour is None:
416
_check_colour(colour)
417
if not colour and not font_size:
419
self.title_colour = colour or '333333'
420
self.title_font_size = font_size or 13.5
422
def set_legend(self, legend):
423
"""legend needs to be a list, tuple or None"""
424
assert(isinstance(legend, list) or isinstance(legend, tuple) or
427
self.legend = [urllib.quote(a) for a in legend]
431
def set_legend_position(self, legend_position):
433
self.legend_position = urllib.quote(legend_position)
435
self.legend_position = None
438
# -------------------------------------------------------------------------
440
def set_colours(self, colours):
441
# colours needs to be a list, tuple or None
442
assert(isinstance(colours, list) or isinstance(colours, tuple) or
444
# make sure the colours are in the right format
448
self.colours = colours
450
def set_colours_within_series(self, colours):
451
# colours needs to be a list, tuple or None
452
assert(isinstance(colours, list) or isinstance(colours, tuple) or
454
# make sure the colours are in the right format
458
self.colours_within_series = colours
460
# Background/Chart colours
461
# -------------------------------------------------------------------------
463
def fill_solid(self, area, colour):
464
assert(area in Chart.VALID_SOLID_FILL_TYPES)
465
_check_colour(colour)
466
self.fill_area[area] = colour
467
self.fill_types[area] = Chart.SOLID
469
def _check_fill_linear(self, angle, *args):
470
assert(isinstance(args, list) or isinstance(args, tuple))
471
assert(angle >= 0 and angle <= 90)
472
assert(len(args) % 2 == 0)
473
args = list(args) # args is probably a tuple and we need to mutate
474
for a in xrange(int(len(args) / 2)):
476
offset = args[a * 2 + 1]
478
assert(offset >= 0 and offset <= 1)
479
args[a * 2 + 1] = str(args[a * 2 + 1])
482
def fill_linear_gradient(self, area, angle, *args):
483
assert(area in Chart.VALID_SOLID_FILL_TYPES)
484
args = self._check_fill_linear(angle, *args)
485
self.fill_types[area] = Chart.LINEAR_GRADIENT
486
self.fill_area[area] = ','.join([str(angle)] + args)
488
def fill_linear_stripes(self, area, angle, *args):
489
assert(area in Chart.VALID_SOLID_FILL_TYPES)
490
args = self._check_fill_linear(angle, *args)
491
self.fill_types[area] = Chart.LINEAR_STRIPES
492
self.fill_area[area] = ','.join([str(angle)] + args)
494
def fill_to_url(self):
496
for area in (Chart.BACKGROUND, Chart.CHART, Chart.ALPHA):
497
if self.fill_types[area]:
498
areas.append('%s,%s,%s' % (area, self.fill_types[area], \
499
self.fill_area[area]))
501
return 'chf=' + '%7c'.join(areas)
504
# -------------------------------------------------------------------------
506
def data_class_detection(self, data):
507
"""Determines the appropriate data encoding type to give satisfactory
508
resolution (http://code.google.com/apis/chart/#chart_data).
510
assert(isinstance(data, list) or isinstance(data, tuple))
511
if not isinstance(self, (LineChart, BarChart, ScatterChart)):
512
# From the link above:
513
# Simple encoding is suitable for all other types of chart
514
# regardless of size.
516
elif self.height < 100:
517
# The link above indicates that line and bar charts less
518
# than 300px in size can be suitably represented with the
519
# simple encoding. I've found that this isn't sufficient,
520
# e.g. examples/line-xy-circle.png. Let's try 100px.
525
def _filter_none(self, data):
526
return [r for r in data if r is not None]
528
def data_x_range(self):
529
"""Return a 2-tuple giving the minimum and maximum x-axis
533
lower = min([min(self._filter_none(s))
534
for type, s in self.annotated_data()
536
upper = max([max(self._filter_none(s))
537
for type, s in self.annotated_data()
539
return (lower, upper)
541
return None # no x-axis datasets
543
def data_y_range(self):
544
"""Return a 2-tuple giving the minimum and maximum y-axis
548
lower = min([min(self._filter_none(s))
549
for type, s in self.annotated_data()
551
upper = max([max(self._filter_none(s)) + 1
552
for type, s in self.annotated_data()
554
return (lower, upper)
556
return None # no y-axis datasets
558
def scaled_data(self, data_class, x_range=None, y_range=None):
559
"""Scale `self.data` as appropriate for the given data encoding
560
(data_class) and return it.
562
An optional `y_range` -- a 2-tuple (lower, upper) -- can be
563
given to specify the y-axis bounds. If not given, the range is
564
inferred from the data: (0, <max-value>) presuming no negative
565
values, or (<min-value>, <max-value>) if there are negative
566
values. `self.scaled_y_range` is set to the actual lower and
569
Ditto for `x_range`. Note that some chart types don't have x-axis
572
self.scaled_data_class = data_class
574
# Determine the x-axis range for scaling.
576
x_range = self.data_x_range()
577
if x_range and x_range[0] > 0:
578
x_range = (x_range[0], x_range[1])
579
self.scaled_x_range = x_range
581
# Determine the y-axis range for scaling.
583
y_range = self.data_y_range()
584
if y_range and y_range[0] > 0:
585
y_range = (y_range[0], y_range[1])
586
self.scaled_y_range = y_range
589
for type, dataset in self.annotated_data():
591
scale_range = x_range
593
scale_range = y_range
594
elif type == 'marker-size':
595
scale_range = (0, max(dataset))
599
scaled_dataset.append(None)
601
scaled_dataset.append(
602
data_class.scale_value(v, scale_range))
603
scaled_data.append(scaled_dataset)
606
def add_data(self, data):
607
self.data.append(data)
608
return len(self.data) - 1 # return the "index" of the data set
610
def data_to_url(self, data_class=None):
612
data_class = self.data_class_detection(self.data)
613
if not issubclass(data_class, Data):
614
raise UnknownDataTypeException()
616
data = self.scaled_data(data_class, self.x_range, self.y_range)
619
return repr(data_class(data))
621
def annotated_data(self):
622
for dataset in self.data:
626
# -------------------------------------------------------------------------
628
def set_axis_labels(self, axis_type, values):
629
assert(axis_type in Axis.TYPES)
630
values = [urllib.quote(str(a)) for a in values]
631
axis_index = len(self.axis)
632
axis = LabelAxis(axis_index, axis_type, values)
633
self.axis.append(axis)
636
def set_axis_range(self, axis_type, low, high):
637
assert(axis_type in Axis.TYPES)
638
axis_index = len(self.axis)
639
axis = RangeAxis(axis_index, axis_type, low, high)
640
self.axis.append(axis)
643
def set_axis_positions(self, axis_index, positions):
645
self.axis[axis_index].set_positions(positions)
647
raise InvalidParametersException('Axis index %i has not been ' \
650
def set_axis_style(self, axis_index, colour, font_size=None, \
653
self.axis[axis_index].set_style(colour, font_size, alignment)
655
raise InvalidParametersException('Axis index %i has not been ' \
658
def axis_to_url(self):
665
for axis in self.axis:
666
available_axis.append(axis.axis_type)
667
if isinstance(axis, RangeAxis):
668
range_axis.append(repr(axis))
669
if isinstance(axis, LabelAxis):
670
label_axis.append(repr(axis))
672
positions.append(axis.positions_to_url())
674
styles.append(axis.style_to_url())
675
if not available_axis:
678
url_bits.append('chxt=%s' % ','.join(available_axis))
680
url_bits.append('chxl=%s' % '%7c'.join(label_axis))
682
url_bits.append('chxr=%s' % '%7c'.join(range_axis))
684
url_bits.append('chxp=%s' % '%7c'.join(positions))
686
url_bits.append('chxs=%s' % '%7c'.join(styles))
687
return '&'.join(url_bits)
689
# Markers, Ranges and Fill area (chm)
690
# -------------------------------------------------------------------------
692
def markers_to_url(self):
693
return 'chm=%s' % '%7c'.join([','.join(a) for a in self.markers])
695
def add_marker(self, index, point, marker_type, colour, size, priority=0):
696
self.markers.append((marker_type, colour, str(index), str(point), \
697
str(size), str(priority)))
699
def add_horizontal_range(self, colour, start, stop):
700
self.markers.append(('r', colour, '0', str(start), str(stop)))
702
def add_data_line(self, colour, data_set, size, priority=0):
703
self.markers.append(('D', colour, str(data_set), '0', str(size), \
706
def add_marker_text(self, string, colour, data_set, data_point, size, \
708
self.markers.append((str(string), colour, str(data_set), \
709
str(data_point), str(size), str(priority)))
711
def add_vertical_range(self, colour, start, stop):
712
self.markers.append(('R', colour, '0', str(start), str(stop)))
714
def add_fill_range(self, colour, index_start, index_end):
715
self.markers.append(('b', colour, str(index_start), str(index_end), \
718
def add_fill_simple(self, colour):
719
self.markers.append(('B', colour, '1', '1', '1'))
722
# -------------------------------------------------------------------------
724
def set_line_style(self, index, thickness=1, line_segment=None, \
727
value.append(str(thickness))
729
value.append(str(line_segment))
730
value.append(str(blank_segment))
731
self.line_styles[index] = value
734
# -------------------------------------------------------------------------
736
def set_grid(self, x_step, y_step, line_segment=1, \
738
self.grid = '%s,%s,%s,%s' % (x_step, y_step, line_segment, \
742
class ScatterChart(Chart):
744
def type_to_url(self):
747
def annotated_data(self):
748
yield ('x', self.data[0])
749
yield ('y', self.data[1])
750
if len(self.data) > 2:
751
# The optional third dataset is relative sizing for point
753
yield ('marker-size', self.data[2])
756
class LineChart(Chart):
758
def __init__(self, *args, **kwargs):
759
if type(self) == LineChart:
760
raise AbstractClassException('This is an abstract class')
761
Chart.__init__(self, *args, **kwargs)
764
class SimpleLineChart(LineChart):
766
def type_to_url(self):
769
def annotated_data(self):
770
# All datasets are y-axis data.
771
for dataset in self.data:
775
class SparkLineChart(SimpleLineChart):
777
def type_to_url(self):
781
class XYLineChart(LineChart):
783
def type_to_url(self):
786
def annotated_data(self):
787
# Datasets alternate between x-axis, y-axis.
788
for i, dataset in enumerate(self.data):
795
class BarChart(Chart):
797
def __init__(self, *args, **kwargs):
798
if type(self) == BarChart:
799
raise AbstractClassException('This is an abstract class')
800
Chart.__init__(self, *args, **kwargs)
801
self.bar_width = None
804
def set_bar_width(self, bar_width):
805
self.bar_width = bar_width
807
def set_zero_line(self, index, zero_line):
808
self.zero_lines[index] = zero_line
810
def get_url_bits(self, data_class=None, skip_chbh=False):
811
url_bits = Chart.get_url_bits(self, data_class=data_class)
812
if not skip_chbh and self.bar_width is not None:
813
url_bits.append('chbh=%i' % self.bar_width)
816
for index in xrange(max(self.zero_lines) + 1):
817
if index in self.zero_lines:
818
zero_line.append(str(self.zero_lines[index]))
820
zero_line.append('0')
821
url_bits.append('chp=%s' % ','.join(zero_line))
825
class StackedHorizontalBarChart(BarChart):
827
def type_to_url(self):
831
class StackedVerticalBarChart(BarChart):
833
def type_to_url(self):
836
def annotated_data(self):
837
for dataset in self.data:
841
class GroupedBarChart(BarChart):
843
def __init__(self, *args, **kwargs):
844
if type(self) == GroupedBarChart:
845
raise AbstractClassException('This is an abstract class')
846
BarChart.__init__(self, *args, **kwargs)
847
self.bar_spacing = None
848
self.group_spacing = None
850
def set_bar_spacing(self, spacing):
851
"""Set spacing between bars in a group."""
852
self.bar_spacing = spacing
854
def set_group_spacing(self, spacing):
855
"""Set spacing between groups of bars."""
856
self.group_spacing = spacing
858
def get_url_bits(self, data_class=None):
859
# Skip 'BarChart.get_url_bits' and call Chart directly so the parent
860
# doesn't add "chbh" before we do.
861
url_bits = BarChart.get_url_bits(self, data_class=data_class,
863
if self.group_spacing is not None:
864
if self.bar_spacing is None:
865
raise InvalidParametersException('Bar spacing is required ' \
866
'to be set when setting group spacing')
867
if self.bar_width is None:
868
raise InvalidParametersException('Bar width is required to ' \
869
'be set when setting bar spacing')
870
url_bits.append('chbh=%i,%i,%i'
871
% (self.bar_width, self.bar_spacing, self.group_spacing))
872
elif self.bar_spacing is not None:
873
if self.bar_width is None:
874
raise InvalidParametersException('Bar width is required to ' \
875
'be set when setting bar spacing')
876
url_bits.append('chbh=%i,%i' % (self.bar_width, self.bar_spacing))
878
url_bits.append('chbh=%i' % self.bar_width)
882
class GroupedHorizontalBarChart(GroupedBarChart):
884
def type_to_url(self):
888
class GroupedVerticalBarChart(GroupedBarChart):
890
def type_to_url(self):
893
def annotated_data(self):
894
for dataset in self.data:
898
class PieChart(Chart):
900
def __init__(self, *args, **kwargs):
901
if type(self) == PieChart:
902
raise AbstractClassException('This is an abstract class')
903
Chart.__init__(self, *args, **kwargs)
906
warnings.warn('y_range is not used with %s.' % \
907
(self.__class__.__name__))
909
def set_pie_labels(self, labels):
910
self.pie_labels = [urllib.quote(a) for a in labels]
912
def get_url_bits(self, data_class=None):
913
url_bits = Chart.get_url_bits(self, data_class=data_class)
915
url_bits.append('chl=%s' % '%7c'.join(self.pie_labels))
918
def annotated_data(self):
919
# Datasets are all y-axis data. However, there should only be
920
# one dataset for pie charts.
921
for dataset in self.data:
924
def scaled_data(self, data_class, x_range=None, y_range=None):
926
x_range = [0, sum(self.data[0])]
927
return Chart.scaled_data(self, data_class, x_range, self.y_range)
930
class PieChart2D(PieChart):
932
def type_to_url(self):
936
class PieChart3D(PieChart):
938
def type_to_url(self):
942
class VennChart(Chart):
944
def type_to_url(self):
947
def annotated_data(self):
948
for dataset in self.data:
952
class RadarChart(Chart):
954
def type_to_url(self):
958
class SplineRadarChart(RadarChart):
960
def type_to_url(self):
964
class MapChart(Chart):
966
def __init__(self, *args, **kwargs):
967
Chart.__init__(self, *args, **kwargs)
968
self.geo_area = 'world'
970
self.__areas = ('africa', 'asia', 'europe', 'middle_east',
971
'south_america', 'usa', 'world')
973
'AD', 'AE', 'AF', 'AG', 'AI', 'AL', 'AM', 'AN', 'AO', 'AQ', 'AR',
974
'AS', 'AT', 'AU', 'AW', 'AX', 'AZ', 'BA', 'BB', 'BD', 'BE', 'BF',
975
'BG', 'BH', 'BI', 'BJ', 'BL', 'BM', 'BN', 'BO', 'BR', 'BS', 'BT',
976
'BV', 'BW', 'BY', 'BZ', 'CA', 'CC', 'CD', 'CF', 'CG', 'CH', 'CI',
977
'CK', 'CL', 'CM', 'CN', 'CO', 'CR', 'CU', 'CV', 'CX', 'CY', 'CZ',
978
'DE', 'DJ', 'DK', 'DM', 'DO', 'DZ', 'EC', 'EE', 'EG', 'EH', 'ER',
979
'ES', 'ET', 'FI', 'FJ', 'FK', 'FM', 'FO', 'FR', 'GA', 'GB', 'GD',
980
'GE', 'GF', 'GG', 'GH', 'GI', 'GL', 'GM', 'GN', 'GP', 'GQ', 'GR',
981
'GS', 'GT', 'GU', 'GW', 'GY', 'HK', 'HM', 'HN', 'HR', 'HT', 'HU',
982
'ID', 'IE', 'IL', 'IM', 'IN', 'IO', 'IQ', 'IR', 'IS', 'IT', 'JE',
983
'JM', 'JO', 'JP', 'KE', 'KG', 'KH', 'KI', 'KM', 'KN', 'KP', 'KR',
984
'KW', 'KY', 'KZ', 'LA', 'LB', 'LC', 'LI', 'LK', 'LR', 'LS', 'LT',
985
'LU', 'LV', 'LY', 'MA', 'MC', 'MD', 'ME', 'MF', 'MG', 'MH', 'MK',
986
'ML', 'MM', 'MN', 'MO', 'MP', 'MQ', 'MR', 'MS', 'MT', 'MU', 'MV',
987
'MW', 'MX', 'MY', 'MZ', 'NA', 'NC', 'NE', 'NF', 'NG', 'NI', 'NL',
988
'NO', 'NP', 'NR', 'NU', 'NZ', 'OM', 'PA', 'PE', 'PF', 'PG', 'PH',
989
'PK', 'PL', 'PM', 'PN', 'PR', 'PS', 'PT', 'PW', 'PY', 'QA', 'RE',
990
'RO', 'RS', 'RU', 'RW', 'SA', 'SB', 'SC', 'SD', 'SE', 'SG', 'SH',
991
'SI', 'SJ', 'SK', 'SL', 'SM', 'SN', 'SO', 'SR', 'ST', 'SV', 'SY',
992
'SZ', 'TC', 'TD', 'TF', 'TG', 'TH', 'TJ', 'TK', 'TL', 'TM', 'TN',
993
'TO', 'TR', 'TT', 'TV', 'TW', 'TZ', 'UA', 'UG', 'UM', 'US', 'UY',
994
'UZ', 'VA', 'VC', 'VE', 'VG', 'VI', 'VN', 'VU', 'WF', 'WS', 'YE',
995
'YT', 'ZA', 'ZM', 'ZW')
997
def type_to_url(self):
1000
def set_codes(self, codes):
1001
'''Set the country code map for the data.
1002
Codes given in a list.
1013
if cc in self.__ccodes:
1016
raise UnknownCountryCodeException(cc)
1018
self.codes = codemap
1020
def set_geo_area(self, area):
1021
'''Sets the geo area for the map.
1032
if area in self.__areas:
1033
self.geo_area = area
1035
raise UnknownChartType('Unknown chart type for maps: %s' %area)
1037
def get_url_bits(self, data_class=None):
1038
url_bits = Chart.get_url_bits(self, data_class=data_class)
1039
url_bits.append('chtm=%s' % self.geo_area)
1041
url_bits.append('chld=%s' % ''.join(self.codes))
1044
def add_data_dict(self, datadict):
1045
'''Sets the data and country codes via a dictionary.
1047
i.e. {'DE': 50, 'GB': 30, 'AT': 70}
1050
self.set_codes(datadict.keys())
1051
self.add_data(datadict.values())
1054
class GoogleOMeterChart(PieChart):
1055
"""Inheriting from PieChart because of similar labeling"""
1057
def __init__(self, *args, **kwargs):
1058
PieChart.__init__(self, *args, **kwargs)
1059
if self.auto_scale and not self.x_range:
1060
warnings.warn('Please specify an x_range with GoogleOMeterChart, '
1061
'otherwise one arrow will always be at the max.')
1063
def type_to_url(self):
1067
class QRChart(Chart):
1069
def __init__(self, *args, **kwargs):
1070
Chart.__init__(self, *args, **kwargs)
1071
self.encoding = None
1072
self.ec_level = None
1075
def type_to_url(self):
1078
def data_to_url(self, data_class=None):
1080
raise NoDataGivenException()
1081
return 'chl=%s' % urllib.quote(self.data[0])
1083
def get_url_bits(self, data_class=None):
1084
url_bits = Chart.get_url_bits(self, data_class=data_class)
1086
url_bits.append('choe=%s' % self.encoding)
1088
url_bits.append('chld=%s%%7c%s' % (self.ec_level, self.margin))
1091
def set_encoding(self, encoding):
1092
self.encoding = encoding
1094
def set_ec(self, level, margin):
1095
self.ec_level = level
1096
self.margin = margin
1099
class ChartGrammar(object):
1105
def parse(self, grammar):
1106
self.grammar = grammar
1107
self.chart = self.create_chart_instance()
1109
for attr in self.grammar:
1110
if attr in ('w', 'h', 'type', 'auto_scale', 'x_range', 'y_range'):
1111
continue # These are already parsed in create_chart_instance
1112
attr_func = 'parse_' + attr
1113
if not hasattr(self, attr_func):
1114
warnings.warn('No parser for grammar attribute "%s"' % (attr))
1116
getattr(self, attr_func)(grammar[attr])
1120
def parse_data(self, data):
1121
self.chart.data = data
1124
def get_possible_chart_types():
1125
possible_charts = []
1126
for cls_name in globals().keys():
1127
if not cls_name.endswith('Chart'):
1129
cls = globals()[cls_name]
1130
# Check if it is an abstract class
1132
a = cls(1, 1, auto_scale=False)
1134
except AbstractClassException:
1137
possible_charts.append(cls_name[:-5])
1138
return possible_charts
1140
def create_chart_instance(self, grammar=None):
1142
grammar = self.grammar
1143
assert(isinstance(grammar, dict)) # grammar must be a dict
1144
assert('w' in grammar) # width is required
1145
assert('h' in grammar) # height is required
1146
assert('type' in grammar) # type is required
1147
chart_type = grammar['type']
1150
auto_scale = grammar.get('auto_scale', None)
1151
x_range = grammar.get('x_range', None)
1152
y_range = grammar.get('y_range', None)
1153
types = ChartGrammar.get_possible_chart_types()
1154
if chart_type not in types:
1155
raise UnknownChartType('%s is an unknown chart type. Possible '
1156
'chart types are %s' % (chart_type, ','.join(types)))
1157
return globals()[chart_type + 'Chart'](w, h, auto_scale=auto_scale,
1158
x_range=x_range, y_range=y_range)