/+junk/pygooglechart-py3k

To get this branch, use:
bzr branch http://gegoxaren.bato24.eu/bzr/%2Bjunk/pygooglechart-py3k

« back to all changes in this revision

Viewing changes to pygooglechart.py

  • Committer: gak
  • Date: 2008-08-23 07:31:12 UTC
  • Revision ID: git-v1:d25980565dd2640f4d700e85f0fab48a685c8ed7
Fixed bug where the module would download twice (#7) (Evan Lezar)

Show diffs side-by-side

added added

removed removed

Lines of Context:
1
 
"""
2
 
PyGoogleChart - A complete Python wrapper for the Google Chart API
3
 
 
4
 
http://pygooglechart.slowchop.com/
5
 
 
6
 
Copyright 2007 Gerald Kaszuba
7
 
 
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.
12
 
 
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.
17
 
 
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/>.
20
 
 
21
 
"""
22
 
 
23
 
import os
24
 
import urllib
25
 
import math
26
 
import random
27
 
import re
28
 
 
29
 
# Helper variables and functions
30
 
# -----------------------------------------------------------------------------
31
 
 
32
 
reo_colour = re.compile('^([A-Fa-f0-9]{2,2}){3,4}$')
33
 
 
34
 
def _check_colour(colour):
35
 
    if not reo_colour.match(colour):
36
 
        raise InvalidParametersException('Colours need to be in ' \
37
 
            'RRGGBB or RRGGBBAA format. One of your colours has %s' % \
38
 
            colour)
39
 
 
40
 
# Exception Classes
41
 
# -----------------------------------------------------------------------------
42
 
 
43
 
class PyGoogleChartException(Exception):
44
 
    pass
45
 
 
46
 
class DataOutOfRangeException(PyGoogleChartException):
47
 
    pass
48
 
 
49
 
class UnknownDataTypeException(PyGoogleChartException):
50
 
    pass
51
 
 
52
 
class NoDataGivenException(PyGoogleChartException):
53
 
    pass
54
 
 
55
 
class InvalidParametersException(PyGoogleChartException):
56
 
    pass
57
 
 
58
 
# Data Classes
59
 
# -----------------------------------------------------------------------------
60
 
 
61
 
class Data(object):
62
 
    def __init__(self, data):
63
 
        assert(type(self) != Data)  # This is an abstract class
64
 
        self.data = data
65
 
 
66
 
class SimpleData(Data):
67
 
    enc_map = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
68
 
    def __repr__(self):
69
 
        encoded_data = []
70
 
        for data in self.data:
71
 
            sub_data = []
72
 
            for value in data:
73
 
                if value is None:
74
 
                    sub_data.append('_')
75
 
                elif value >= 0 and value <= SimpleData.max_value:
76
 
                    sub_data.append(SimpleData.enc_map[value])
77
 
                else:
78
 
                    raise DataOutOfRangeException()
79
 
            encoded_data.append(''.join(sub_data))
80
 
        return 'chd=s:' + ','.join(encoded_data)
81
 
    @staticmethod
82
 
    def max_value():
83
 
        return 61
84
 
 
85
 
class TextData(Data):
86
 
    def __repr__(self):
87
 
        encoded_data = []
88
 
        for data in self.data:
89
 
            sub_data = []
90
 
            for value in data:
91
 
                if value is None:
92
 
                    sub_data.append(-1)
93
 
                elif value >= 0 and value <= TextData.max_value:
94
 
                    sub_data.append(str(float(value)))
95
 
                else:
96
 
                    raise DataOutOfRangeException()
97
 
            encoded_data.append(','.join(sub_data))
98
 
        return 'chd=t:' + '|'.join(encoded_data)
99
 
    @staticmethod
100
 
    def max_value():
101
 
        return 100
102
 
 
103
 
class ExtendedData(Data):
104
 
    enc_map = \
105
 
        'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-.'
106
 
    def __repr__(self):
107
 
        encoded_data = []
108
 
        enc_size = len(ExtendedData.enc_map)
109
 
        for data in self.data:
110
 
            sub_data = []
111
 
            for value in data:
112
 
                if value is None:
113
 
                    sub_data.append('__')
114
 
                elif value >= 0 and value <= ExtendedData.max_value:
115
 
                    first, second = divmod(int(value), enc_size)
116
 
                    sub_data.append('%s%s' % (
117
 
                        ExtendedData.enc_map[first],
118
 
                        ExtendedData.enc_map[second]))
119
 
                else:
120
 
                    raise DataOutOfRangeException( \
121
 
                        'Item #%i "%s" is out of range' % (data.index(value), \
122
 
                        value))
123
 
            encoded_data.append(''.join(sub_data))
124
 
        return 'chd=e:' + ','.join(encoded_data)
125
 
    @staticmethod
126
 
    def max_value():
127
 
        return 4095
128
 
 
129
 
# Axis Classes
130
 
# -----------------------------------------------------------------------------
131
 
 
132
 
class Axis(object):
133
 
    BOTTOM = 'x'
134
 
    TOP = 't'
135
 
    LEFT = 'y'
136
 
    RIGHT = 'r'
137
 
    TYPES = (BOTTOM, TOP, LEFT, RIGHT)
138
 
    def __init__(self, axis, **kw):
139
 
        assert(axis in Axis.TYPES)
140
 
        self.has_style = False
141
 
        self.index = None
142
 
        self.positions = None
143
 
    def set_index(self, index):
144
 
        self.index = index
145
 
    def set_positions(self, positions):
146
 
        self.positions = positions
147
 
    def set_style(self, colour, font_size=None, alignment=None):
148
 
        _check_colour(colour)
149
 
        self.colour = colour
150
 
        self.font_size = font_size
151
 
        self.alignment = alignment
152
 
        self.has_style = True
153
 
    def style_to_url(self):
154
 
        bits = []
155
 
        bits.append(str(self.index))
156
 
        bits.append(self.colour)
157
 
        if self.font_size is not None:
158
 
            bits.append(str(self.font_size))
159
 
            if self.alignment is not None:
160
 
                bits.append(str(self.alignment))
161
 
        return ','.join(bits)
162
 
    def positions_to_url(self):
163
 
        bits = []
164
 
        bits.append(str(self.index))
165
 
        bits += [ str(a) for a in self.positions ]
166
 
        return ','.join(bits)
167
 
 
168
 
class LabelAxis(Axis):
169
 
    def __init__(self, axis, values, **kwargs):
170
 
        Axis.__init__(self, axis, **kwargs)
171
 
        self.values = [ str(a) for a in values ]
172
 
    def __repr__(self):
173
 
        return '%i:|%s' % (self.index, '|'.join(self.values))
174
 
 
175
 
class RangeAxis(Axis):
176
 
    def __init__(self, axis, low, high, **kwargs):
177
 
        Axis.__init__(self, axis, **kwargs)
178
 
        self.low = low
179
 
        self.high = high
180
 
    def __repr__(self):
181
 
        return '%i,%s,%s' % (self.index, self.low, self.high)
182
 
 
183
 
# Chart Classes
184
 
# -----------------------------------------------------------------------------
185
 
 
186
 
class Chart(object):
187
 
 
188
 
    BASE_URL = 'http://chart.apis.google.com/chart?'
189
 
    BACKGROUND = 'bg'
190
 
    CHART = 'c'
191
 
    SOLID = 's'
192
 
    LINEAR_GRADIENT = 'lg'
193
 
    LINEAR_STRIPES = 'ls'
194
 
 
195
 
    def __init__(self, width, height, title=None, legend=None, colours=None):
196
 
        assert(type(self) != Chart)  # This is an abstract class
197
 
        assert(isinstance(width, int))
198
 
        assert(isinstance(height, int))
199
 
        self.width = width
200
 
        self.height = height
201
 
        self.data = []
202
 
        self.set_title(title)
203
 
        self.set_legend(legend)
204
 
        self.set_colours(colours)
205
 
        self.fill_types = {
206
 
            Chart.BACKGROUND: None,
207
 
            Chart.CHART: None,
208
 
        }
209
 
        self.fill_area = {
210
 
            Chart.BACKGROUND: None,
211
 
            Chart.CHART: None,
212
 
        }
213
 
        self.axis = {
214
 
            Axis.TOP: None,
215
 
            Axis.BOTTOM: None,
216
 
            Axis.LEFT: None,
217
 
            Axis.RIGHT: None,
218
 
        }
219
 
        self.markers = []
220
 
 
221
 
    # URL generation
222
 
    # -------------------------------------------------------------------------
223
 
 
224
 
    def get_url(self):
225
 
        url_bits = self.get_url_bits()
226
 
        return self.BASE_URL + '&'.join(url_bits)
227
 
 
228
 
    def get_url_bits(self):
229
 
        url_bits = []
230
 
        # required arguments
231
 
        url_bits.append(self.type_to_url())
232
 
        url_bits.append('chs=%ix%i' % (self.width, self.height))
233
 
        url_bits.append(self.data_to_url())
234
 
        # optional arguments
235
 
        if self.title:
236
 
            url_bits.append('chtt=%s' % self.title)
237
 
        if self.legend:
238
 
            url_bits.append('chdl=%s' % '|'.join(self.legend))
239
 
        if self.colours:
240
 
            url_bits.append('chco=%s' % ','.join(self.colours))
241
 
        ret = self.fill_to_url()
242
 
        if ret:
243
 
            url_bits.append(ret)
244
 
        ret = self.axis_to_url()
245
 
        if ret:
246
 
            url_bits.append(ret)
247
 
        if self.markers:
248
 
            url_bits.append(self.markers_to_url())
249
 
        return url_bits
250
 
 
251
 
    # Downloading
252
 
    # -------------------------------------------------------------------------
253
 
    def download(self, file_name):
254
 
        open(file_name, 'wb').write(urllib.urlopen(self.get_url()).read())
255
 
 
256
 
    # Simple settings
257
 
    # -------------------------------------------------------------------------
258
 
 
259
 
    def set_title(self, title):
260
 
        if title:
261
 
            self.title = urllib.quote(title)
262
 
        else:
263
 
            self.title = None
264
 
 
265
 
    def set_legend(self, legend):
266
 
        # legend needs to be a list, tuple or None
267
 
        assert(isinstance(legend, list) or isinstance(legend, tuple) or
268
 
            legend is None)
269
 
        if legend:
270
 
            self.legend = [ urllib.quote(a) for a in legend ]
271
 
        else:
272
 
            self.legend = None
273
 
 
274
 
    # Chart colours
275
 
    # -------------------------------------------------------------------------
276
 
 
277
 
    def set_colours(self, colours):
278
 
        # colours needs to be a list, tuple or None
279
 
        assert(isinstance(colours, list) or isinstance(colours, tuple) or
280
 
            colours is None)
281
 
        # make sure the colours are in the right format
282
 
        if colours:
283
 
            for col in colours:
284
 
                _check_colour(col)
285
 
        self.colours = colours
286
 
 
287
 
    # Background/Chart colours
288
 
    # -------------------------------------------------------------------------
289
 
 
290
 
    def fill_solid(self, area, colour):
291
 
        assert(area in (Chart.BACKGROUND, Chart.CHART))
292
 
        _check_colour(colour)
293
 
        self.fill_area[area] = colour
294
 
        self.fill_types[area] = Chart.SOLID
295
 
 
296
 
    def _check_fill_linear(self, angle, *args):
297
 
        assert(isinstance(args, list) or isinstance(args, tuple))
298
 
        assert(angle >= 0 and angle <= 90)
299
 
        assert(len(args) % 2 == 0)
300
 
        args = list(args)  # args is probably a tuple and we need to mutate
301
 
        for a in xrange(len(args) / 2):
302
 
            col = args[a * 2]
303
 
            offset = args[a * 2 + 1]
304
 
            _check_colour(col)
305
 
            assert(offset >= 0 and offset <= 1)
306
 
            args[a * 2 + 1] = str(args[a * 2 + 1])
307
 
        return args
308
 
 
309
 
    def fill_linear_gradient(self, area, angle, *args):
310
 
        assert(area in (Chart.BACKGROUND, Chart.CHART))
311
 
        args = self._check_fill_linear(angle, *args)
312
 
        self.fill_types[area] = Chart.LINEAR_GRADIENT
313
 
        self.fill_area[area] = ','.join([str(angle)] + args)
314
 
 
315
 
    def fill_linear_stripes(self, area, angle, *args):
316
 
        assert(area in (Chart.BACKGROUND, Chart.CHART))
317
 
        args = self._check_fill_linear(angle, *args)
318
 
        self.fill_types[area] = Chart.LINEAR_STRIPES
319
 
        self.fill_area[area] = ','.join([str(angle)] + args)
320
 
 
321
 
    def fill_to_url(self):
322
 
        areas = []
323
 
        for area in (Chart.BACKGROUND, Chart.CHART):
324
 
            if self.fill_types[area]:
325
 
                areas.append('%s,%s,%s' % (area, self.fill_types[area], \
326
 
                    self.fill_area[area]))
327
 
        if areas:
328
 
            return 'chf=' + '|'.join(areas)
329
 
 
330
 
    # Data
331
 
    # -------------------------------------------------------------------------
332
 
 
333
 
    def data_class_detection(self, data):
334
 
        """
335
 
        Detects and returns the data type required based on the range of the
336
 
        data given. The data given must be lists of numbers within a list.
337
 
        """
338
 
        assert(isinstance(data, list) or isinstance(data, tuple))
339
 
        max_value = None
340
 
        for a in data:
341
 
            assert(isinstance(a, list) or isinstance(a, tuple))
342
 
            if max_value is None or max(a) > max_value:
343
 
                max_value = max(a)
344
 
        for data_class in (SimpleData, TextData, ExtendedData):
345
 
            if max_value <= data_class.max_value():
346
 
                return data_class
347
 
        raise DataOutOfRangeException()
348
 
 
349
 
    def add_data(self, data):
350
 
        self.data.append(data)
351
 
 
352
 
    def data_to_url(self, data_class=None):
353
 
        if not data_class:
354
 
            data_class = self.data_class_detection(self.data)
355
 
        if not issubclass(data_class, Data):
356
 
            raise UnknownDataTypeException()
357
 
        return repr(data_class(self.data))
358
 
 
359
 
    # Axis Labels
360
 
    # -------------------------------------------------------------------------
361
 
 
362
 
    def set_axis_labels(self, axis, values):
363
 
        assert(axis in Axis.TYPES)
364
 
        self.axis[axis] = LabelAxis(axis, values)
365
 
 
366
 
    def set_axis_range(self, axis, low, high):
367
 
        assert(axis in Axis.TYPES)
368
 
        self.axis[axis] = RangeAxis(axis, low, high)
369
 
 
370
 
    def set_axis_positions(self, axis, positions):
371
 
        assert(axis in Axis.TYPES)
372
 
        if not self.axis[axis]:
373
 
            raise InvalidParametersException('Please create an axis first')
374
 
        self.axis[axis].set_positions(positions)
375
 
 
376
 
    def set_axis_style(self, axis, colour, font_size=None, alignment=None):
377
 
        assert(axis in Axis.TYPES)
378
 
        if not self.axis[axis]:
379
 
            raise InvalidParametersException('Please create an axis first')
380
 
        self.axis[axis].set_style(colour, font_size, alignment)
381
 
 
382
 
    def axis_to_url(self):
383
 
        available_axis = []
384
 
        label_axis = []
385
 
        range_axis = []
386
 
        positions = []
387
 
        styles = []
388
 
        index = -1
389
 
        for position, axis in self.axis.items():
390
 
            if not axis:
391
 
                continue
392
 
            index += 1
393
 
            axis.set_index(index)
394
 
            available_axis.append(position)
395
 
            if isinstance(axis, RangeAxis):
396
 
                range_axis.append(repr(axis))
397
 
            if isinstance(axis, LabelAxis):
398
 
                label_axis.append(repr(axis))
399
 
            if axis.positions:
400
 
                positions.append(axis.positions_to_url())
401
 
            if axis.has_style:
402
 
                styles.append(axis.style_to_url())
403
 
        if not available_axis:
404
 
            return
405
 
        url_bits = []
406
 
        url_bits.append('chxt=%s' % ','.join(available_axis))
407
 
        if label_axis:
408
 
            url_bits.append('chxl=%s' % '|'.join(label_axis))
409
 
        if range_axis:
410
 
            url_bits.append('chxr=%s' % '|'.join(range_axis))
411
 
        if positions:
412
 
            url_bits.append('chxp=%s' % '|'.join(positions))
413
 
        if styles:
414
 
            url_bits.append('chxs=%s' % '|'.join(styles))
415
 
        return '&'.join(url_bits)
416
 
 
417
 
    # Markers, Ranges and Fill area (chm)
418
 
    # -------------------------------------------------------------------------
419
 
 
420
 
    def markers_to_url(self):
421
 
        return 'chm=%s' % '|'.join([ ','.join(a) for a in self.markers ])
422
 
 
423
 
    def add_marker(self, index, point, marker_type, colour, size):
424
 
        self.markers.append((marker_type, colour, str(index), str(point), \
425
 
            str(size)))
426
 
 
427
 
    def add_horizontal_range(self, colour, start, stop):
428
 
        self.markers.append(('r', colour, '1', str(start), str(stop)))
429
 
 
430
 
    def add_vertical_range(self, colour, start, stop):
431
 
        self.markers.append(('R', colour, '1', str(start), str(stop)))
432
 
 
433
 
    def add_fill_range(self, colour, index_start, index_end):
434
 
        self.markers.append(('b', colour, str(index_start), str(index_end), \
435
 
            '1'))
436
 
 
437
 
    def add_fill_simple(self, colour):
438
 
        self.markers.append(('B', colour, '1', '1', '1'))
439
 
 
440
 
class ScatterChart(Chart):
441
 
    def __init__(self, *args, **kwargs):
442
 
        Chart.__init__(self, *args, **kwargs)
443
 
    def type_to_url(self):
444
 
        return 'cht=s'
445
 
 
446
 
 
447
 
class LineChart(Chart):
448
 
    def __init__(self, *args, **kwargs):
449
 
        Chart.__init__(self, *args, **kwargs)
450
 
        self.line_styles = {}
451
 
        self.grid = None
452
 
    def set_line_style(self, index, thickness=1, line_segment=None, \
453
 
            blank_segment=None):
454
 
        value = []
455
 
        value.append(str(thickness))
456
 
        if line_segment:
457
 
            value.append(str(line_segment))
458
 
            value.append(str(blank_segment))
459
 
        self.line_styles[index] = value
460
 
    def set_grid(self, x_step, y_step, line_segment=1, \
461
 
            blank_segment=0):
462
 
        self.grid = '%s,%s,%s,%s' % (x_step, y_step, line_segment, \
463
 
            blank_segment)
464
 
    def get_url_bits(self):
465
 
        url_bits = Chart.get_url_bits(self)
466
 
        if self.line_styles:
467
 
            style = []
468
 
            # for index, values in self.line_style.items():
469
 
            for index in xrange(max(self.line_styles) + 1):
470
 
                if index in self.line_styles:
471
 
                    values = self.line_styles[index]
472
 
                else:
473
 
                    values = ('1', )
474
 
                style.append(','.join(values))
475
 
            url_bits.append('chls=%s' % '|'.join(style))
476
 
        if self.grid:
477
 
            url_bits.append('chg=%s' % self.grid)
478
 
        return url_bits
479
 
 
480
 
class SimpleLineChart(LineChart):
481
 
    def type_to_url(self):
482
 
        return 'cht=lc'
483
 
 
484
 
class XYLineChart(LineChart):
485
 
    def type_to_url(self):
486
 
        return 'cht=lxy'
487
 
 
488
 
class BarChart(Chart):
489
 
    def __init__(self, *args, **kwargs):
490
 
        assert(type(self) != BarChart)  # This is an abstract class
491
 
        Chart.__init__(self, *args, **kwargs)
492
 
        self.bar_width = None
493
 
    def set_bar_width(self, bar_width):
494
 
        self.bar_width = bar_width
495
 
    def get_url_bits(self):
496
 
        url_bits = Chart.get_url_bits(self)
497
 
        url_bits.append('chbh=%i' % self.bar_width)
498
 
        return url_bits
499
 
 
500
 
class StackedHorizontalBarChart(BarChart):
501
 
    def type_to_url(self):
502
 
        return 'cht=bhs'
503
 
 
504
 
class StackedVerticalBarChart(BarChart):
505
 
    def type_to_url(self):
506
 
        return 'cht=bvs'
507
 
 
508
 
class GroupedBarChart(BarChart):
509
 
    def __init__(self, *args, **kwargs):
510
 
        assert(type(self) != GroupedBarChart)  # This is an abstract class
511
 
        BarChart.__init__(self, *args, **kwargs)
512
 
        self.bar_spacing = None
513
 
    def set_bar_spacing(self, spacing):
514
 
        self.bar_spacing = spacing
515
 
    def get_url_bits(self):
516
 
        # Skip 'BarChart.get_url_bits' and call Chart directly so the parent
517
 
        # doesn't add "chbh" before we do.
518
 
        url_bits = Chart.get_url_bits(self)
519
 
        if self.bar_spacing is not None:
520
 
            if self.bar_width is None:
521
 
                raise InvalidParametersException('Bar width is required to ' \
522
 
                    'be set when setting spacing')
523
 
            url_bits.append('chbh=%i,%i' % (self.bar_width, self.bar_spacing))
524
 
        else:
525
 
            url_bits.append('chbh=%i' % self.bar_width)
526
 
        return url_bits
527
 
 
528
 
class GroupedHorizontalBarChart(GroupedBarChart):
529
 
    def type_to_url(self):
530
 
        return 'cht=bhg'
531
 
 
532
 
class GroupedVerticalBarChart(GroupedBarChart):
533
 
    def type_to_url(self):
534
 
        return 'cht=bvg'
535
 
 
536
 
class PieChart(Chart):
537
 
    def __init__(self, *args, **kwargs):
538
 
        assert(type(self) != PieChart)  # This is an abstract class
539
 
        Chart.__init__(self, *args, **kwargs)
540
 
        self.pie_labels = []
541
 
    def set_pie_labels(self, labels):
542
 
        self.pie_labels = labels
543
 
    def get_url_bits(self):
544
 
        url_bits = Chart.get_url_bits(self)
545
 
        if self.pie_labels:
546
 
            url_bits.append('chl=%s' % '|'.join(self.pie_labels))
547
 
        return url_bits
548
 
 
549
 
class PieChart2D(PieChart):
550
 
    def type_to_url(self):
551
 
        return 'cht=p'
552
 
 
553
 
class PieChart3D(PieChart):
554
 
    def type_to_url(self):
555
 
        return 'cht=p3'
556
 
 
557
 
class VennChart(Chart):
558
 
    def type_to_url(self):
559
 
        return 'cht=v'
560
 
 
561
 
def test():
562
 
    chart = GroupedVerticalBarChart(320, 200)
563
 
    chart = PieChart2D(320, 200)
564
 
    chart = ScatterChart(320, 200)
565
 
    chart = SimpleLineChart(320, 200)
566
 
    sine_data = [ math.sin(float(a) / 10) * 2000 + 2000 for a in xrange(100) ]
567
 
    random_data = [ a * random.random() * 30 for a in xrange(40) ]
568
 
    random_data2 = [ random.random() * 4000 for a in xrange(10) ]
569
 
#    chart.set_bar_width(50)
570
 
#    chart.set_bar_spacing(0)
571
 
    chart.add_data(sine_data)
572
 
    chart.add_data(random_data)
573
 
    chart.add_data(random_data2)
574
 
#    chart.set_line_style(1, thickness=2)
575
 
#    chart.set_line_style(2, line_segment=10, blank_segment=5)
576
 
#    chart.set_title('heloooo')
577
 
#    chart.set_legend(('sine wave', 'random * x'))
578
 
#    chart.set_colours(('ee2000', 'DDDDAA', 'fF03f2'))
579
 
#    chart.fill_solid(Chart.BACKGROUND, '123456')
580
 
#    chart.fill_linear_gradient(Chart.CHART, 20, '004070', 1, '300040', 0,
581
 
#        'aabbcc00', 0.5)
582
 
#    chart.fill_linear_stripes(Chart.CHART, 20, '204070', .2, '300040', .2,
583
 
#        'aabbcc00', 0.2)
584
 
    chart.set_axis_range(Axis.LEFT, 0, 10)
585
 
    chart.set_axis_range(Axis.RIGHT, 5, 30)
586
 
    chart.set_axis_labels(Axis.BOTTOM, [1, 25, 95])
587
 
    chart.set_axis_positions(Axis.BOTTOM, [1, 25, 95])
588
 
    chart.set_axis_style(Axis.BOTTOM, 'FFFFFF', 15)
589
 
 
590
 
#    chart.set_pie_labels(('apples', 'oranges', 'bananas'))
591
 
 
592
 
#    chart.set_grid(10, 10)
593
 
 
594
 
#    for a in xrange(0, 100, 10):
595
 
#        chart.add_marker(1, a, 'a', 'AACA20', 10)
596
 
 
597
 
    chart.add_horizontal_range('00A020', .2, .5)
598
 
    chart.add_vertical_range('00c030', .2, .4)
599
 
 
600
 
    chart.add_fill_simple('303030A0')
601
 
 
602
 
 
603
 
    chart = SimpleLineChart(320, 200)
604
 
    data = [ 1, 5, 30, 10, 25 ]
605
 
    chart.add_data(data)
606
 
    chart.set_title('Hello World!')
607
 
    chart.set_axis_range(Axis.LEFT, 0, 10)
608
 
    print chart.get_url()
609
 
    chart.download('hello.png')
610
 
 
611
 
    url = chart.get_url()
612
 
    print url
613
 
    if 0:
614
 
        data = urllib.urlopen(chart.get_url()).read()
615
 
        open('meh.png', 'wb').write(data)
616
 
        os.system('start meh.png')
617
 
 
618
 
if __name__ == '__main__':
619
 
    test()
620
 
 
 
1
"""
 
2
pygooglechart - A complete Python wrapper for the Google Chart API
 
3
 
 
4
http://pygooglechart.slowchop.com/
 
5
 
 
6
Copyright 2007-2008 Gerald Kaszuba
 
7
 
 
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.
 
12
 
 
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.
 
17
 
 
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/>.
 
20
 
 
21
"""
 
22
 
 
23
import os
 
24
import urllib
 
25
import urllib2
 
26
import math
 
27
import random
 
28
import re
 
29
import warnings
 
30
import copy
 
31
 
 
32
# Helper variables and functions
 
33
# -----------------------------------------------------------------------------
 
34
 
 
35
__version__ = '0.2.1'
 
36
__author__ = 'Gerald Kaszuba'
 
37
 
 
38
reo_colour = re.compile('^([A-Fa-f0-9]{2,2}){3,4}$')
 
39
 
 
40
def _check_colour(colour):
 
41
    if not reo_colour.match(colour):
 
42
        raise InvalidParametersException('Colours need to be in ' \
 
43
            'RRGGBB or RRGGBBAA format. One of your colours has %s' % \
 
44
            colour)
 
45
 
 
46
 
 
47
def _reset_warnings():
 
48
    """Helper function to reset all warnings. Used by the unit tests."""
 
49
    globals()['__warningregistry__'] = None
 
50
 
 
51
 
 
52
# Exception Classes
 
53
# -----------------------------------------------------------------------------
 
54
 
 
55
 
 
56
class PyGoogleChartException(Exception):
 
57
    pass
 
58
 
 
59
 
 
60
class DataOutOfRangeException(PyGoogleChartException):
 
61
    pass
 
62
 
 
63
 
 
64
class UnknownDataTypeException(PyGoogleChartException):
 
65
    pass
 
66
 
 
67
 
 
68
class NoDataGivenException(PyGoogleChartException):
 
69
    pass
 
70
 
 
71
 
 
72
class InvalidParametersException(PyGoogleChartException):
 
73
    pass
 
74
 
 
75
 
 
76
class BadContentTypeException(PyGoogleChartException):
 
77
    pass
 
78
 
 
79
 
 
80
class AbstractClassException(PyGoogleChartException):
 
81
    pass
 
82
 
 
83
 
 
84
class UnknownChartType(PyGoogleChartException):
 
85
    pass
 
86
 
 
87
 
 
88
# Data Classes
 
89
# -----------------------------------------------------------------------------
 
90
 
 
91
 
 
92
class Data(object):
 
93
 
 
94
    def __init__(self, data):
 
95
        if type(self) == Data:
 
96
            raise AbstractClassException('This is an abstract class')
 
97
        self.data = data
 
98
 
 
99
    @classmethod
 
100
    def float_scale_value(cls, value, range):
 
101
        lower, upper = range
 
102
        assert(upper > lower)
 
103
        scaled = (value - lower) * (float(cls.max_value) / (upper - lower))
 
104
        return scaled
 
105
 
 
106
    @classmethod
 
107
    def clip_value(cls, value):
 
108
        return max(0, min(value, cls.max_value))
 
109
 
 
110
    @classmethod
 
111
    def int_scale_value(cls, value, range):
 
112
        return int(round(cls.float_scale_value(value, range)))
 
113
 
 
114
    @classmethod
 
115
    def scale_value(cls, value, range):
 
116
        scaled = cls.int_scale_value(value, range)
 
117
        clipped = cls.clip_value(scaled)
 
118
        Data.check_clip(scaled, clipped)
 
119
        return clipped
 
120
 
 
121
    @staticmethod
 
122
    def check_clip(scaled, clipped):
 
123
        if clipped != scaled:
 
124
            warnings.warn('One or more of of your data points has been '
 
125
                'clipped because it is out of range.')
 
126
 
 
127
 
 
128
class SimpleData(Data):
 
129
 
 
130
    max_value = 61
 
131
    enc_map = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
 
132
 
 
133
    def __repr__(self):
 
134
        encoded_data = []
 
135
        for data in self.data:
 
136
            sub_data = []
 
137
            for value in data:
 
138
                if value is None:
 
139
                    sub_data.append('_')
 
140
                elif value >= 0 and value <= self.max_value:
 
141
                    sub_data.append(SimpleData.enc_map[value])
 
142
                else:
 
143
                    raise DataOutOfRangeException('cannot encode value: %d'
 
144
                                                  % value)
 
145
            encoded_data.append(''.join(sub_data))
 
146
        return 'chd=s:' + ','.join(encoded_data)
 
147
 
 
148
 
 
149
class TextData(Data):
 
150
 
 
151
    max_value = 100
 
152
 
 
153
    def __repr__(self):
 
154
        encoded_data = []
 
155
        for data in self.data:
 
156
            sub_data = []
 
157
            for value in data:
 
158
                if value is None:
 
159
                    sub_data.append(-1)
 
160
                elif value >= 0 and value <= self.max_value:
 
161
                    sub_data.append("%.1f" % float(value))
 
162
                else:
 
163
                    raise DataOutOfRangeException()
 
164
            encoded_data.append(','.join(sub_data))
 
165
        return 'chd=t:' + '|'.join(encoded_data)
 
166
 
 
167
    @classmethod
 
168
    def scale_value(cls, value, range):
 
169
        # use float values instead of integers because we don't need an encode
 
170
        # map index
 
171
        scaled = cls.float_scale_value(value, range)
 
172
        clipped = cls.clip_value(scaled)
 
173
        Data.check_clip(scaled, clipped)
 
174
        return clipped
 
175
 
 
176
 
 
177
class ExtendedData(Data):
 
178
 
 
179
    max_value = 4095
 
180
    enc_map = \
 
181
        'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-.'
 
182
 
 
183
    def __repr__(self):
 
184
        encoded_data = []
 
185
        enc_size = len(ExtendedData.enc_map)
 
186
        for data in self.data:
 
187
            sub_data = []
 
188
            for value in data:
 
189
                if value is None:
 
190
                    sub_data.append('__')
 
191
                elif value >= 0 and value <= self.max_value:
 
192
                    first, second = divmod(int(value), enc_size)
 
193
                    sub_data.append('%s%s' % (
 
194
                        ExtendedData.enc_map[first],
 
195
                        ExtendedData.enc_map[second]))
 
196
                else:
 
197
                    raise DataOutOfRangeException( \
 
198
                        'Item #%i "%s" is out of range' % (data.index(value), \
 
199
                        value))
 
200
            encoded_data.append(''.join(sub_data))
 
201
        return 'chd=e:' + ','.join(encoded_data)
 
202
 
 
203
 
 
204
# Axis Classes
 
205
# -----------------------------------------------------------------------------
 
206
 
 
207
 
 
208
class Axis(object):
 
209
 
 
210
    BOTTOM = 'x'
 
211
    TOP = 't'
 
212
    LEFT = 'y'
 
213
    RIGHT = 'r'
 
214
    TYPES = (BOTTOM, TOP, LEFT, RIGHT)
 
215
 
 
216
    def __init__(self, axis_index, axis_type, **kw):
 
217
        assert(axis_type in Axis.TYPES)
 
218
        self.has_style = False
 
219
        self.axis_index = axis_index
 
220
        self.axis_type = axis_type
 
221
        self.positions = None
 
222
 
 
223
    def set_index(self, axis_index):
 
224
        self.axis_index = axis_index
 
225
 
 
226
    def set_positions(self, positions):
 
227
        self.positions = positions
 
228
 
 
229
    def set_style(self, colour, font_size=None, alignment=None):
 
230
        _check_colour(colour)
 
231
        self.colour = colour
 
232
        self.font_size = font_size
 
233
        self.alignment = alignment
 
234
        self.has_style = True
 
235
 
 
236
    def style_to_url(self):
 
237
        bits = []
 
238
        bits.append(str(self.axis_index))
 
239
        bits.append(self.colour)
 
240
        if self.font_size is not None:
 
241
            bits.append(str(self.font_size))
 
242
            if self.alignment is not None:
 
243
                bits.append(str(self.alignment))
 
244
        return ','.join(bits)
 
245
 
 
246
    def positions_to_url(self):
 
247
        bits = []
 
248
        bits.append(str(self.axis_index))
 
249
        bits += [str(a) for a in self.positions]
 
250
        return ','.join(bits)
 
251
 
 
252
 
 
253
class LabelAxis(Axis):
 
254
 
 
255
    def __init__(self, axis_index, axis_type, values, **kwargs):
 
256
        Axis.__init__(self, axis_index, axis_type, **kwargs)
 
257
        self.values = [str(a) for a in values]
 
258
 
 
259
    def __repr__(self):
 
260
        return '%i:|%s' % (self.axis_index, '|'.join(self.values))
 
261
 
 
262
 
 
263
class RangeAxis(Axis):
 
264
 
 
265
    def __init__(self, axis_index, axis_type, low, high, **kwargs):
 
266
        Axis.__init__(self, axis_index, axis_type, **kwargs)
 
267
        self.low = low
 
268
        self.high = high
 
269
 
 
270
    def __repr__(self):
 
271
        return '%i,%s,%s' % (self.axis_index, self.low, self.high)
 
272
 
 
273
# Chart Classes
 
274
# -----------------------------------------------------------------------------
 
275
 
 
276
 
 
277
class Chart(object):
 
278
    """Abstract class for all chart types.
 
279
 
 
280
    width are height specify the dimensions of the image. title sets the title
 
281
    of the chart. legend requires a list that corresponds to datasets.
 
282
    """
 
283
 
 
284
    BASE_URL = 'http://chart.apis.google.com/chart?'
 
285
    BACKGROUND = 'bg'
 
286
    CHART = 'c'
 
287
    ALPHA = 'a'
 
288
    VALID_SOLID_FILL_TYPES = (BACKGROUND, CHART, ALPHA)
 
289
    SOLID = 's'
 
290
    LINEAR_GRADIENT = 'lg'
 
291
    LINEAR_STRIPES = 'ls'
 
292
 
 
293
    def __init__(self, width, height, title=None, legend=None, colours=None,
 
294
            auto_scale=True, x_range=None, y_range=None,
 
295
            colours_within_series=None):
 
296
        if type(self) == Chart:
 
297
            raise AbstractClassException('This is an abstract class')
 
298
        assert(isinstance(width, int))
 
299
        assert(isinstance(height, int))
 
300
        self.width = width
 
301
        self.height = height
 
302
        self.data = []
 
303
        self.set_title(title)
 
304
        self.set_legend(legend)
 
305
        self.set_legend_position(None)
 
306
        self.set_colours(colours)
 
307
        self.set_colours_within_series(colours_within_series)
 
308
 
 
309
        # Data for scaling.
 
310
        self.auto_scale = auto_scale  # Whether to automatically scale data
 
311
        self.x_range = x_range  # (min, max) x-axis range for scaling
 
312
        self.y_range = y_range  # (min, max) y-axis range for scaling
 
313
        self.scaled_data_class = None
 
314
        self.scaled_x_range = None
 
315
        self.scaled_y_range = None
 
316
 
 
317
        self.fill_types = {
 
318
            Chart.BACKGROUND: None,
 
319
            Chart.CHART: None,
 
320
            Chart.ALPHA: None,
 
321
        }
 
322
        self.fill_area = {
 
323
            Chart.BACKGROUND: None,
 
324
            Chart.CHART: None,
 
325
            Chart.ALPHA: None,
 
326
        }
 
327
        self.axis = []
 
328
        self.markers = []
 
329
        self.line_styles = {}
 
330
        self.grid = None
 
331
 
 
332
    # URL generation
 
333
    # -------------------------------------------------------------------------
 
334
 
 
335
    def get_url(self, data_class=None):
 
336
        url_bits = self.get_url_bits(data_class=data_class)
 
337
        return self.BASE_URL + '&'.join(url_bits)
 
338
 
 
339
    def get_url_bits(self, data_class=None):
 
340
        url_bits = []
 
341
        # required arguments
 
342
        url_bits.append(self.type_to_url())
 
343
        url_bits.append('chs=%ix%i' % (self.width, self.height))
 
344
        url_bits.append(self.data_to_url(data_class=data_class))
 
345
        # optional arguments
 
346
        if self.title:
 
347
            url_bits.append('chtt=%s' % self.title)
 
348
        if self.legend:
 
349
            url_bits.append('chdl=%s' % '|'.join(self.legend))
 
350
        if self.legend_position:
 
351
            url_bits.append('chdlp=%s' % (self.legend_position))
 
352
        if self.colours:
 
353
            url_bits.append('chco=%s' % ','.join(self.colours))            
 
354
        if self.colours_within_series:
 
355
            url_bits.append('chco=%s' % '|'.join(self.colours_within_series))
 
356
        ret = self.fill_to_url()
 
357
        if ret:
 
358
            url_bits.append(ret)
 
359
        ret = self.axis_to_url()
 
360
        if ret:
 
361
            url_bits.append(ret)                    
 
362
        if self.markers:
 
363
            url_bits.append(self.markers_to_url())        
 
364
        if self.line_styles:
 
365
            style = []
 
366
            for index in xrange(max(self.line_styles) + 1):
 
367
                if index in self.line_styles:
 
368
                    values = self.line_styles[index]
 
369
                else:
 
370
                    values = ('1', )
 
371
                style.append(','.join(values))
 
372
            url_bits.append('chls=%s' % '|'.join(style))
 
373
        if self.grid:
 
374
            url_bits.append('chg=%s' % self.grid)
 
375
        return url_bits
 
376
 
 
377
    # Downloading
 
378
    # -------------------------------------------------------------------------
 
379
 
 
380
    def download(self, file_name):
 
381
        opener = urllib2.urlopen(self.get_url())
 
382
 
 
383
        if opener.headers['content-type'] != 'image/png':
 
384
            raise BadContentTypeException('Server responded with a ' \
 
385
                'content-type of %s' % opener.headers['content-type'])
 
386
 
 
387
        open(file_name, 'wb').write(opener.read())
 
388
 
 
389
    # Simple settings
 
390
    # -------------------------------------------------------------------------
 
391
 
 
392
    def set_title(self, title):
 
393
        if title:
 
394
            self.title = urllib.quote(title)
 
395
        else:
 
396
            self.title = None
 
397
 
 
398
    def set_legend(self, legend):
 
399
        """legend needs to be a list, tuple or None"""
 
400
        assert(isinstance(legend, list) or isinstance(legend, tuple) or
 
401
            legend is None)
 
402
        if legend:
 
403
            self.legend = [urllib.quote(a) for a in legend]
 
404
        else:
 
405
            self.legend = None
 
406
 
 
407
    def set_legend_position(self, legend_position):
 
408
        if legend_position:
 
409
            self.legend_position = urllib.quote(legend_position)
 
410
        else:    
 
411
            self.legend_position = None
 
412
 
 
413
    # Chart colours
 
414
    # -------------------------------------------------------------------------
 
415
 
 
416
    def set_colours(self, colours):
 
417
        # colours needs to be a list, tuple or None
 
418
        assert(isinstance(colours, list) or isinstance(colours, tuple) or
 
419
            colours is None)
 
420
        # make sure the colours are in the right format
 
421
        if colours:
 
422
            for col in colours:
 
423
                _check_colour(col)
 
424
        self.colours = colours
 
425
 
 
426
    def set_colours_within_series(self, colours):
 
427
        # colours needs to be a list, tuple or None
 
428
        assert(isinstance(colours, list) or isinstance(colours, tuple) or
 
429
            colours is None)
 
430
        # make sure the colours are in the right format
 
431
        if colours:
 
432
            for col in colours:
 
433
                _check_colour(col)
 
434
        self.colours_within_series = colours        
 
435
 
 
436
    # Background/Chart colours
 
437
    # -------------------------------------------------------------------------
 
438
 
 
439
    def fill_solid(self, area, colour):
 
440
        assert(area in Chart.VALID_SOLID_FILL_TYPES)
 
441
        _check_colour(colour)
 
442
        self.fill_area[area] = colour
 
443
        self.fill_types[area] = Chart.SOLID
 
444
 
 
445
    def _check_fill_linear(self, angle, *args):
 
446
        assert(isinstance(args, list) or isinstance(args, tuple))
 
447
        assert(angle >= 0 and angle <= 90)
 
448
        assert(len(args) % 2 == 0)
 
449
        args = list(args)  # args is probably a tuple and we need to mutate
 
450
        for a in xrange(len(args) / 2):
 
451
            col = args[a * 2]
 
452
            offset = args[a * 2 + 1]
 
453
            _check_colour(col)
 
454
            assert(offset >= 0 and offset <= 1)
 
455
            args[a * 2 + 1] = str(args[a * 2 + 1])
 
456
        return args
 
457
 
 
458
    def fill_linear_gradient(self, area, angle, *args):
 
459
        assert(area in Chart.VALID_SOLID_FILL_TYPES)
 
460
        args = self._check_fill_linear(angle, *args)
 
461
        self.fill_types[area] = Chart.LINEAR_GRADIENT
 
462
        self.fill_area[area] = ','.join([str(angle)] + args)
 
463
 
 
464
    def fill_linear_stripes(self, area, angle, *args):
 
465
        assert(area in Chart.VALID_SOLID_FILL_TYPES)
 
466
        args = self._check_fill_linear(angle, *args)
 
467
        self.fill_types[area] = Chart.LINEAR_STRIPES
 
468
        self.fill_area[area] = ','.join([str(angle)] + args)
 
469
 
 
470
    def fill_to_url(self):
 
471
        areas = []
 
472
        for area in (Chart.BACKGROUND, Chart.CHART, Chart.ALPHA):
 
473
            if self.fill_types[area]:
 
474
                areas.append('%s,%s,%s' % (area, self.fill_types[area], \
 
475
                    self.fill_area[area]))
 
476
        if areas:
 
477
            return 'chf=' + '|'.join(areas)
 
478
 
 
479
    # Data
 
480
    # -------------------------------------------------------------------------
 
481
 
 
482
    def data_class_detection(self, data):
 
483
        """Determines the appropriate data encoding type to give satisfactory
 
484
        resolution (http://code.google.com/apis/chart/#chart_data).
 
485
        """
 
486
        assert(isinstance(data, list) or isinstance(data, tuple))
 
487
        if not isinstance(self, (LineChart, BarChart, ScatterChart)):
 
488
            # From the link above:
 
489
            #   Simple encoding is suitable for all other types of chart
 
490
            #   regardless of size.
 
491
            return SimpleData
 
492
        elif self.height < 100:
 
493
            # The link above indicates that line and bar charts less
 
494
            # than 300px in size can be suitably represented with the
 
495
            # simple encoding. I've found that this isn't sufficient,
 
496
            # e.g. examples/line-xy-circle.png. Let's try 100px.
 
497
            return SimpleData
 
498
        else:
 
499
            return ExtendedData
 
500
 
 
501
    def _filter_none(self, data):
 
502
        return [r for r in data if r is not None]
 
503
 
 
504
    def data_x_range(self):
 
505
        """Return a 2-tuple giving the minimum and maximum x-axis
 
506
        data range.
 
507
        """
 
508
        try:
 
509
            lower = min([min(self._filter_none(s))
 
510
                         for type, s in self.annotated_data()
 
511
                         if type == 'x'])
 
512
            upper = max([max(self._filter_none(s))
 
513
                         for type, s in self.annotated_data()
 
514
                         if type == 'x'])
 
515
            return (lower, upper)
 
516
        except ValueError:
 
517
            return None     # no x-axis datasets
 
518
 
 
519
    def data_y_range(self):
 
520
        """Return a 2-tuple giving the minimum and maximum y-axis
 
521
        data range.
 
522
        """
 
523
        try:
 
524
            lower = min([min(self._filter_none(s))
 
525
                         for type, s in self.annotated_data()
 
526
                         if type == 'y'])
 
527
            upper = max([max(self._filter_none(s)) + 1
 
528
                         for type, s in self.annotated_data()
 
529
                         if type == 'y'])
 
530
            return (lower, upper)
 
531
        except ValueError:
 
532
            return None     # no y-axis datasets
 
533
 
 
534
    def scaled_data(self, data_class, x_range=None, y_range=None):
 
535
        """Scale `self.data` as appropriate for the given data encoding
 
536
        (data_class) and return it.
 
537
 
 
538
        An optional `y_range` -- a 2-tuple (lower, upper) -- can be
 
539
        given to specify the y-axis bounds. If not given, the range is
 
540
        inferred from the data: (0, <max-value>) presuming no negative
 
541
        values, or (<min-value>, <max-value>) if there are negative
 
542
        values.  `self.scaled_y_range` is set to the actual lower and
 
543
        upper scaling range.
 
544
 
 
545
        Ditto for `x_range`. Note that some chart types don't have x-axis
 
546
        data.
 
547
        """
 
548
        self.scaled_data_class = data_class
 
549
 
 
550
        # Determine the x-axis range for scaling.
 
551
        if x_range is None:
 
552
            x_range = self.data_x_range()
 
553
            if x_range and x_range[0] > 0:
 
554
                x_range = (x_range[0], x_range[1])
 
555
        self.scaled_x_range = x_range
 
556
 
 
557
        # Determine the y-axis range for scaling.
 
558
        if y_range is None:
 
559
            y_range = self.data_y_range()
 
560
            if y_range and y_range[0] > 0:
 
561
                y_range = (y_range[0], y_range[1])
 
562
        self.scaled_y_range = y_range
 
563
 
 
564
        scaled_data = []
 
565
        for type, dataset in self.annotated_data():
 
566
            if type == 'x':
 
567
                scale_range = x_range
 
568
            elif type == 'y':
 
569
                scale_range = y_range
 
570
            elif type == 'marker-size':
 
571
                scale_range = (0, max(dataset))
 
572
            scaled_dataset = []
 
573
            for v in dataset:
 
574
                if v is None:
 
575
                    scaled_dataset.append(None)
 
576
                else:
 
577
                    scaled_dataset.append(
 
578
                        data_class.scale_value(v, scale_range))
 
579
            scaled_data.append(scaled_dataset)
 
580
        return scaled_data
 
581
 
 
582
    def add_data(self, data):
 
583
        self.data.append(data)
 
584
        return len(self.data) - 1  # return the "index" of the data set
 
585
 
 
586
    def data_to_url(self, data_class=None):
 
587
        if not data_class:
 
588
            data_class = self.data_class_detection(self.data)
 
589
        if not issubclass(data_class, Data):
 
590
            raise UnknownDataTypeException()
 
591
        if self.auto_scale:
 
592
            data = self.scaled_data(data_class, self.x_range, self.y_range)
 
593
        else:
 
594
            data = self.data
 
595
        return repr(data_class(data))
 
596
 
 
597
    def annotated_data(self):
 
598
        for dataset in self.data:
 
599
            yield ('x', dataset)
 
600
 
 
601
    # Axis Labels
 
602
    # -------------------------------------------------------------------------
 
603
 
 
604
    def set_axis_labels(self, axis_type, values):
 
605
        assert(axis_type in Axis.TYPES)
 
606
        values = [urllib.quote(a) for a in values]
 
607
        axis_index = len(self.axis)
 
608
        axis = LabelAxis(axis_index, axis_type, values)
 
609
        self.axis.append(axis)
 
610
        return axis_index
 
611
 
 
612
    def set_axis_range(self, axis_type, low, high):
 
613
        assert(axis_type in Axis.TYPES)
 
614
        axis_index = len(self.axis)
 
615
        axis = RangeAxis(axis_index, axis_type, low, high)
 
616
        self.axis.append(axis)
 
617
        return axis_index
 
618
 
 
619
    def set_axis_positions(self, axis_index, positions):
 
620
        try:
 
621
            self.axis[axis_index].set_positions(positions)
 
622
        except IndexError:
 
623
            raise InvalidParametersException('Axis index %i has not been ' \
 
624
                'created' % axis)
 
625
 
 
626
    def set_axis_style(self, axis_index, colour, font_size=None, \
 
627
            alignment=None):
 
628
        try:
 
629
            self.axis[axis_index].set_style(colour, font_size, alignment)
 
630
        except IndexError:
 
631
            raise InvalidParametersException('Axis index %i has not been ' \
 
632
                'created' % axis)
 
633
 
 
634
    def axis_to_url(self):
 
635
        available_axis = []
 
636
        label_axis = []
 
637
        range_axis = []
 
638
        positions = []
 
639
        styles = []
 
640
        index = -1
 
641
        for axis in self.axis:
 
642
            available_axis.append(axis.axis_type)
 
643
            if isinstance(axis, RangeAxis):
 
644
                range_axis.append(repr(axis))
 
645
            if isinstance(axis, LabelAxis):
 
646
                label_axis.append(repr(axis))
 
647
            if axis.positions:
 
648
                positions.append(axis.positions_to_url())
 
649
            if axis.has_style:
 
650
                styles.append(axis.style_to_url())
 
651
        if not available_axis:
 
652
            return
 
653
        url_bits = []
 
654
        url_bits.append('chxt=%s' % ','.join(available_axis))
 
655
        if label_axis:
 
656
            url_bits.append('chxl=%s' % '|'.join(label_axis))
 
657
        if range_axis:
 
658
            url_bits.append('chxr=%s' % '|'.join(range_axis))
 
659
        if positions:
 
660
            url_bits.append('chxp=%s' % '|'.join(positions))
 
661
        if styles:
 
662
            url_bits.append('chxs=%s' % '|'.join(styles))
 
663
        return '&'.join(url_bits)
 
664
 
 
665
    # Markers, Ranges and Fill area (chm)
 
666
    # -------------------------------------------------------------------------
 
667
 
 
668
    def markers_to_url(self):        
 
669
        return 'chm=%s' % '|'.join([','.join(a) for a in self.markers])
 
670
 
 
671
    def add_marker(self, index, point, marker_type, colour, size, priority=0):
 
672
        self.markers.append((marker_type, colour, str(index), str(point), \
 
673
            str(size), str(priority)))
 
674
 
 
675
    def add_horizontal_range(self, colour, start, stop):
 
676
        self.markers.append(('r', colour, '0', str(start), str(stop)))
 
677
 
 
678
    def add_data_line(self, colour, data_set, size, priority=0):
 
679
        self.markers.append(('D', colour, str(data_set), '0', str(size), str(priority)))
 
680
 
 
681
    def add_marker_text(self, string, colour, data_set, data_point, size, priority=0):
 
682
        self.markers.append((str(string), colour, str(data_set), str(data_point), str(size), str(priority)))        
 
683
 
 
684
    def add_vertical_range(self, colour, start, stop):
 
685
        self.markers.append(('R', colour, '0', str(start), str(stop)))
 
686
 
 
687
    def add_fill_range(self, colour, index_start, index_end):
 
688
        self.markers.append(('b', colour, str(index_start), str(index_end), \
 
689
            '1'))
 
690
 
 
691
    def add_fill_simple(self, colour):
 
692
        self.markers.append(('B', colour, '1', '1', '1'))
 
693
 
 
694
    # Line styles
 
695
    # -------------------------------------------------------------------------
 
696
 
 
697
    def set_line_style(self, index, thickness=1, line_segment=None, \
 
698
            blank_segment=None):
 
699
        value = []
 
700
        value.append(str(thickness))
 
701
        if line_segment:
 
702
            value.append(str(line_segment))
 
703
            value.append(str(blank_segment))
 
704
        self.line_styles[index] = value
 
705
 
 
706
    # Grid
 
707
    # -------------------------------------------------------------------------
 
708
 
 
709
    def set_grid(self, x_step, y_step, line_segment=1, \
 
710
            blank_segment=0):
 
711
        self.grid = '%s,%s,%s,%s' % (x_step, y_step, line_segment, \
 
712
            blank_segment)
 
713
 
 
714
 
 
715
class ScatterChart(Chart):
 
716
 
 
717
    def type_to_url(self):
 
718
        return 'cht=s'
 
719
 
 
720
    def annotated_data(self):
 
721
        yield ('x', self.data[0])
 
722
        yield ('y', self.data[1])
 
723
        if len(self.data) > 2:
 
724
            # The optional third dataset is relative sizing for point
 
725
            # markers.
 
726
            yield ('marker-size', self.data[2])
 
727
 
 
728
 
 
729
class LineChart(Chart):
 
730
 
 
731
    def __init__(self, *args, **kwargs):
 
732
        if type(self) == LineChart:
 
733
            raise AbstractClassException('This is an abstract class')
 
734
        Chart.__init__(self, *args, **kwargs)
 
735
 
 
736
 
 
737
class SimpleLineChart(LineChart):
 
738
 
 
739
    def type_to_url(self):
 
740
        return 'cht=lc'
 
741
 
 
742
    def annotated_data(self):
 
743
        # All datasets are y-axis data.
 
744
        for dataset in self.data:
 
745
            yield ('y', dataset)
 
746
 
 
747
 
 
748
class SparkLineChart(SimpleLineChart):
 
749
 
 
750
    def type_to_url(self):
 
751
        return 'cht=ls'
 
752
 
 
753
 
 
754
class XYLineChart(LineChart):
 
755
 
 
756
    def type_to_url(self):
 
757
        return 'cht=lxy'
 
758
 
 
759
    def annotated_data(self):
 
760
        # Datasets alternate between x-axis, y-axis.
 
761
        for i, dataset in enumerate(self.data):
 
762
            if i % 2 == 0:
 
763
                yield ('x', dataset)
 
764
            else:
 
765
                yield ('y', dataset)
 
766
 
 
767
 
 
768
class BarChart(Chart):
 
769
 
 
770
    def __init__(self, *args, **kwargs):
 
771
        if type(self) == BarChart:
 
772
            raise AbstractClassException('This is an abstract class')
 
773
        Chart.__init__(self, *args, **kwargs)
 
774
        self.bar_width = None
 
775
        self.zero_lines = {}
 
776
 
 
777
    def set_bar_width(self, bar_width):
 
778
        self.bar_width = bar_width
 
779
 
 
780
    def set_zero_line(self, index, zero_line):
 
781
        self.zero_lines[index] = zero_line
 
782
 
 
783
    def get_url_bits(self, data_class=None, skip_chbh=False):
 
784
        url_bits = Chart.get_url_bits(self, data_class=data_class)
 
785
        if not skip_chbh and self.bar_width is not None:
 
786
            url_bits.append('chbh=%i' % self.bar_width)
 
787
        zero_line = []
 
788
        if self.zero_lines:
 
789
            for index in xrange(max(self.zero_lines) + 1):
 
790
                if index in self.zero_lines:
 
791
                    zero_line.append(str(self.zero_lines[index]))
 
792
                else:
 
793
                    zero_line.append('0')
 
794
            url_bits.append('chp=%s' % ','.join(zero_line))
 
795
        return url_bits
 
796
 
 
797
 
 
798
class StackedHorizontalBarChart(BarChart):
 
799
 
 
800
    def type_to_url(self):
 
801
        return 'cht=bhs'
 
802
 
 
803
 
 
804
class StackedVerticalBarChart(BarChart):
 
805
 
 
806
    def type_to_url(self):
 
807
        return 'cht=bvs'
 
808
 
 
809
    def annotated_data(self):
 
810
        for dataset in self.data:
 
811
            yield ('y', dataset)
 
812
 
 
813
 
 
814
class GroupedBarChart(BarChart):
 
815
 
 
816
    def __init__(self, *args, **kwargs):
 
817
        if type(self) == GroupedBarChart:
 
818
            raise AbstractClassException('This is an abstract class')
 
819
        BarChart.__init__(self, *args, **kwargs)
 
820
        self.bar_spacing = None
 
821
        self.group_spacing = None
 
822
 
 
823
    def set_bar_spacing(self, spacing):
 
824
        """Set spacing between bars in a group."""
 
825
        self.bar_spacing = spacing
 
826
 
 
827
    def set_group_spacing(self, spacing):
 
828
        """Set spacing between groups of bars."""
 
829
        self.group_spacing = spacing
 
830
 
 
831
    def get_url_bits(self, data_class=None):
 
832
        # Skip 'BarChart.get_url_bits' and call Chart directly so the parent
 
833
        # doesn't add "chbh" before we do.
 
834
        url_bits = BarChart.get_url_bits(self, data_class=data_class,
 
835
            skip_chbh=True)
 
836
        if self.group_spacing is not None:
 
837
            if self.bar_spacing is None:
 
838
                raise InvalidParametersException('Bar spacing is required ' \
 
839
                    'to be set when setting group spacing')
 
840
            if self.bar_width is None:
 
841
                raise InvalidParametersException('Bar width is required to ' \
 
842
                    'be set when setting bar spacing')
 
843
            url_bits.append('chbh=%i,%i,%i'
 
844
                % (self.bar_width, self.bar_spacing, self.group_spacing))
 
845
        elif self.bar_spacing is not None:
 
846
            if self.bar_width is None:
 
847
                raise InvalidParametersException('Bar width is required to ' \
 
848
                    'be set when setting bar spacing')
 
849
            url_bits.append('chbh=%i,%i' % (self.bar_width, self.bar_spacing))
 
850
        elif self.bar_width:
 
851
            url_bits.append('chbh=%i' % self.bar_width)
 
852
        return url_bits
 
853
 
 
854
 
 
855
class GroupedHorizontalBarChart(GroupedBarChart):
 
856
 
 
857
    def type_to_url(self):
 
858
        return 'cht=bhg'
 
859
 
 
860
 
 
861
class GroupedVerticalBarChart(GroupedBarChart):
 
862
 
 
863
    def type_to_url(self):
 
864
        return 'cht=bvg'
 
865
 
 
866
    def annotated_data(self):
 
867
        for dataset in self.data:
 
868
            yield ('y', dataset)
 
869
 
 
870
 
 
871
class PieChart(Chart):
 
872
 
 
873
    def __init__(self, *args, **kwargs):
 
874
        if type(self) == PieChart:
 
875
            raise AbstractClassException('This is an abstract class')
 
876
        Chart.__init__(self, *args, **kwargs)
 
877
        self.pie_labels = []
 
878
        if self.y_range:
 
879
            warnings.warn('y_range is not used with %s.' % \
 
880
                (self.__class__.__name__))
 
881
 
 
882
    def set_pie_labels(self, labels):
 
883
        self.pie_labels = [urllib.quote(a) for a in labels]
 
884
 
 
885
    def get_url_bits(self, data_class=None):
 
886
        url_bits = Chart.get_url_bits(self, data_class=data_class)
 
887
        if self.pie_labels:
 
888
            url_bits.append('chl=%s' % '|'.join(self.pie_labels))
 
889
        return url_bits
 
890
 
 
891
    def annotated_data(self):
 
892
        # Datasets are all y-axis data. However, there should only be
 
893
        # one dataset for pie charts.
 
894
        for dataset in self.data:
 
895
            yield ('x', dataset)
 
896
 
 
897
 
 
898
class PieChart2D(PieChart):
 
899
 
 
900
    def type_to_url(self):
 
901
        return 'cht=p'
 
902
 
 
903
 
 
904
class PieChart3D(PieChart):
 
905
 
 
906
    def type_to_url(self):
 
907
        return 'cht=p3'
 
908
 
 
909
 
 
910
class VennChart(Chart):
 
911
 
 
912
    def type_to_url(self):
 
913
        return 'cht=v'
 
914
 
 
915
    def annotated_data(self):
 
916
        for dataset in self.data:
 
917
            yield ('y', dataset)
 
918
 
 
919
 
 
920
class RadarChart(Chart):
 
921
 
 
922
    def type_to_url(self):
 
923
        return 'cht=r'
 
924
 
 
925
 
 
926
class SplineRadarChart(RadarChart):
 
927
 
 
928
    def type_to_url(self):
 
929
        return 'cht=rs'
 
930
 
 
931
 
 
932
class MapChart(Chart):
 
933
 
 
934
    def __init__(self, *args, **kwargs):
 
935
        Chart.__init__(self, *args, **kwargs)
 
936
        self.geo_area = 'world'
 
937
        self.codes = []
 
938
 
 
939
    def type_to_url(self):
 
940
        return 'cht=t'
 
941
 
 
942
    def set_codes(self, codes):
 
943
        self.codes = codes
 
944
 
 
945
    def get_url_bits(self, data_class=None):
 
946
        url_bits = Chart.get_url_bits(self, data_class=data_class)
 
947
        url_bits.append('chtm=%s' % self.geo_area)
 
948
        if self.codes:
 
949
            url_bits.append('chld=%s' % ''.join(self.codes))
 
950
        return url_bits
 
951
 
 
952
 
 
953
class GoogleOMeterChart(PieChart):
 
954
    """Inheriting from PieChart because of similar labeling"""
 
955
 
 
956
    def __init__(self, *args, **kwargs):
 
957
        PieChart.__init__(self, *args, **kwargs)
 
958
        if self.auto_scale and not self.x_range:
 
959
            warnings.warn('Please specify an x_range with GoogleOMeterChart, '
 
960
                'otherwise one arrow will always be at the max.')
 
961
 
 
962
    def type_to_url(self):
 
963
        return 'cht=gom'
 
964
 
 
965
 
 
966
class QRChart(Chart):
 
967
 
 
968
    def __init__(self, *args, **kwargs):
 
969
        Chart.__init__(self, *args, **kwargs)
 
970
        self.encoding = None
 
971
        self.ec_level = None
 
972
        self.margin = None
 
973
 
 
974
    def type_to_url(self):
 
975
        return 'cht=qr'
 
976
 
 
977
    def data_to_url(self, data_class=None):
 
978
        if not self.data:
 
979
            raise NoDataGivenException()
 
980
        return 'chl=%s' % urllib.quote(self.data[0])
 
981
 
 
982
    def get_url_bits(self, data_class=None):
 
983
        url_bits = Chart.get_url_bits(self, data_class=data_class)
 
984
        if self.encoding:
 
985
            url_bits.append('choe=%s' % self.encoding)
 
986
        if self.ec_level:
 
987
            url_bits.append('chld=%s|%s' % (self.ec_level, self.margin))
 
988
        return url_bits
 
989
 
 
990
    def set_encoding(self, encoding):
 
991
        self.encoding = encoding
 
992
 
 
993
    def set_ec(self, level, margin):
 
994
        self.ec_level = level
 
995
        self.margin = margin
 
996
 
 
997
 
 
998
class ChartGrammar(object):
 
999
 
 
1000
    def __init__(self):
 
1001
        self.grammar = None
 
1002
        self.chart = None
 
1003
 
 
1004
    def parse(self, grammar):
 
1005
        self.grammar = grammar
 
1006
        self.chart = self.create_chart_instance()
 
1007
 
 
1008
        for attr in self.grammar:
 
1009
            if attr in ('w', 'h', 'type', 'auto_scale', 'x_range', 'y_range'):
 
1010
                continue  # These are already parsed in create_chart_instance
 
1011
            attr_func = 'parse_' + attr
 
1012
            if not hasattr(self, attr_func):
 
1013
                warnings.warn('No parser for grammar attribute "%s"' % (attr))
 
1014
                continue
 
1015
            getattr(self, attr_func)(grammar[attr])
 
1016
 
 
1017
        return self.chart
 
1018
 
 
1019
    def parse_data(self, data):
 
1020
        self.chart.data = data
 
1021
 
 
1022
    @staticmethod
 
1023
    def get_possible_chart_types():
 
1024
        possible_charts = []
 
1025
        for cls_name in globals().keys():
 
1026
            if not cls_name.endswith('Chart'):
 
1027
                continue
 
1028
            cls = globals()[cls_name]
 
1029
            # Check if it is an abstract class
 
1030
            try:
 
1031
                a = cls(1, 1, auto_scale=False)
 
1032
                del a
 
1033
            except AbstractClassException:
 
1034
                continue
 
1035
            # Strip off "Class"
 
1036
            possible_charts.append(cls_name[:-5])
 
1037
        return possible_charts
 
1038
 
 
1039
    def create_chart_instance(self, grammar=None):
 
1040
        if not grammar:
 
1041
            grammar = self.grammar
 
1042
        assert(isinstance(grammar, dict))  # grammar must be a dict
 
1043
        assert('w' in grammar)  # width is required
 
1044
        assert('h' in grammar)  # height is required
 
1045
        assert('type' in grammar)  # type is required
 
1046
        chart_type = grammar['type']
 
1047
        w = grammar['w']
 
1048
        h = grammar['h']
 
1049
        auto_scale = grammar.get('auto_scale', None)
 
1050
        x_range = grammar.get('x_range', None)
 
1051
        y_range = grammar.get('y_range', None)
 
1052
        types = ChartGrammar.get_possible_chart_types()
 
1053
        if chart_type not in types:
 
1054
            raise UnknownChartType('%s is an unknown chart type. Possible '
 
1055
                'chart types are %s' % (chart_type, ','.join(types)))
 
1056
        return globals()[chart_type + 'Chart'](w, h, auto_scale=auto_scale,
 
1057
            x_range=x_range, y_range=y_range)
 
1058
 
 
1059
    def download(self):
 
1060
        pass
 
1061