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'
87
max_value = self.max_value()
89
for data in self.data:
94
elif value >= 0 and value <= max_value:
95
sub_data.append(SimpleData.enc_map[value])
97
raise DataOutOfRangeException('cannot encode value: %d'
99
encoded_data.append(''.join(sub_data))
100
return 'chd=s:' + ','.join(encoded_data)
107
def scale_value(cls, value, range):
109
max_value = cls.max_value()
110
scaled = int(round((float(value) - lower) * max_value / upper))
111
clipped = max(0, min(scaled, max_value))
114
class TextData(Data):
117
max_value = self.max_value()
119
for data in self.data:
124
elif value >= 0 and value <= max_value:
125
sub_data.append("%.1f" % float(value))
127
raise DataOutOfRangeException()
128
encoded_data.append(','.join(sub_data))
129
return 'chd=t:' + '|'.join(encoded_data)
136
def scale_value(cls, value, range):
138
max_value = cls.max_value()
139
scaled = (float(value) - lower) * max_value / upper
140
clipped = max(0, min(scaled, max_value))
143
class ExtendedData(Data):
145
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-.'
148
max_value = self.max_value()
150
enc_size = len(ExtendedData.enc_map)
151
for data in self.data:
155
sub_data.append('__')
156
elif value >= 0 and value <= max_value:
157
first, second = divmod(int(value), enc_size)
158
sub_data.append('%s%s' % (
159
ExtendedData.enc_map[first],
160
ExtendedData.enc_map[second]))
162
raise DataOutOfRangeException( \
163
'Item #%i "%s" is out of range' % (data.index(value), \
165
encoded_data.append(''.join(sub_data))
166
return 'chd=e:' + ','.join(encoded_data)
173
def scale_value(cls, value, range):
175
max_value = cls.max_value()
176
scaled = int(round((float(value) - lower) * max_value / upper))
177
clipped = max(0, min(scaled, max_value))
182
# -----------------------------------------------------------------------------
190
TYPES = (BOTTOM, TOP, LEFT, RIGHT)
192
def __init__(self, axis_index, axis_type, **kw):
193
assert(axis_type in Axis.TYPES)
194
self.has_style = False
195
self.axis_index = axis_index
196
self.axis_type = axis_type
197
self.positions = None
199
def set_index(self, axis_index):
200
self.axis_index = axis_index
202
def set_positions(self, positions):
203
self.positions = positions
205
def set_style(self, colour, font_size=None, alignment=None):
206
_check_colour(colour)
208
self.font_size = font_size
209
self.alignment = alignment
210
self.has_style = True
212
def style_to_url(self):
214
bits.append(str(self.axis_index))
215
bits.append(self.colour)
216
if self.font_size is not None:
217
bits.append(str(self.font_size))
218
if self.alignment is not None:
219
bits.append(str(self.alignment))
220
return ','.join(bits)
222
def positions_to_url(self):
224
bits.append(str(self.axis_index))
225
bits += [str(a) for a in self.positions]
226
return ','.join(bits)
229
class LabelAxis(Axis):
231
def __init__(self, axis_index, axis_type, values, **kwargs):
232
Axis.__init__(self, axis_index, axis_type, **kwargs)
233
self.values = [str(a) for a in values]
236
return '%i:|%s' % (self.axis_index, '|'.join(self.values))
239
class RangeAxis(Axis):
241
def __init__(self, axis_index, axis_type, low, high, **kwargs):
242
Axis.__init__(self, axis_index, axis_type, **kwargs)
247
return '%i,%s,%s' % (self.axis_index, self.low, self.high)
250
# -----------------------------------------------------------------------------
254
"""Abstract class for all chart types.
256
width are height specify the dimensions of the image. title sets the title
257
of the chart. legend requires a list that corresponds to datasets.
260
BASE_URL = 'http://chart.apis.google.com/chart?'
264
LINEAR_GRADIENT = 'lg'
265
LINEAR_STRIPES = 'ls'
267
def __init__(self, width, height, title=None, legend=None, colours=None,
268
auto_scale=True, x_range=None, y_range=None):
269
assert(type(self) != Chart) # This is an abstract class
270
assert(isinstance(width, int))
271
assert(isinstance(height, int))
275
self.set_title(title)
276
self.set_legend(legend)
277
self.set_colours(colours)
280
self.auto_scale = auto_scale # Whether to automatically scale data
281
self.x_range = x_range # (min, max) x-axis range for scaling
282
self.y_range = y_range # (min, max) y-axis range for scaling
283
self.scaled_data_class = None
284
self.scaled_x_range = None
285
self.scaled_y_range = None
288
Chart.BACKGROUND: None,
292
Chart.BACKGROUND: None,
305
# -------------------------------------------------------------------------
308
url_bits = self.get_url_bits()
309
return self.BASE_URL + '&'.join(url_bits)
311
def get_url_bits(self):
314
url_bits.append(self.type_to_url())
315
url_bits.append('chs=%ix%i' % (self.width, self.height))
316
url_bits.append(self.data_to_url())
319
url_bits.append('chtt=%s' % self.title)
321
url_bits.append('chdl=%s' % '|'.join(self.legend))
323
url_bits.append('chco=%s' % ','.join(self.colours))
324
ret = self.fill_to_url()
327
ret = self.axis_to_url()
331
url_bits.append(self.markers_to_url())
335
# -------------------------------------------------------------------------
337
def download(self, file_name):
338
opener = urllib2.urlopen(self.get_url())
340
if opener.headers['content-type'] != 'image/png':
341
raise BadContentTypeException('Server responded with a ' \
342
'content-type of %s' % opener.headers['content-type'])
344
open(file_name, 'wb').write(urllib.urlopen(self.get_url()).read())
347
# -------------------------------------------------------------------------
349
def set_title(self, title):
351
self.title = urllib.quote(title)
355
def set_legend(self, legend):
356
# legend needs to be a list, tuple or None
357
assert(isinstance(legend, list) or isinstance(legend, tuple) or
360
self.legend = [urllib.quote(a) for a in legend]
365
# -------------------------------------------------------------------------
367
def set_colours(self, colours):
368
# colours needs to be a list, tuple or None
369
assert(isinstance(colours, list) or isinstance(colours, tuple) or
371
# make sure the colours are in the right format
375
self.colours = colours
377
# Background/Chart colours
378
# -------------------------------------------------------------------------
380
def fill_solid(self, area, colour):
381
assert(area in (Chart.BACKGROUND, Chart.CHART))
382
_check_colour(colour)
383
self.fill_area[area] = colour
384
self.fill_types[area] = Chart.SOLID
386
def _check_fill_linear(self, angle, *args):
387
assert(isinstance(args, list) or isinstance(args, tuple))
388
assert(angle >= 0 and angle <= 90)
389
assert(len(args) % 2 == 0)
390
args = list(args) # args is probably a tuple and we need to mutate
391
for a in xrange(len(args) / 2):
393
offset = args[a * 2 + 1]
395
assert(offset >= 0 and offset <= 1)
396
args[a * 2 + 1] = str(args[a * 2 + 1])
399
def fill_linear_gradient(self, area, angle, *args):
400
assert(area in (Chart.BACKGROUND, Chart.CHART))
401
args = self._check_fill_linear(angle, *args)
402
self.fill_types[area] = Chart.LINEAR_GRADIENT
403
self.fill_area[area] = ','.join([str(angle)] + args)
405
def fill_linear_stripes(self, area, angle, *args):
406
assert(area in (Chart.BACKGROUND, Chart.CHART))
407
args = self._check_fill_linear(angle, *args)
408
self.fill_types[area] = Chart.LINEAR_STRIPES
409
self.fill_area[area] = ','.join([str(angle)] + args)
411
def fill_to_url(self):
413
for area in (Chart.BACKGROUND, Chart.CHART):
414
if self.fill_types[area]:
415
areas.append('%s,%s,%s' % (area, self.fill_types[area], \
416
self.fill_area[area]))
418
return 'chf=' + '|'.join(areas)
421
# -------------------------------------------------------------------------
423
def data_class_detection(self, data):
424
"""Determines the appropriate data encoding type to give satisfactory
425
resolution (http://code.google.com/apis/chart/#chart_data).
427
assert(isinstance(data, list) or isinstance(data, tuple))
428
if not isinstance(self, (LineChart, BarChart, ScatterChart)):
429
# From the link above:
430
# Simple encoding is suitable for all other types of chart
431
# regardless of size.
433
elif self.height < 100:
434
# The link above indicates that line and bar charts less
435
# than 300px in size can be suitably represented with the
436
# simple encoding. I've found that this isn't sufficient,
437
# e.g. examples/line-xy-circle.png. Let's try 100px.
439
elif self.height < 500:
444
def data_x_range(self):
445
"""Return a 2-tuple giving the minimum and maximum x-axis
449
lower = min([min(s) for type, s in self.annotated_data()
451
upper = max([max(s) for type, s in self.annotated_data()
453
return (lower, upper)
455
return None # no x-axis datasets
457
def data_y_range(self):
458
"""Return a 2-tuple giving the minimum and maximum y-axis
462
lower = min([min(s) for type, s in self.annotated_data()
464
upper = max([max(s) for type, s in self.annotated_data()
466
return (lower, upper)
468
return None # no y-axis datasets
470
def scaled_data(self, data_class, x_range=None, y_range=None):
471
"""Scale `self.data` as appropriate for the given data encoding
472
(data_class) and return it.
474
An optional `y_range` -- a 2-tuple (lower, upper) -- can be
475
given to specify the y-axis bounds. If not given, the range is
476
inferred from the data: (0, <max-value>) presuming no negative
477
values, or (<min-value>, <max-value>) if there are negative
478
values. `self.scaled_y_range` is set to the actual lower and
481
Ditto for `x_range`. Note that some chart types don't have x-axis
484
self.scaled_data_class = data_class
486
# Determine the x-axis range for scaling.
488
x_range = self.data_x_range()
489
if x_range and x_range[0] > 0:
490
x_range = (0, x_range[1])
491
self.scaled_x_range = x_range
493
# Determine the y-axis range for scaling.
495
y_range = self.data_y_range()
496
if y_range and y_range[0] > 0:
497
y_range = (0, y_range[1])
498
self.scaled_y_range = y_range
501
for type, dataset in self.annotated_data():
503
scale_range = x_range
505
scale_range = y_range
506
elif type == 'marker-size':
507
scale_range = (0, max(dataset))
508
scaled_data.append([data_class.scale_value(v, scale_range)
512
def add_data(self, data):
513
self.data.append(data)
514
return len(self.data) - 1 # return the "index" of the data set
516
def data_to_url(self, data_class=None):
518
data_class = self.data_class_detection(self.data)
519
if not issubclass(data_class, Data):
520
raise UnknownDataTypeException()
522
data = self.scaled_data(data_class, self.x_range, self.y_range)
525
return repr(data_class(data))
528
# -------------------------------------------------------------------------
530
def set_axis_labels(self, axis_type, values):
531
assert(axis_type in Axis.TYPES)
532
values = [ urllib.quote(a) for a in values ]
533
axis_index = len(self.axis)
534
axis = LabelAxis(axis_index, axis_type, values)
535
self.axis.append(axis)
538
def set_axis_range(self, axis_type, low, high):
539
assert(axis_type in Axis.TYPES)
540
axis_index = len(self.axis)
541
axis = RangeAxis(axis_index, axis_type, low, high)
542
self.axis.append(axis)
545
def set_axis_positions(self, axis_index, positions):
547
self.axis[axis_index].set_positions(positions)
549
raise InvalidParametersException('Axis index %i has not been ' \
552
def set_axis_style(self, axis_index, colour, font_size=None, \
555
self.axis[axis_index].set_style(colour, font_size, alignment)
557
raise InvalidParametersException('Axis index %i has not been ' \
560
def axis_to_url(self):
567
for axis in self.axis:
568
available_axis.append(axis.axis_type)
569
if isinstance(axis, RangeAxis):
570
range_axis.append(repr(axis))
571
if isinstance(axis, LabelAxis):
572
label_axis.append(repr(axis))
574
positions.append(axis.positions_to_url())
576
styles.append(axis.style_to_url())
577
if not available_axis:
580
url_bits.append('chxt=%s' % ','.join(available_axis))
582
url_bits.append('chxl=%s' % '|'.join(label_axis))
584
url_bits.append('chxr=%s' % '|'.join(range_axis))
586
url_bits.append('chxp=%s' % '|'.join(positions))
588
url_bits.append('chxs=%s' % '|'.join(styles))
589
return '&'.join(url_bits)
591
# Markers, Ranges and Fill area (chm)
592
# -------------------------------------------------------------------------
594
def markers_to_url(self):
595
return 'chm=%s' % '|'.join([','.join(a) for a in self.markers])
597
def add_marker(self, index, point, marker_type, colour, size):
598
self.markers.append((marker_type, colour, str(index), str(point), \
601
def add_horizontal_range(self, colour, start, stop):
602
self.markers.append(('r', colour, '1', str(start), str(stop)))
604
def add_vertical_range(self, colour, start, stop):
605
self.markers.append(('R', colour, '1', str(start), str(stop)))
607
def add_fill_range(self, colour, index_start, index_end):
608
self.markers.append(('b', colour, str(index_start), str(index_end), \
611
def add_fill_simple(self, colour):
612
self.markers.append(('B', colour, '1', '1', '1'))
615
class ScatterChart(Chart):
617
def type_to_url(self):
620
def annotated_data(self):
621
yield ('x', self.data[0])
622
yield ('y', self.data[1])
623
if len(self.data) > 2:
624
# The optional third dataset is relative sizing for point
626
yield ('marker-size', self.data[2])
628
class LineChart(Chart):
630
def __init__(self, *args, **kwargs):
631
assert(type(self) != LineChart) # This is an abstract class
632
Chart.__init__(self, *args, **kwargs)
633
self.line_styles = {}
636
def set_line_style(self, index, thickness=1, line_segment=None, \
639
value.append(str(thickness))
641
value.append(str(line_segment))
642
value.append(str(blank_segment))
643
self.line_styles[index] = value
645
def set_grid(self, x_step, y_step, line_segment=1, \
647
self.grid = '%s,%s,%s,%s' % (x_step, y_step, line_segment, \
650
def get_url_bits(self):
651
url_bits = Chart.get_url_bits(self)
654
# for index, values in self.line_style.items():
655
for index in xrange(max(self.line_styles) + 1):
656
if index in self.line_styles:
657
values = self.line_styles[index]
660
style.append(','.join(values))
661
url_bits.append('chls=%s' % '|'.join(style))
663
url_bits.append('chg=%s' % self.grid)
667
class SimpleLineChart(LineChart):
669
def type_to_url(self):
672
def annotated_data(self):
673
# All datasets are y-axis data.
674
for dataset in self.data:
677
class XYLineChart(LineChart):
679
def type_to_url(self):
682
def annotated_data(self):
683
# Datasets alternate between x-axis, y-axis.
684
for i, dataset in enumerate(self.data):
690
class BarChart(Chart):
692
def __init__(self, *args, **kwargs):
693
assert(type(self) != BarChart) # This is an abstract class
694
Chart.__init__(self, *args, **kwargs)
695
self.bar_width = None
697
def set_bar_width(self, bar_width):
698
self.bar_width = bar_width
700
def get_url_bits(self):
701
url_bits = Chart.get_url_bits(self)
702
if self.bar_width is not None:
703
url_bits.append('chbh=%i' % self.bar_width)
707
class StackedHorizontalBarChart(BarChart):
709
def type_to_url(self):
712
def annotated_data(self):
713
for dataset in self.data:
716
class StackedVerticalBarChart(BarChart):
718
def type_to_url(self):
721
def annotated_data(self):
722
for dataset in self.data:
726
class GroupedBarChart(BarChart):
728
def __init__(self, *args, **kwargs):
729
assert(type(self) != GroupedBarChart) # This is an abstract class
730
BarChart.__init__(self, *args, **kwargs)
731
self.bar_spacing = None
732
self.group_spacing = None
734
def set_bar_spacing(self, spacing):
735
"""Set spacing between bars in a group."""
736
self.bar_spacing = spacing
738
def set_group_spacing(self, spacing):
739
"""Set spacing between groups of bars."""
740
self.group_spacing = spacing
742
def get_url_bits(self):
743
# Skip 'BarChart.get_url_bits' and call Chart directly so the parent
744
# doesn't add "chbh" before we do.
745
url_bits = Chart.get_url_bits(self)
746
if self.group_spacing is not None:
747
if self.bar_spacing is None:
748
raise InvalidParametersException('Bar spacing is required to ' \
749
'be set when setting group spacing')
750
if self.bar_width is None:
751
raise InvalidParametersException('Bar width is required to ' \
752
'be set when setting bar spacing')
753
url_bits.append('chbh=%i,%i,%i'
754
% (self.bar_width, self.bar_spacing, self.group_spacing))
755
elif self.bar_spacing is not None:
756
if self.bar_width is None:
757
raise InvalidParametersException('Bar width is required to ' \
758
'be set when setting bar spacing')
759
url_bits.append('chbh=%i,%i' % (self.bar_width, self.bar_spacing))
761
url_bits.append('chbh=%i' % self.bar_width)
765
class GroupedHorizontalBarChart(GroupedBarChart):
767
def type_to_url(self):
770
def annotated_data(self):
771
for dataset in self.data:
775
class GroupedVerticalBarChart(GroupedBarChart):
777
def type_to_url(self):
780
def annotated_data(self):
781
for dataset in self.data:
785
class PieChart(Chart):
787
def __init__(self, *args, **kwargs):
788
assert(type(self) != PieChart) # This is an abstract class
789
Chart.__init__(self, *args, **kwargs)
792
def set_pie_labels(self, labels):
793
self.pie_labels = [urllib.quote(a) for a in labels]
795
def get_url_bits(self):
796
url_bits = Chart.get_url_bits(self)
798
url_bits.append('chl=%s' % '|'.join(self.pie_labels))
801
def annotated_data(self):
802
# Datasets are all y-axis data. However, there should only be
803
# one dataset for pie charts.
804
for dataset in self.data:
808
class PieChart2D(PieChart):
810
def type_to_url(self):
814
class PieChart3D(PieChart):
816
def type_to_url(self):
820
class VennChart(Chart):
822
def type_to_url(self):
825
def annotated_data(self):
826
for dataset in self.data:
831
chart = GroupedVerticalBarChart(320, 200)
832
chart = PieChart2D(320, 200)
833
chart = ScatterChart(320, 200)
834
chart = SimpleLineChart(320, 200)
835
sine_data = [math.sin(float(a) / 10) * 2000 + 2000 for a in xrange(100)]
836
random_data = [a * random.random() * 30 for a in xrange(40)]
837
random_data2 = [random.random() * 4000 for a in xrange(10)]
838
# chart.set_bar_width(50)
839
# chart.set_bar_spacing(0)
840
chart.add_data(sine_data)
841
chart.add_data(random_data)
842
chart.add_data(random_data2)
843
# chart.set_line_style(1, thickness=2)
844
# chart.set_line_style(2, line_segment=10, blank_segment=5)
845
# chart.set_title('heloooo')
846
# chart.set_legend(('sine wave', 'random * x'))
847
# chart.set_colours(('ee2000', 'DDDDAA', 'fF03f2'))
848
# chart.fill_solid(Chart.BACKGROUND, '123456')
849
# chart.fill_linear_gradient(Chart.CHART, 20, '004070', 1, '300040', 0,
851
# chart.fill_linear_stripes(Chart.CHART, 20, '204070', .2, '300040', .2,
853
axis_left_index = chart.set_axis_range(Axis.LEFT, 0, 10)
854
axis_left_index = chart.set_axis_range(Axis.LEFT, 0, 10)
855
axis_left_index = chart.set_axis_range(Axis.LEFT, 0, 10)
856
axis_right_index = chart.set_axis_range(Axis.RIGHT, 5, 30)
857
axis_bottom_index = chart.set_axis_labels(Axis.BOTTOM, [1, 25, 95])
858
chart.set_axis_positions(axis_bottom_index, [1, 25, 95])
859
chart.set_axis_style(axis_bottom_index, '003050', 15)
861
# chart.set_pie_labels(('apples', 'oranges', 'bananas'))
863
# chart.set_grid(10, 10)
865
# for a in xrange(0, 100, 10):
866
# chart.add_marker(1, a, 'a', 'AACA20', 10)
868
chart.add_horizontal_range('00A020', .2, .5)
869
chart.add_vertical_range('00c030', .2, .4)
871
chart.add_fill_simple('303030A0')
873
chart.download('test.png')
875
url = chart.get_url()
878
data = urllib.urlopen(chart.get_url()).read()
879
open('meh.png', 'wb').write(data)
880
os.system('start meh.png')
883
if __name__ == '__main__':
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'
87
max_value = self.max_value()
89
for data in self.data:
94
elif value >= 0 and value <= max_value:
95
sub_data.append(SimpleData.enc_map[value])
97
raise DataOutOfRangeException('cannot encode value: %d'
99
encoded_data.append(''.join(sub_data))
100
return 'chd=s:' + ','.join(encoded_data)
107
def scale_value(cls, value, range):
109
max_value = cls.max_value()
110
scaled = int(round((float(value) - lower) * max_value / upper))
111
clipped = max(0, min(scaled, max_value))
114
class TextData(Data):
117
max_value = self.max_value()
119
for data in self.data:
124
elif value >= 0 and value <= max_value:
125
sub_data.append("%.1f" % float(value))
127
raise DataOutOfRangeException()
128
encoded_data.append(','.join(sub_data))
129
return 'chd=t:' + '|'.join(encoded_data)
136
def scale_value(cls, value, range):
138
max_value = cls.max_value()
139
scaled = (float(value) - lower) * max_value / upper
140
clipped = max(0, min(scaled, max_value))
143
class ExtendedData(Data):
145
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-.'
148
max_value = self.max_value()
150
enc_size = len(ExtendedData.enc_map)
151
for data in self.data:
155
sub_data.append('__')
156
elif value >= 0 and value <= max_value:
157
first, second = divmod(int(value), enc_size)
158
sub_data.append('%s%s' % (
159
ExtendedData.enc_map[first],
160
ExtendedData.enc_map[second]))
162
raise DataOutOfRangeException( \
163
'Item #%i "%s" is out of range' % (data.index(value), \
165
encoded_data.append(''.join(sub_data))
166
return 'chd=e:' + ','.join(encoded_data)
173
def scale_value(cls, value, range):
175
max_value = cls.max_value()
176
scaled = int(round((float(value) - lower) * max_value / upper))
177
clipped = max(0, min(scaled, max_value))
182
# -----------------------------------------------------------------------------
190
TYPES = (BOTTOM, TOP, LEFT, RIGHT)
192
def __init__(self, axis_index, axis_type, **kw):
193
assert(axis_type in Axis.TYPES)
194
self.has_style = False
195
self.axis_index = axis_index
196
self.axis_type = axis_type
197
self.positions = None
199
def set_index(self, axis_index):
200
self.axis_index = axis_index
202
def set_positions(self, positions):
203
self.positions = positions
205
def set_style(self, colour, font_size=None, alignment=None):
206
_check_colour(colour)
208
self.font_size = font_size
209
self.alignment = alignment
210
self.has_style = True
212
def style_to_url(self):
214
bits.append(str(self.axis_index))
215
bits.append(self.colour)
216
if self.font_size is not None:
217
bits.append(str(self.font_size))
218
if self.alignment is not None:
219
bits.append(str(self.alignment))
220
return ','.join(bits)
222
def positions_to_url(self):
224
bits.append(str(self.axis_index))
225
bits += [str(a) for a in self.positions]
226
return ','.join(bits)
229
class LabelAxis(Axis):
231
def __init__(self, axis_index, axis_type, values, **kwargs):
232
Axis.__init__(self, axis_index, axis_type, **kwargs)
233
self.values = [str(a) for a in values]
236
return '%i:|%s' % (self.axis_index, '|'.join(self.values))
239
class RangeAxis(Axis):
241
def __init__(self, axis_index, axis_type, low, high, **kwargs):
242
Axis.__init__(self, axis_index, axis_type, **kwargs)
247
return '%i,%s,%s' % (self.axis_index, self.low, self.high)
250
# -----------------------------------------------------------------------------
254
"""Abstract class for all chart types.
256
width are height specify the dimensions of the image. title sets the title
257
of the chart. legend requires a list that corresponds to datasets.
260
BASE_URL = 'http://chart.apis.google.com/chart?'
264
LINEAR_GRADIENT = 'lg'
265
LINEAR_STRIPES = 'ls'
267
def __init__(self, width, height, title=None, legend=None, colours=None,
268
auto_scale=True, x_range=None, y_range=None):
269
assert(type(self) != Chart) # This is an abstract class
270
assert(isinstance(width, int))
271
assert(isinstance(height, int))
275
self.set_title(title)
276
self.set_legend(legend)
277
self.set_colours(colours)
280
self.auto_scale = auto_scale # Whether to automatically scale data
281
self.x_range = x_range # (min, max) x-axis range for scaling
282
self.y_range = y_range # (min, max) y-axis range for scaling
283
self.scaled_data_class = None
284
self.scaled_x_range = None
285
self.scaled_y_range = None
288
Chart.BACKGROUND: None,
292
Chart.BACKGROUND: None,
299
# -------------------------------------------------------------------------
302
url_bits = self.get_url_bits()
303
return self.BASE_URL + '&'.join(url_bits)
305
def get_url_bits(self):
308
url_bits.append(self.type_to_url())
309
url_bits.append('chs=%ix%i' % (self.width, self.height))
310
url_bits.append(self.data_to_url())
313
url_bits.append('chtt=%s' % self.title)
315
url_bits.append('chdl=%s' % '|'.join(self.legend))
317
url_bits.append('chco=%s' % ','.join(self.colours))
318
ret = self.fill_to_url()
321
ret = self.axis_to_url()
325
url_bits.append(self.markers_to_url())
329
# -------------------------------------------------------------------------
331
def download(self, file_name):
332
opener = urllib2.urlopen(self.get_url())
334
if opener.headers['content-type'] != 'image/png':
335
raise BadContentTypeException('Server responded with a ' \
336
'content-type of %s' % opener.headers['content-type'])
338
open(file_name, 'wb').write(urllib.urlopen(self.get_url()).read())
341
# -------------------------------------------------------------------------
343
def set_title(self, title):
345
self.title = urllib.quote(title)
349
def set_legend(self, legend):
350
"""legend needs to be a list, tuple or None"""
351
assert(isinstance(legend, list) or isinstance(legend, tuple) or
354
self.legend = [urllib.quote(a) for a in legend]
359
# -------------------------------------------------------------------------
361
def set_colours(self, colours):
362
# colours needs to be a list, tuple or None
363
assert(isinstance(colours, list) or isinstance(colours, tuple) or
365
# make sure the colours are in the right format
369
self.colours = colours
371
# Background/Chart colours
372
# -------------------------------------------------------------------------
374
def fill_solid(self, area, colour):
375
assert(area in (Chart.BACKGROUND, Chart.CHART))
376
_check_colour(colour)
377
self.fill_area[area] = colour
378
self.fill_types[area] = Chart.SOLID
380
def _check_fill_linear(self, angle, *args):
381
assert(isinstance(args, list) or isinstance(args, tuple))
382
assert(angle >= 0 and angle <= 90)
383
assert(len(args) % 2 == 0)
384
args = list(args) # args is probably a tuple and we need to mutate
385
for a in xrange(len(args) / 2):
387
offset = args[a * 2 + 1]
389
assert(offset >= 0 and offset <= 1)
390
args[a * 2 + 1] = str(args[a * 2 + 1])
393
def fill_linear_gradient(self, area, angle, *args):
394
assert(area in (Chart.BACKGROUND, Chart.CHART))
395
args = self._check_fill_linear(angle, *args)
396
self.fill_types[area] = Chart.LINEAR_GRADIENT
397
self.fill_area[area] = ','.join([str(angle)] + args)
399
def fill_linear_stripes(self, area, angle, *args):
400
assert(area in (Chart.BACKGROUND, Chart.CHART))
401
args = self._check_fill_linear(angle, *args)
402
self.fill_types[area] = Chart.LINEAR_STRIPES
403
self.fill_area[area] = ','.join([str(angle)] + args)
405
def fill_to_url(self):
407
for area in (Chart.BACKGROUND, Chart.CHART):
408
if self.fill_types[area]:
409
areas.append('%s,%s,%s' % (area, self.fill_types[area], \
410
self.fill_area[area]))
412
return 'chf=' + '|'.join(areas)
415
# -------------------------------------------------------------------------
417
def data_class_detection(self, data):
418
"""Determines the appropriate data encoding type to give satisfactory
419
resolution (http://code.google.com/apis/chart/#chart_data).
421
assert(isinstance(data, list) or isinstance(data, tuple))
422
if not isinstance(self, (LineChart, BarChart, ScatterChart)):
423
# From the link above:
424
# Simple encoding is suitable for all other types of chart
425
# regardless of size.
427
elif self.height < 100:
428
# The link above indicates that line and bar charts less
429
# than 300px in size can be suitably represented with the
430
# simple encoding. I've found that this isn't sufficient,
431
# e.g. examples/line-xy-circle.png. Let's try 100px.
433
elif self.height < 500:
438
def data_x_range(self):
439
"""Return a 2-tuple giving the minimum and maximum x-axis
443
lower = min([min(s) for type, s in self.annotated_data()
445
upper = max([max(s) for type, s in self.annotated_data()
447
return (lower, upper)
449
return None # no x-axis datasets
451
def data_y_range(self):
452
"""Return a 2-tuple giving the minimum and maximum y-axis
456
lower = min([min(s) for type, s in self.annotated_data()
458
upper = max([max(s) for type, s in self.annotated_data()
460
return (lower, upper)
462
return None # no y-axis datasets
464
def scaled_data(self, data_class, x_range=None, y_range=None):
465
"""Scale `self.data` as appropriate for the given data encoding
466
(data_class) and return it.
468
An optional `y_range` -- a 2-tuple (lower, upper) -- can be
469
given to specify the y-axis bounds. If not given, the range is
470
inferred from the data: (0, <max-value>) presuming no negative
471
values, or (<min-value>, <max-value>) if there are negative
472
values. `self.scaled_y_range` is set to the actual lower and
475
Ditto for `x_range`. Note that some chart types don't have x-axis
478
self.scaled_data_class = data_class
480
# Determine the x-axis range for scaling.
482
x_range = self.data_x_range()
483
if x_range and x_range[0] > 0:
484
x_range = (0, x_range[1])
485
self.scaled_x_range = x_range
487
# Determine the y-axis range for scaling.
489
y_range = self.data_y_range()
490
if y_range and y_range[0] > 0:
491
y_range = (0, y_range[1])
492
self.scaled_y_range = y_range
495
for type, dataset in self.annotated_data():
497
scale_range = x_range
499
scale_range = y_range
500
elif type == 'marker-size':
501
scale_range = (0, max(dataset))
502
scaled_data.append([data_class.scale_value(v, scale_range)
506
def add_data(self, data):
507
self.data.append(data)
508
return len(self.data) - 1 # return the "index" of the data set
510
def data_to_url(self, data_class=None):
512
data_class = self.data_class_detection(self.data)
513
if not issubclass(data_class, Data):
514
raise UnknownDataTypeException()
516
data = self.scaled_data(data_class, self.x_range, self.y_range)
519
return repr(data_class(data))
522
# -------------------------------------------------------------------------
524
def set_axis_labels(self, axis_type, values):
525
assert(axis_type in Axis.TYPES)
526
values = [ urllib.quote(a) for a in values ]
527
axis_index = len(self.axis)
528
axis = LabelAxis(axis_index, axis_type, values)
529
self.axis.append(axis)
532
def set_axis_range(self, axis_type, low, high):
533
assert(axis_type in Axis.TYPES)
534
axis_index = len(self.axis)
535
axis = RangeAxis(axis_index, axis_type, low, high)
536
self.axis.append(axis)
539
def set_axis_positions(self, axis_index, positions):
541
self.axis[axis_index].set_positions(positions)
543
raise InvalidParametersException('Axis index %i has not been ' \
546
def set_axis_style(self, axis_index, colour, font_size=None, \
549
self.axis[axis_index].set_style(colour, font_size, alignment)
551
raise InvalidParametersException('Axis index %i has not been ' \
554
def axis_to_url(self):
561
for axis in self.axis:
562
available_axis.append(axis.axis_type)
563
if isinstance(axis, RangeAxis):
564
range_axis.append(repr(axis))
565
if isinstance(axis, LabelAxis):
566
label_axis.append(repr(axis))
568
positions.append(axis.positions_to_url())
570
styles.append(axis.style_to_url())
571
if not available_axis:
574
url_bits.append('chxt=%s' % ','.join(available_axis))
576
url_bits.append('chxl=%s' % '|'.join(label_axis))
578
url_bits.append('chxr=%s' % '|'.join(range_axis))
580
url_bits.append('chxp=%s' % '|'.join(positions))
582
url_bits.append('chxs=%s' % '|'.join(styles))
583
return '&'.join(url_bits)
585
# Markers, Ranges and Fill area (chm)
586
# -------------------------------------------------------------------------
588
def markers_to_url(self):
589
return 'chm=%s' % '|'.join([','.join(a) for a in self.markers])
591
def add_marker(self, index, point, marker_type, colour, size):
592
self.markers.append((marker_type, colour, str(index), str(point), \
595
def add_horizontal_range(self, colour, start, stop):
596
self.markers.append(('r', colour, '1', str(start), str(stop)))
598
def add_vertical_range(self, colour, start, stop):
599
self.markers.append(('R', colour, '1', str(start), str(stop)))
601
def add_fill_range(self, colour, index_start, index_end):
602
self.markers.append(('b', colour, str(index_start), str(index_end), \
605
def add_fill_simple(self, colour):
606
self.markers.append(('B', colour, '1', '1', '1'))
609
class ScatterChart(Chart):
611
def type_to_url(self):
614
def annotated_data(self):
615
yield ('x', self.data[0])
616
yield ('y', self.data[1])
617
if len(self.data) > 2:
618
# The optional third dataset is relative sizing for point
620
yield ('marker-size', self.data[2])
622
class LineChart(Chart):
624
def __init__(self, *args, **kwargs):
625
assert(type(self) != LineChart) # This is an abstract class
626
Chart.__init__(self, *args, **kwargs)
627
self.line_styles = {}
630
def set_line_style(self, index, thickness=1, line_segment=None, \
633
value.append(str(thickness))
635
value.append(str(line_segment))
636
value.append(str(blank_segment))
637
self.line_styles[index] = value
639
def set_grid(self, x_step, y_step, line_segment=1, \
641
self.grid = '%s,%s,%s,%s' % (x_step, y_step, line_segment, \
644
def get_url_bits(self):
645
url_bits = Chart.get_url_bits(self)
648
# for index, values in self.line_style.items():
649
for index in xrange(max(self.line_styles) + 1):
650
if index in self.line_styles:
651
values = self.line_styles[index]
654
style.append(','.join(values))
655
url_bits.append('chls=%s' % '|'.join(style))
657
url_bits.append('chg=%s' % self.grid)
661
class SimpleLineChart(LineChart):
663
def type_to_url(self):
666
def annotated_data(self):
667
# All datasets are y-axis data.
668
for dataset in self.data:
671
class SparkLineChart(SimpleLineChart):
673
def type_to_url(self):
676
class XYLineChart(LineChart):
678
def type_to_url(self):
681
def annotated_data(self):
682
# Datasets alternate between x-axis, y-axis.
683
for i, dataset in enumerate(self.data):
689
class BarChart(Chart):
691
def __init__(self, *args, **kwargs):
692
assert(type(self) != BarChart) # This is an abstract class
693
Chart.__init__(self, *args, **kwargs)
694
self.bar_width = None
696
def set_bar_width(self, bar_width):
697
self.bar_width = bar_width
699
def get_url_bits(self):
700
url_bits = Chart.get_url_bits(self)
701
if self.bar_width is not None:
702
url_bits.append('chbh=%i' % self.bar_width)
706
class StackedHorizontalBarChart(BarChart):
708
def type_to_url(self):
711
def annotated_data(self):
712
for dataset in self.data:
715
class StackedVerticalBarChart(BarChart):
717
def type_to_url(self):
720
def annotated_data(self):
721
for dataset in self.data:
725
class GroupedBarChart(BarChart):
727
def __init__(self, *args, **kwargs):
728
assert(type(self) != GroupedBarChart) # This is an abstract class
729
BarChart.__init__(self, *args, **kwargs)
730
self.bar_spacing = None
731
self.group_spacing = None
733
def set_bar_spacing(self, spacing):
734
"""Set spacing between bars in a group."""
735
self.bar_spacing = spacing
737
def set_group_spacing(self, spacing):
738
"""Set spacing between groups of bars."""
739
self.group_spacing = spacing
741
def get_url_bits(self):
742
# Skip 'BarChart.get_url_bits' and call Chart directly so the parent
743
# doesn't add "chbh" before we do.
744
url_bits = Chart.get_url_bits(self)
745
if self.group_spacing is not None:
746
if self.bar_spacing is None:
747
raise InvalidParametersException('Bar spacing is required to ' \
748
'be set when setting group spacing')
749
if self.bar_width is None:
750
raise InvalidParametersException('Bar width is required to ' \
751
'be set when setting bar spacing')
752
url_bits.append('chbh=%i,%i,%i'
753
% (self.bar_width, self.bar_spacing, self.group_spacing))
754
elif self.bar_spacing is not None:
755
if self.bar_width is None:
756
raise InvalidParametersException('Bar width is required to ' \
757
'be set when setting bar spacing')
758
url_bits.append('chbh=%i,%i' % (self.bar_width, self.bar_spacing))
760
url_bits.append('chbh=%i' % self.bar_width)
764
class GroupedHorizontalBarChart(GroupedBarChart):
766
def type_to_url(self):
769
def annotated_data(self):
770
for dataset in self.data:
774
class GroupedVerticalBarChart(GroupedBarChart):
776
def type_to_url(self):
779
def annotated_data(self):
780
for dataset in self.data:
784
class PieChart(Chart):
786
def __init__(self, *args, **kwargs):
787
assert(type(self) != PieChart) # This is an abstract class
788
Chart.__init__(self, *args, **kwargs)
791
def set_pie_labels(self, labels):
792
self.pie_labels = [urllib.quote(a) for a in labels]
794
def get_url_bits(self):
795
url_bits = Chart.get_url_bits(self)
797
url_bits.append('chl=%s' % '|'.join(self.pie_labels))
800
def annotated_data(self):
801
# Datasets are all y-axis data. However, there should only be
802
# one dataset for pie charts.
803
for dataset in self.data:
807
class PieChart2D(PieChart):
809
def type_to_url(self):
813
class PieChart3D(PieChart):
815
def type_to_url(self):
819
class VennChart(Chart):
821
def type_to_url(self):
824
def annotated_data(self):
825
for dataset in self.data:
830
chart = GroupedVerticalBarChart(320, 200)
831
chart = PieChart2D(320, 200)
832
chart = ScatterChart(320, 200)
833
chart = SimpleLineChart(320, 200)
834
sine_data = [math.sin(float(a) / 10) * 2000 + 2000 for a in xrange(100)]
835
random_data = [a * random.random() * 30 for a in xrange(40)]
836
random_data2 = [random.random() * 4000 for a in xrange(10)]
837
# chart.set_bar_width(50)
838
# chart.set_bar_spacing(0)
839
chart.add_data(sine_data)
840
chart.add_data(random_data)
841
chart.add_data(random_data2)
842
# chart.set_line_style(1, thickness=2)
843
# chart.set_line_style(2, line_segment=10, blank_segment=5)
844
# chart.set_title('heloooo')
845
# chart.set_legend(('sine wave', 'random * x'))
846
# chart.set_colours(('ee2000', 'DDDDAA', 'fF03f2'))
847
# chart.fill_solid(Chart.BACKGROUND, '123456')
848
# chart.fill_linear_gradient(Chart.CHART, 20, '004070', 1, '300040', 0,
850
# chart.fill_linear_stripes(Chart.CHART, 20, '204070', .2, '300040', .2,
852
axis_left_index = chart.set_axis_range(Axis.LEFT, 0, 10)
853
axis_left_index = chart.set_axis_range(Axis.LEFT, 0, 10)
854
axis_left_index = chart.set_axis_range(Axis.LEFT, 0, 10)
855
axis_right_index = chart.set_axis_range(Axis.RIGHT, 5, 30)
856
axis_bottom_index = chart.set_axis_labels(Axis.BOTTOM, [1, 25, 95])
857
chart.set_axis_positions(axis_bottom_index, [1, 25, 95])
858
chart.set_axis_style(axis_bottom_index, '003050', 15)
860
# chart.set_pie_labels(('apples', 'oranges', 'bananas'))
862
# chart.set_grid(10, 10)
864
# for a in xrange(0, 100, 10):
865
# chart.add_marker(1, a, 'a', 'AACA20', 10)
867
chart.add_horizontal_range('00A020', .2, .5)
868
chart.add_vertical_range('00c030', .2, .4)
870
chart.add_fill_simple('303030A0')
872
chart.download('test.png')
874
url = chart.get_url()
877
data = urllib.urlopen(chart.get_url()).read()
878
open('meh.png', 'wb').write(data)
879
os.system('start meh.png')
882
if __name__ == '__main__':